import json
from collections.abc import Iterator
from datetime import datetime
from pathlib import Path
from typing import Any, cast

import sqlite_utils
from sqlite_utils.db import Table

from corpus_analyzer.core.models import (
    Chunk,
    CodeBlock,
    Document,
    DocumentCategory,
    DomainTag,
    Heading,
    Link,
    PythonSymbol,
)


class CorpusDatabase:
    """SQLite database for storing the document corpus."""

    def __init__(self, path: Path) -> None:
        """Initialize database connection."""
        self.path = path
        self.db = sqlite_utils.Database(path)

    def initialize(self) -> None:
        """Create database schema."""
        # Documents table
        self.db.executescript("""
            CREATE TABLE IF NOT EXISTS documents (
                id INTEGER PRIMARY KEY AUTOINCREMENT,
                path TEXT UNIQUE NOT NULL,
                relative_path TEXT NOT NULL,
                file_type TEXT NOT NULL,
                title TEXT NOT NULL,
                mtime TEXT NOT NULL,
                size_bytes INTEGER NOT NULL,
                headings TEXT,  -- JSON
                links TEXT,  -- JSON
                code_blocks TEXT,  -- JSON
                token_estimate INTEGER DEFAULT 0,
                module_docstring TEXT,
                imports TEXT,  -- JSON
                symbols TEXT,  -- JSON
                is_cli INTEGER DEFAULT 0,
                category TEXT DEFAULT 'unknown',
                category_confidence REAL DEFAULT 0.0,
                domain_tags TEXT,  -- JSON
                quality_score REAL DEFAULT 0.0,
                is_gold_standard INTEGER DEFAULT 0,
                content_hash TEXT DEFAULT '',
                last_modified REAL DEFAULT 0.0
            );

            CREATE TABLE IF NOT EXISTS chunks (
                id INTEGER PRIMARY KEY AUTOINCREMENT,
                document_id INTEGER NOT NULL,
                content TEXT NOT NULL,
                heading TEXT,
                chunk_index INTEGER NOT NULL,
                token_estimate INTEGER DEFAULT 0,
                FOREIGN KEY (document_id) REFERENCES documents(id)
            );

            CREATE INDEX IF NOT EXISTS idx_documents_category ON documents(category);
            CREATE INDEX IF NOT EXISTS idx_documents_file_type ON documents(file_type);
            CREATE INDEX IF NOT EXISTS idx_chunks_document_id ON chunks(document_id);
        """)

        # Migrate existing databases that pre-date the change-detection columns.
        existing_cols = {
            row[1] for row in self.db.execute("PRAGMA table_info(documents)").fetchall()
        }
        if "content_hash" not in existing_cols:
            self.db.execute("ALTER TABLE documents ADD COLUMN content_hash TEXT DEFAULT ''")
        if "last_modified" not in existing_cols:
            self.db.execute("ALTER TABLE documents ADD COLUMN last_modified REAL DEFAULT 0.0")

    def insert_document(self, doc: Document) -> int:
        """Insert or update a document and return its ID."""
        data = self._doc_to_dict(doc)
        path_str = str(doc.path)

        # Check if document already exists by path
        existing = self.db.execute(
            "SELECT id FROM documents WHERE path = ?", [path_str]
        ).fetchone()

        if existing:
            doc_id = int(existing[0])
            # Delete existing chunks to avoid orphans/duplicates
            cast(Table, self.db["chunks"]).delete_where("document_id = ?", [doc_id])
            # Update existing document
            cast(Table, self.db["documents"]).update(doc_id, data, alter=True)
            return doc_id

        # If not exists, insert new
        cast(Table, self.db["documents"]).insert(data, alter=True)
        return int(self.db.execute("SELECT last_insert_rowid()").fetchone()[0])

    def get_file_fingerprint(self, path_str: str) -> tuple[str, float] | None:
        """Return the stored (content_hash, last_modified) for a path, or None if not indexed.

        Args:
            path_str: Absolute path string as stored in the database.

        Returns:
            Tuple of (content_hash, last_modified) or None if path not found.
        """
        row = self.db.execute(
            "SELECT content_hash, last_modified FROM documents WHERE path = ?", [path_str]
        ).fetchone()
        if row is None:
            return None
        return (row[0] or "", float(row[1] or 0.0))

    def update_file_fingerprint(
        self, path_str: str, content_hash: str, last_modified: float
    ) -> None:
        """Update only the change-detection fingerprint for an already-indexed document.

        Called when mtime changed but content hash is unchanged — avoids a full re-extract.

        Args:
            path_str: Absolute path string identifying the document.
            content_hash: SHA256 hash of the file content.
            last_modified: Current mtime of the file.
        """
        cursor = self.db.execute(
            "UPDATE documents SET content_hash = ?, last_modified = ? WHERE path = ?",
            [content_hash, last_modified, path_str],
        )
        if cursor.rowcount == 0:
            raise ValueError(
                f"update_file_fingerprint: no document found for path {path_str!r}"
            )

    def _doc_to_dict(self, doc: Document) -> dict[str, Any]:
        """Convert a Document model to a database dictionary."""
        return {
            "path": str(doc.path),
            "relative_path": doc.relative_path,
            "file_type": doc.file_type,
            "title": doc.title,
            "mtime": doc.mtime.isoformat(),
            "size_bytes": doc.size_bytes,
            "headings": json.dumps([h.model_dump() for h in doc.headings]),
            "links": json.dumps([link.model_dump() for link in doc.links]),
            "code_blocks": json.dumps([c.model_dump() for c in doc.code_blocks]),
            "token_estimate": doc.token_estimate,
            "module_docstring": doc.module_docstring,
            "imports": json.dumps(doc.imports),
            "symbols": json.dumps([s.model_dump() for s in doc.symbols]),
            "is_cli": 1 if doc.is_cli else 0,
            "category": doc.category.value,
            "category_confidence": doc.category_confidence,
            "domain_tags": json.dumps([t.value for t in doc.domain_tags]),
            "quality_score": doc.quality_score,
            "is_gold_standard": 1 if doc.is_gold_standard else 0,
            "content_hash": doc.content_hash,
            "last_modified": doc.last_modified,
        }

    def insert_chunk(self, chunk: Chunk) -> int:
        """Insert a chunk and return its ID."""
        data = {
            "document_id": chunk.document_id,
            "content": chunk.content,
            "heading": chunk.heading,
            "chunk_index": chunk.chunk_index,
            "token_estimate": chunk.token_estimate,
        }
        cast(Table, self.db["chunks"]).insert(data)
        return int(self.db.execute("SELECT last_insert_rowid()").fetchone()[0])

    def get_documents(
        self,
        category: DocumentCategory | None = None,
        file_type: str | None = None,
    ) -> Iterator[Document]:
        """Retrieve documents with optional filtering."""
        query = "SELECT * FROM documents WHERE 1=1"
        params: list[str] = []

        if category:
            query += " AND category = ?"
            params.append(category.value)
        if file_type:
            query += " AND file_type = ?"
            params.append(file_type)

        # Execute once and get column names from description
        cursor = self.db.execute(query, params)
        cols = [desc[0] for desc in cursor.description]

        for row in cursor.fetchall():
            yield self._row_to_document(dict(zip(cols, row, strict=False)))

    def get_document_by_id(self, doc_id: int) -> Document | None:
        """Get a single document by ID."""
        row = self.db.execute(
            "SELECT * FROM documents WHERE id = ?", [doc_id]
        ).fetchone()
        if row:
            cols = [
                desc[0]
                for desc in self.db.execute("SELECT * FROM documents LIMIT 0").description
            ]
            return self._row_to_document(dict(zip(cols, row, strict=False)))
        return None

    def update_document_classification(
        self,
        doc_id: int,
        category: DocumentCategory,
        confidence: float,
        domain_tags: list[DomainTag] | None = None,
    ) -> None:
        """Update document classification."""
        data = {
            "category": category.value,
            "category_confidence": confidence,
        }
        if domain_tags is not None:
            data["domain_tags"] = json.dumps([t.value for t in domain_tags])

        cast(Table, self.db["documents"]).update(doc_id, data, alter=True)

    def update_document_tags(self, doc_id: int, domain_tags: list[DomainTag]) -> None:
        """Update only document domain tags."""
        cast(Table, self.db["documents"]).update(
            doc_id,
            {"domain_tags": json.dumps([t.value for t in domain_tags])},
            alter=True
        )

    def update_document_quality(
        self, doc_id: int, score: float, is_gold_standard: bool
    ) -> None:
        """Update document quality metrics."""
        cast(Table, self.db["documents"]).update(
            doc_id,
            {
                "quality_score": score,
                "is_gold_standard": 1 if is_gold_standard else 0,
            },
            alter=True
        )

    def set_gold_standard(self, doc_id: int, is_gold: bool) -> None:
        """Toggle gold standard status for a document."""
        cast(Table, self.db["documents"]).update(
            doc_id,
            {"is_gold_standard": 1 if is_gold else 0},
            alter=True
        )

    def get_document(self, doc_id: int) -> Document | None:
        """Alias for get_document_by_id."""
        return self.get_document_by_id(doc_id)

    def get_gold_standard_documents(
        self,
        category: DocumentCategory | None = None,
        tag: DomainTag | None = None,
    ) -> Iterator[Document]:
        """Retrieve gold standard documents."""
        query = "SELECT * FROM documents WHERE is_gold_standard = 1"
        params: list[str] = []

        if category:
            query += " AND category = ?"
            params.append(category.value)
        if tag:
            # Domain tags are stored as JSON list, so we use LIKE or JSON_EACH
            # For simplicity with SQLite, we can use LIKE since it's a fixed set of tags
            query += " AND domain_tags LIKE ?"
            params.append(f'%"{tag.value}"%')

        cursor = self.db.execute(query, params)
        cols = [desc[0] for desc in cursor.description]

        for row in cursor.fetchall():
            yield self._row_to_document(dict(zip(cols, row, strict=False)))

    def get_categories(self) -> list[str]:
        """Get all unique categories in the corpus."""
        rows = self.db.execute(
            "SELECT DISTINCT category FROM documents WHERE category != 'unknown'"
        ).fetchall()
        return [row[0] for row in rows]

    def count_by_category(self) -> dict[str, int]:
        """Count documents per category."""
        rows = self.db.execute(
            "SELECT category, COUNT(*) FROM documents GROUP BY category"
        ).fetchall()
        return {row[0]: row[1] for row in rows}

    def _row_to_document(self, row: dict[str, Any]) -> Document:
        """Convert a database row to a Document model."""
        _cc = row.get("category_confidence")
        _qs = row.get("quality_score")
        return Document(
            id=row["id"],
            path=Path(row["path"]),
            relative_path=row["relative_path"],
            file_type=row["file_type"],
            title=row["title"],
            mtime=datetime.fromisoformat(row["mtime"]),
            size_bytes=row["size_bytes"],
            headings=[Heading(**h) for h in json.loads(row["headings"] or "[]")],
            links=[Link(**lnk) for lnk in json.loads(row.get("links") or "[]")],
            code_blocks=[CodeBlock(**c) for c in json.loads(row.get("code_blocks") or "[]")],
            token_estimate=int(row.get("token_estimate") or 0),
            module_docstring=row.get("module_docstring"),
            imports=json.loads(row.get("imports") or "[]"),
            symbols=[PythonSymbol(**s) for s in json.loads(row.get("symbols") or "[]")],
            is_cli=bool(row.get("is_cli", 0)),
            category=DocumentCategory(row.get("category", "unknown")),
            category_confidence=float(_cc) if _cc is not None else 0.0,
            domain_tags=[DomainTag(t) for t in json.loads(row.get("domain_tags") or "[]")],
            quality_score=float(_qs) if _qs is not None else 0.0,
            is_gold_standard=bool(
                row.get("is_gold_standard")
                if row.get("is_gold_standard") is not None
                else 0
            ),
            content_hash=row.get("content_hash") or "",
            last_modified=float(row.get("last_modified") or 0.0),
        )
