#!/usr/bin/env python3
"""
Base Adaptor for Multi-LLM Support

Defines the abstract interface that all platform-specific adaptors must implement.
This enables Skill Seekers to generate skills for multiple LLM platforms (Claude, Gemini, ChatGPT).
"""

import os
import shutil
from abc import ABC, abstractmethod
from dataclasses import dataclass, field
from pathlib import Path
from typing import Any

from skill_seekers.cli.arguments.common import DEFAULT_CHUNK_TOKENS, DEFAULT_CHUNK_OVERLAP_TOKENS


def save_skill_md_atomic(skill_md_path: Path, content: str, *, log_prefix: str = "  ") -> None:
    """Atomically replace SKILL.md, keeping a backup of the original.

    Write to a temp file, back up the original by COPY (not rename — so the
    original survives if anything here fails), then os.replace() the temp into
    place. The old rename-then-write left only SKILL.md.backup (no SKILL.md)
    if the write failed after the rename. Single home for the dance — callers
    must not hand-roll their own variants.
    """
    skill_md_path = Path(skill_md_path)
    tmp_path = skill_md_path.with_suffix(".md.tmp")
    try:
        tmp_path.write_text(content, encoding="utf-8")
        if skill_md_path.exists():
            backup_path = skill_md_path.with_suffix(".md.backup")
            shutil.copy2(skill_md_path, backup_path)
            print(f"{log_prefix}💾 Backed up original to: {backup_path.name}")
        os.replace(tmp_path, skill_md_path)
    finally:
        # Don't leave an orphaned .md.tmp behind if the backup/replace failed.
        if tmp_path.exists():
            tmp_path.unlink(missing_ok=True)
    print(f"{log_prefix}✅ Saved enhanced SKILL.md")


@dataclass
class SkillMetadata:
    """Universal skill metadata used across all platforms"""

    name: str
    description: str
    version: str = "1.0.0"
    doc_version: str = ""  # Documentation version (e.g., "16.2") for RAG metadata filtering
    author: str | None = None
    tags: list[str] = field(default_factory=list)


class SkillAdaptor(ABC):
    """
    Abstract base class for platform-specific skill adaptors.

    Each platform (Claude, Gemini, OpenAI) implements this interface to handle:
    - Platform-specific SKILL.md formatting
    - Platform-specific package structure (ZIP, tar.gz, etc.)
    - Platform-specific upload endpoints and authentication
    - Optional AI enhancement capabilities
    """

    # Platform identifiers (override in subclasses)
    PLATFORM: str = "unknown"  # e.g., "claude", "gemini", "openai"
    PLATFORM_NAME: str = "Unknown"  # e.g., "Claude AI (Anthropic)"
    DEFAULT_API_ENDPOINT: str | None = None

    def __init__(self, config: dict[str, Any] | None = None):
        """
        Initialize adaptor with optional configuration.

        Args:
            config: Platform-specific configuration options
        """
        self.config = config or {}

    @abstractmethod
    def format_skill_md(self, skill_dir: Path, metadata: SkillMetadata) -> str:
        """
        Format SKILL.md content with platform-specific frontmatter/structure.

        Different platforms require different formats:
        - Claude: YAML frontmatter + markdown
        - Gemini: Plain markdown (no frontmatter)
        - OpenAI: Assistant instructions format

        Args:
            skill_dir: Path to skill directory containing references/
            metadata: Skill metadata (name, description, version, etc.)

        Returns:
            Formatted SKILL.md content as string
        """
        pass

    @abstractmethod
    def package(
        self,
        skill_dir: Path,
        output_path: Path,
        enable_chunking: bool = False,
        chunk_max_tokens: int = DEFAULT_CHUNK_TOKENS,
        preserve_code_blocks: bool = True,
        chunk_overlap_tokens: int = DEFAULT_CHUNK_OVERLAP_TOKENS,
    ) -> Path:
        """
        Package skill for platform (ZIP, tar.gz, etc.).

        Different platforms require different package formats:
        - Claude: .zip with SKILL.md, references/, scripts/, assets/
        - Gemini: .tar.gz with system_instructions.md, references/
        - OpenAI: .zip with assistant_instructions.txt, vector_store_files/

        Args:
            skill_dir: Path to skill directory to package
            output_path: Path for output package (file or directory)
            enable_chunking: Enable intelligent chunking for large documents
            chunk_max_tokens: Maximum tokens per chunk (default: 512)
            preserve_code_blocks: Preserve code blocks during chunking

        Returns:
            Path to created package file
        """
        pass

    @abstractmethod
    def upload(self, package_path: Path, api_key: str, **kwargs) -> dict[str, Any]:
        """
        Upload packaged skill to platform.

        Returns a standardized response dictionary for all platforms.

        Args:
            package_path: Path to packaged skill file
            api_key: Platform API key
            **kwargs: Additional platform-specific arguments

        Returns:
            Dictionary with keys:
            - success (bool): Whether upload succeeded
            - skill_id (str|None): Platform-specific skill/assistant ID
            - url (str|None): URL to view/manage skill
            - message (str): Success or error message
        """
        pass

    def validate_api_key(self, api_key: str) -> bool:
        """
        Validate API key format for this platform.

        Default implementation just checks if key is non-empty.
        Override for platform-specific validation.

        Args:
            api_key: API key to validate

        Returns:
            True if key format is valid
        """
        return bool(api_key and api_key.strip())

    def get_env_var_name(self) -> str:
        """
        Get expected environment variable name for API key.

        Returns:
            Environment variable name (e.g., "ANTHROPIC_API_KEY", "GOOGLE_API_KEY")
        """
        return f"{self.PLATFORM.upper()}_API_KEY"

    def supports_enhancement(self) -> bool:
        """
        Whether this platform supports AI-powered SKILL.md enhancement.

        Returns:
            True if platform can enhance skills
        """
        return False

    def supports_upload(self) -> bool:
        """
        Whether this platform's ``upload()`` performs a real upload/push.

        Default is False. Adaptors whose ``upload()`` only returns an
        "unsupported" message (e.g. markdown, or vector-DB adaptors that direct
        users to a client library) leave this False; only adaptors that actually
        push to a remote platform override it to True. Used to derive the
        ``--target`` choices for the upload command from the registry.

        Returns:
            True if platform can upload skills
        """
        return False

    def enhance(self, _skill_dir: Path, _api_key: str) -> bool:
        """
        Optionally enhance SKILL.md using platform's AI.

        Only called if supports_enhancement() returns True.

        Args:
            skill_dir: Path to skill directory
            api_key: Platform API key

        Returns:
            True if enhancement succeeded
        """
        return False

    def _read_reference_files(
        self, references_dir: Path, max_chars: int = 200000
    ) -> dict[str, str]:
        """
        Read reference markdown files from skill directory.

        Single canonical copy — claude/openai/gemini carried byte-identical
        versions and openai_compatible a cosmetic variant.

        Args:
            references_dir: Path to references directory
            max_chars: Maximum total characters to read

        Returns:
            Dictionary mapping filename to content
        """
        if not references_dir.exists():
            return {}

        references = {}
        total_chars = 0

        for ref_file in sorted(references_dir.rglob("*.md")):
            if total_chars >= max_chars:
                break

            try:
                content = ref_file.read_text(encoding="utf-8")
                # Truncate very large files
                if len(content) > 30000:
                    content = content[:30000] + "\n\n...(truncated)"

                references[ref_file.name] = content
                total_chars += len(content)

            except Exception as e:
                print(f"  ⚠ Could not read {ref_file.name}: {e}")

        return references

    def _enhance_skill_md_via_client(
        self,
        skill_dir: Path,
        api_key: str,
        *,
        provider: str,
        base_url: str | None = None,
        model: str | None = None,
        system: str | None = None,
        temperature: float | None = 0.3,
    ) -> bool:
        """Shared SKILL.md enhancement flow for all API-capable adaptors.

        read references → build prompt (per-adaptor ``_build_enhancement_prompt``)
        → AgentClient (single AI transport: central truncation gate, timeout
        policy, error classification) → atomic save with backup.

        Adaptors supply only their provider/endpoint/model/system parameters —
        they must NOT hand-roll SDK clients (that's how gemini/openai shipped
        without a truncation gate and with a rename-then-write save).
        """
        from skill_seekers.cli.agent_client import AgentClient

        skill_dir = Path(skill_dir)
        references_dir = skill_dir / "references"
        skill_md_path = skill_dir / "SKILL.md"

        # Read reference files
        print("📖 Reading reference documentation...")
        references = self._read_reference_files(references_dir)

        if not references:
            print("❌ No reference files found to analyze")
            return False

        print(f"  ✓ Read {len(references)} reference files")
        total_size = sum(len(c) for c in references.values())
        print(f"  ✓ Total size: {total_size:,} characters\n")

        # Read current SKILL.md
        current_skill_md = None
        if skill_md_path.exists():
            current_skill_md = skill_md_path.read_text(encoding="utf-8")
            print(f"  ℹ Found existing SKILL.md ({len(current_skill_md)} chars)")
        else:
            print("  ℹ No existing SKILL.md, will create new one")

        # Build enhancement prompt
        prompt = self._build_enhancement_prompt(skill_dir.name, references, current_skill_md)

        print(f"\n🤖 Asking {self.PLATFORM_NAME} to enhance SKILL.md...")
        print(f"   Input: {len(prompt):,} characters")

        try:
            client = AgentClient(
                mode="api",
                api_key=api_key,
                provider=provider,
                base_url=base_url,
                model=model,
            )
        except RuntimeError as e:
            print(f"❌ {e}")
            return False
        # A missing SDK makes AgentClient fall back to LOCAL mode — an adaptor
        # enhancement must use the platform API or fail, never spawn a CLI agent.
        if client.mode != "api" or client.client is None:
            print(f"❌ {self.PLATFORM_NAME} SDK not available for API enhancement")
            return False

        # 16384: a rewritten SKILL.md can exceed ~16 KB; 4096 made any such
        # skill permanently un-enhanceable (truncation gate → None every run).
        enhanced_content = client.call(
            prompt, max_tokens=16384, system=system, temperature=temperature
        )
        # AgentClient returns None on truncation (max_tokens), rate limits,
        # auth and connection errors — all logged there with classification.
        if not enhanced_content or not enhanced_content.strip():
            print("❌ Empty or truncated enhancement response; leaving SKILL.md intact.")
            return False

        print(f"  ✓ Generated enhanced SKILL.md ({len(enhanced_content)} chars)\n")

        save_skill_md_atomic(skill_md_path, enhanced_content)

        return True

    def _read_existing_content(self, skill_dir: Path) -> str:
        """
        Helper to read existing SKILL.md content (without frontmatter).

        Args:
            skill_dir: Path to skill directory

        Returns:
            SKILL.md content without YAML frontmatter
        """
        skill_md_path = skill_dir / "SKILL.md"
        if not skill_md_path.exists():
            return ""

        content = skill_md_path.read_text(encoding="utf-8")

        # Strip YAML frontmatter if present
        if content.startswith("---"):
            parts = content.split("---", 2)
            if len(parts) >= 3:
                return parts[2].strip()

        return content

    def _extract_quick_reference(self, skill_dir: Path) -> str:
        """
        Helper to extract quick reference section from references.

        Args:
            skill_dir: Path to skill directory

        Returns:
            Quick reference content as markdown string
        """
        index_path = skill_dir / "references" / "index.md"
        if not index_path.exists():
            return "See references/ directory for documentation."

        # Read index and extract relevant sections
        content = index_path.read_text(encoding="utf-8")
        return content[:500] + "..." if len(content) > 500 else content

    def _read_skill_md(self, skill_dir: Path) -> str:
        """
        Read SKILL.md file with error handling.

        Args:
            skill_dir: Path to skill directory

        Returns:
            SKILL.md contents

        Raises:
            FileNotFoundError: If SKILL.md doesn't exist
        """
        skill_md_path = skill_dir / "SKILL.md"

        if not skill_md_path.exists():
            # Return empty string instead of raising - let adaptors decide how to handle
            return ""

        return skill_md_path.read_text(encoding="utf-8")

    def _read_frontmatter(self, skill_dir: Path) -> dict[str, str]:
        """Read YAML frontmatter from SKILL.md.

        Args:
            skill_dir: Path to skill directory

        Returns:
            Dict of key-value pairs from the frontmatter block.
        """
        content = self._read_skill_md(skill_dir)
        if content.startswith("---"):
            parts = content.split("---", 2)
            if len(parts) >= 3:
                frontmatter: dict[str, str] = {}
                for line in parts[1].strip().splitlines():
                    if ":" in line:
                        key, _, value = line.partition(":")
                        frontmatter[key.strip()] = value.strip()
                return frontmatter
        return {}

    def _build_skill_metadata(self, skill_dir: Path) -> SkillMetadata:
        """Build SkillMetadata from SKILL.md frontmatter.

        Reads name, description, version, and doc_version from frontmatter
        instead of using hardcoded defaults.

        Args:
            skill_dir: Path to skill directory

        Returns:
            SkillMetadata populated from frontmatter values.
        """
        fm = self._read_frontmatter(skill_dir)
        return SkillMetadata(
            name=skill_dir.name,
            description=fm.get("description", f"Documentation for {skill_dir.name}"),
            version=fm.get("version", "1.0.0"),
            doc_version=fm.get("doc_version", ""),
        )

    def _iterate_references(self, skill_dir: Path):
        """
        Iterate over all reference files in skill directory.

        Args:
            skill_dir: Path to skill directory

        Yields:
            Tuple of (file_path, file_content)
        """
        references_dir = skill_dir / "references"

        if not references_dir.exists():
            return

        for ref_file in sorted(references_dir.rglob("*.md")):
            if ref_file.is_file() and not ref_file.name.startswith("."):
                try:
                    content = ref_file.read_text(encoding="utf-8")
                    yield ref_file, content
                except Exception as e:
                    print(f"⚠️  Warning: Could not read {ref_file.name}: {e}")
                    continue

    def _build_metadata_dict(self, metadata: SkillMetadata, **extra: Any) -> dict[str, Any]:
        """
        Build standard metadata dictionary from SkillMetadata.

        Args:
            metadata: SkillMetadata object
            **extra: Additional platform-specific fields

        Returns:
            Metadata dictionary
        """
        base_meta = {
            "source": metadata.name,
            "version": metadata.version,
            "doc_version": metadata.doc_version,
            "description": metadata.description,
        }
        if metadata.author:
            base_meta["author"] = metadata.author
        if metadata.tags:
            base_meta["tags"] = metadata.tags
        base_meta.update(extra)
        return base_meta

    def _maybe_chunk_content(
        self,
        content: str,
        metadata: dict,
        enable_chunking: bool = False,
        chunk_max_tokens: int = DEFAULT_CHUNK_TOKENS,
        preserve_code_blocks: bool = True,
        source_file: str = None,
        chunk_overlap_tokens: int = DEFAULT_CHUNK_OVERLAP_TOKENS,
    ) -> list[tuple[str, dict]]:
        """
        Optionally chunk content for RAG platforms.

        Args:
            content: Document content to chunk
            metadata: Base metadata for document
            enable_chunking: Whether to enable chunking
            chunk_max_tokens: Maximum tokens per chunk
            preserve_code_blocks: Preserve code blocks during chunking
            source_file: Source file name for tracking

        Returns:
            List of (chunk_text, chunk_metadata) tuples
            If chunking disabled or doc small: [(content, metadata)]
            If chunking enabled: [(chunk1, meta1), (chunk2, meta2), ...]
        """
        # Skip chunking if disabled or document is small
        if not enable_chunking:
            return [(content, metadata)]

        # Estimate tokens (~4 chars per token)
        estimated_tokens = len(content) // 4

        # Add some buffer for safety (20%)
        if estimated_tokens < (chunk_max_tokens * 0.8):
            # Document fits in single chunk (with buffer)
            return [(content, metadata)]

        # Initialize chunker with current settings (don't reuse to allow different settings per call)
        try:
            from skill_seekers.cli.rag_chunker import RAGChunker
        except ImportError:
            # RAGChunker not available - fall back to no chunking
            print("⚠️  Warning: RAGChunker not available, chunking disabled")
            return [(content, metadata)]

        # RAGChunker uses TOKENS (it converts to chars internally)
        # If overlap is at the default value but chunk size was customized,
        # scale overlap proportionally (10% of chunk size, min DEFAULT_CHUNK_OVERLAP_TOKENS)
        effective_overlap = chunk_overlap_tokens
        if (
            chunk_overlap_tokens == DEFAULT_CHUNK_OVERLAP_TOKENS
            and chunk_max_tokens != DEFAULT_CHUNK_TOKENS
        ):
            effective_overlap = max(DEFAULT_CHUNK_OVERLAP_TOKENS, chunk_max_tokens // 10)

        chunker = RAGChunker(
            chunk_size=chunk_max_tokens,
            chunk_overlap=effective_overlap,
            preserve_code_blocks=preserve_code_blocks,
            preserve_paragraphs=True,
            min_chunk_size=100,  # 100 tokens minimum
        )

        # Chunk the document
        chunks = chunker.chunk_document(
            text=content,
            metadata=metadata,
            source_file=source_file or metadata.get("file", "unknown"),
        )

        # Convert RAGChunker output format to (text, metadata) tuples
        result = []
        for chunk_dict in chunks:
            chunk_text = chunk_dict["page_content"]
            chunk_meta = {
                **metadata,  # Base metadata
                **chunk_dict["metadata"],  # RAGChunker metadata (chunk_index, etc.)
                "is_chunked": True,
                "chunk_id": chunk_dict["chunk_id"],
            }
            result.append((chunk_text, chunk_meta))

        return result

    def _format_output_path(self, skill_dir: Path, output_path: Path, suffix: str) -> Path:
        """
        Generate standardized output path with intelligent format handling.

        Handles three cases:
        1. output_path is a directory → generate filename with suffix
        2. output_path is a file without correct suffix → fix extension and add suffix
        3. output_path is already correct → use as-is

        Args:
            skill_dir: Input skill directory
            output_path: Output path (file or directory)
            suffix: Platform-specific suffix (e.g., "-langchain.json")

        Returns:
            Output file path with correct extension and suffix
        """
        skill_name = skill_dir.name

        # Case 1: Directory path - generate filename
        if output_path.is_dir() or str(output_path).endswith("/"):
            return Path(output_path) / f"{skill_name}{suffix}"

        # Case 2: File path without correct extension - fix it
        output_str = str(output_path)

        # Extract the file extension from suffix (e.g., ".json" from "-langchain.json")
        correct_ext = suffix.split(".")[-1] if "." in suffix else ""

        if correct_ext and not output_str.endswith(f".{correct_ext}"):
            # Replace common incorrect extensions
            output_str = output_str.replace(".zip", f".{correct_ext}").replace(
                ".tar.gz", f".{correct_ext}"
            )

            # Ensure platform suffix is present
            if not output_str.endswith(suffix):
                output_str = output_str.replace(f".{correct_ext}", suffix)

            # Add extension if still missing
            if not output_str.endswith(f".{correct_ext}"):
                output_str += f".{correct_ext}"

        return Path(output_str)

    def _generate_deterministic_id(self, content: str, metadata: dict, format: str = "hex") -> str:
        """
        Generate deterministic ID from content and metadata.

        Provides consistent ID generation across all RAG adaptors with platform-specific formatting.

        Args:
            content: Document content
            metadata: Document metadata
            format: ID format - 'hex', 'uuid', or 'uuid5'
                - 'hex': Plain MD5 hex digest (32 chars) - used by Chroma, FAISS
                - 'uuid': UUID format from MD5 (8-4-4-4-12) - used by Weaviate, Qdrant
                - 'uuid5': RFC 4122 UUID v5 (SHA-1 based) - used by LlamaIndex

        Returns:
            Generated ID string in requested format
        """
        import hashlib
        import uuid

        # Create stable input for hashing
        id_string = f"{metadata.get('source', '')}-{metadata.get('file', '')}-{content[:100]}"

        if format == "uuid5":
            # UUID v5 (SHA-1 based, RFC 4122 compliant)
            return str(uuid.uuid5(uuid.NAMESPACE_DNS, id_string))

        # For hex and uuid formats, use MD5
        hash_obj = hashlib.md5(id_string.encode())
        hash_hex = hash_obj.hexdigest()

        if format == "uuid":
            # Format as UUID (8-4-4-4-12)
            return f"{hash_hex[:8]}-{hash_hex[8:12]}-{hash_hex[12:16]}-{hash_hex[16:20]}-{hash_hex[20:32]}"
        else:  # format == "hex"
            # Plain hex digest
            return hash_hex

    def _generate_openai_embeddings(
        self, documents: list[str], api_key: str | None = None
    ) -> list[list[float]]:
        """Generate embeddings using OpenAI text-embedding-3-small.

        Args:
            documents: List of document texts
            api_key: OpenAI API key (or uses OPENAI_API_KEY env var)

        Returns:
            List of embedding vectors
        """
        import os

        try:
            from openai import OpenAI
        except ImportError:
            raise ImportError("openai not installed. Run: pip install openai") from None

        api_key = api_key or os.getenv("OPENAI_API_KEY")
        if not api_key:
            raise ValueError("OPENAI_API_KEY not set. Set via env var or --openai-api-key")

        client = OpenAI(api_key=api_key)
        embeddings: list[list[float]] = []
        batch_size = 100

        print(f"  Generating OpenAI embeddings for {len(documents)} documents...")

        for i in range(0, len(documents), batch_size):
            batch = documents[i : i + batch_size]
            try:
                response = client.embeddings.create(input=batch, model="text-embedding-3-small")
                embeddings.extend([item.embedding for item in response.data])
                print(f"  ✓ Embedded {min(i + batch_size, len(documents))}/{len(documents)}")
            except Exception as e:
                raise Exception(f"OpenAI embedding generation failed: {e}") from e

        return embeddings

    def _generate_st_embeddings(self, documents: list[str]) -> list[list[float]]:
        """Generate embeddings using sentence-transformers (all-MiniLM-L6-v2).

        Args:
            documents: List of document texts

        Returns:
            List of embedding vectors
        """
        try:
            from sentence_transformers import SentenceTransformer
        except ImportError:
            raise ImportError(
                "sentence-transformers not installed. Run: pip install sentence-transformers"
            ) from None

        print(f"  Generating sentence-transformer embeddings for {len(documents)} documents...")
        model = SentenceTransformer("all-MiniLM-L6-v2")
        embeddings = model.encode(documents, show_progress_bar=True)
        return [emb.tolist() for emb in embeddings]

    def _generate_toc(self, skill_dir: Path) -> str:
        """
        Helper to generate table of contents from references.

        Args:
            skill_dir: Path to skill directory

        Returns:
            Table of contents as markdown string
        """
        refs_dir = skill_dir / "references"
        if not refs_dir.exists():
            return ""

        toc_lines = []
        for ref_file in sorted(refs_dir.rglob("*.md")):
            if ref_file.name == "index.md":
                continue
            title = ref_file.stem.replace("_", " ").title()
            toc_lines.append(f"- [{title}](references/{ref_file.name})")

        return "\n".join(toc_lines)
