"""Local project registry backed by SQLite.

Stores metadata about all cokodo-enabled projects on this machine so that
any `co` command (and MCP tools) can discover and query them regardless of
their filesystem location.

Database: ~/.cokodo/cokodo.db  (see config.COKODO_HOME_DIR)

Tables:
    projects   — one row per registered project path
    snapshots  — lightweight cached content per project (status, context …)
    schema_version — single-row version tracking for migrations
"""

from __future__ import annotations

import hashlib
import json
import re
import sqlite3
import threading
from contextlib import contextmanager
from datetime import datetime, timezone
from pathlib import Path
from typing import Generator, Optional

from cokodo_agent.config import COKODO_HOME_DIR, VERSION

# ---------------------------------------------------------------------------
# Schema
# ---------------------------------------------------------------------------

SCHEMA_VERSION = 1

_DDL = """\
CREATE TABLE IF NOT EXISTS schema_version (
    version  INTEGER PRIMARY KEY
);

CREATE TABLE IF NOT EXISTS projects (
    id               TEXT PRIMARY KEY,
    name             TEXT NOT NULL,
    path             TEXT NOT NULL UNIQUE,
    stack            TEXT,
    protocol_version TEXT,
    agent_version    TEXT,
    status_summary   TEXT,
    tags             TEXT DEFAULT '[]',
    last_seen        TEXT NOT NULL,
    created_at       TEXT NOT NULL,
    is_valid         INTEGER NOT NULL DEFAULT 1
);

CREATE INDEX IF NOT EXISTS idx_projects_name     ON projects(name);
CREATE INDEX IF NOT EXISTS idx_projects_is_valid ON projects(is_valid);
CREATE INDEX IF NOT EXISTS idx_projects_last_seen ON projects(last_seen DESC);

CREATE TABLE IF NOT EXISTS snapshots (
    project_id  TEXT NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
    key         TEXT NOT NULL,
    content     TEXT,
    updated_at  TEXT NOT NULL,
    PRIMARY KEY (project_id, key)
);
"""

# ---------------------------------------------------------------------------
# Thread-local connection pool (one connection per thread)
# ---------------------------------------------------------------------------

_local = threading.local()


def _db_path() -> Path:
    return COKODO_HOME_DIR / "cokodo.db"


def _connect() -> sqlite3.Connection:
    """Return a thread-local SQLite connection, creating it if needed."""
    conn: Optional[sqlite3.Connection] = getattr(_local, "conn", None)
    db_file = _db_path()
    if conn is None or not db_file.exists():
        db_file.parent.mkdir(parents=True, exist_ok=True)
        conn = sqlite3.connect(str(db_file), check_same_thread=False)
        conn.row_factory = sqlite3.Row
        # WAL mode for concurrent access from multiple IDEs / MCP servers
        conn.execute("PRAGMA journal_mode=WAL")
        conn.execute("PRAGMA foreign_keys=ON")
        _local.conn = conn
        _ensure_schema(conn)
    return conn


@contextmanager
def _tx(conn: sqlite3.Connection) -> Generator[sqlite3.Connection, None, None]:
    """Context manager for a single transaction with automatic rollback on error."""
    try:
        yield conn
        conn.commit()
    except Exception:
        conn.rollback()
        raise


def _ensure_schema(conn: sqlite3.Connection) -> None:
    """Create tables and run migrations if needed."""
    conn.executescript(_DDL)
    row = conn.execute("SELECT version FROM schema_version").fetchone()
    if row is None:
        conn.execute("INSERT INTO schema_version (version) VALUES (?)", (SCHEMA_VERSION,))
        conn.commit()


# ---------------------------------------------------------------------------
# ID generation
# ---------------------------------------------------------------------------

def _project_id(path: Path) -> str:
    """Derive a stable 16-char hex ID from the normalized absolute path."""
    normalized = path.resolve().as_posix().lower()
    return hashlib.sha256(normalized.encode()).hexdigest()[:16]


def _now_iso() -> str:
    return datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")


# ---------------------------------------------------------------------------
# Metadata extraction helpers
# ---------------------------------------------------------------------------

def _extract_name(agent_dir: Path) -> str:
    """Derive a display name from context.md or the project directory name."""
    context_file = agent_dir / "project" / "context.md"
    if context_file.exists():
        try:
            text = context_file.read_text(encoding="utf-8")
            # Look for "## Project Name\n\n<name>" pattern
            m = re.search(r"##\s+Project\s+Name\s*\n+([^\n#{}]+)", text)
            if m:
                candidate = m.group(1).strip()
                if candidate and "{{" not in candidate and "[" not in candidate:
                    return candidate[:80]
        except OSError:
            pass
    return agent_dir.parent.name


def _extract_stack(agent_dir: Path) -> Optional[str]:
    """Extract tech stack from tech-stack.md.

    Supports multiple formats:
    - Markdown table with a Language/Backend/Frontend column
    - Heading like "## Primary Stack\\n\\nRust"
    - Bullet list like "- **Backend**: Python, FastAPI"
    """
    ts_file = agent_dir / "project" / "tech-stack.md"
    if not ts_file.exists():
        return None

    # Ordered: most-specific keyword first so "typescript" doesn't shadow "vue"
    _STACK_KEYWORDS = [
        "python", "rust", "typescript", "javascript", "go", "java",
        "kotlin", "swift", "dart", "flutter", "ruby", "php", "scala",
        "elixir", "haskell", "cpp", "c++", "c#", "csharp", "qt",
        "vue", "react", "angular", "node", "mixed",
    ]

    def _detect(raw: str) -> Optional[str]:
        low = raw.strip().lower()
        if not low or "{{" in low:
            return None
        for key in _STACK_KEYWORDS:
            if key in low:
                return key
        return None

    try:
        text = ts_file.read_text(encoding="utf-8")

        # 1. Table row: | Language | <value> | or | Backend Language | <value> |
        #    Also matches "| Backend | Python, FastAPI |" style rows
        m = re.search(
            r"\|\s*(?:[\w\s]*?language|backend|frontend)\s*\|\s*([^|{}\n]+?)\s*\|",
            text,
            re.IGNORECASE,
        )
        if m:
            result = _detect(m.group(1))
            if result:
                return result

        # 2. Heading followed by a bare tech name, e.g. "## Primary Stack\n\nRust"
        m = re.search(
            r"##\s+(?:primary\s+)?(?:tech\s+)?stack[^\n]*\n+([^\n#\-*|]{2,40})",
            text,
            re.IGNORECASE,
        )
        if m:
            result = _detect(m.group(1))
            if result:
                return result

        # 3. Bullet list: "- **Backend**: Python" or "- **Language**: Rust 1.75+"
        for m in re.finditer(
            r"-\s+\*{0,2}(?:language|backend|frontend|stack|primary)\*{0,2}\s*[:\-–]\s*([^\n,;|]+)",
            text,
            re.IGNORECASE,
        ):
            result = _detect(m.group(1))
            if result:
                return result

        # 4. Fallback: scan full text for any known keyword (first match wins)
        text_low = text.lower()
        for key in _STACK_KEYWORDS:
            if re.search(rf"\b{re.escape(key)}\b", text_low):
                return key

    except OSError:
        pass
    return None


def _extract_protocol_version(agent_dir: Path) -> Optional[str]:
    """Extract protocol version from manifest.json."""
    manifest = agent_dir / "manifest.json"
    if not manifest.exists():
        return None
    try:
        data = json.loads(manifest.read_text(encoding="utf-8"))
        return data.get("protocol_version") or data.get("version")
    except (OSError, json.JSONDecodeError):
        return None


def _extract_status_summary(agent_dir: Path) -> Optional[str]:
    """Extract the first non-empty line after '## Current Phase' from status.md."""
    status_file = agent_dir / "project" / "status.md"
    if not status_file.exists():
        return None
    try:
        text = status_file.read_text(encoding="utf-8")
        m = re.search(r"##\s+Current\s+Phase\s*\n+([^\n#]+)", text)
        if m:
            summary = m.group(1).strip()
            if summary:
                return summary[:120]
    except OSError:
        pass
    return None


# ---------------------------------------------------------------------------
# Public API
# ---------------------------------------------------------------------------

def register_project(agent_dir: Path) -> str:
    """Register or update a project in the registry.

    Called automatically on every `co` command when .agent/ is detected.
    Performs an UPSERT — safe to call repeatedly.

    Returns the project ID (16-char hex).
    """
    project_root = agent_dir.parent.resolve()
    project_id = _project_id(project_root)
    now = _now_iso()

    name = _extract_name(agent_dir)
    stack = _extract_stack(agent_dir)
    protocol_version = _extract_protocol_version(agent_dir)
    status_summary = _extract_status_summary(agent_dir)

    conn = _connect()
    with _tx(conn):
        existing = conn.execute(
            "SELECT id, created_at FROM projects WHERE id = ?", (project_id,)
        ).fetchone()

        if existing:
            conn.execute(
                """UPDATE projects
                   SET name=?, path=?, stack=?, protocol_version=?,
                       agent_version=?, status_summary=?, last_seen=?, is_valid=1
                   WHERE id=?""",
                (
                    name,
                    project_root.as_posix(),
                    stack,
                    protocol_version,
                    VERSION,
                    status_summary,
                    now,
                    project_id,
                ),
            )
        else:
            conn.execute(
                """INSERT INTO projects
                       (id, name, path, stack, protocol_version, agent_version,
                        status_summary, tags, last_seen, created_at, is_valid)
                   VALUES (?,?,?,?,?,?,?,'[]',?,?,1)""",
                (
                    project_id,
                    name,
                    project_root.as_posix(),
                    stack,
                    protocol_version,
                    VERSION,
                    status_summary,
                    now,
                    now,
                ),
            )

        # Update status snapshot
        if status_summary:
            _upsert_snapshot(conn, project_id, "status_summary", status_summary)

    return project_id


def _upsert_snapshot(
    conn: sqlite3.Connection, project_id: str, key: str, content: str
) -> None:
    """Insert or update a snapshot entry (must be called inside a transaction)."""
    now = _now_iso()
    conn.execute(
        """INSERT INTO snapshots (project_id, key, content, updated_at)
           VALUES (?,?,?,?)
           ON CONFLICT(project_id, key) DO UPDATE
               SET content=excluded.content, updated_at=excluded.updated_at""",
        (project_id, key, content, now),
    )


def list_projects(include_invalid: bool = False) -> list[dict]:
    """Return all registered projects as a list of dicts."""
    conn = _connect()
    if include_invalid:
        rows = conn.execute(
            "SELECT * FROM projects ORDER BY last_seen DESC"
        ).fetchall()
    else:
        rows = conn.execute(
            "SELECT * FROM projects WHERE is_valid=1 ORDER BY last_seen DESC"
        ).fetchall()
    return [dict(r) for r in rows]


def get_project(name_or_id: str) -> Optional[dict]:
    """Look up a project by its ID prefix or display name (case-insensitive)."""
    conn = _connect()
    # Exact ID match first
    row = conn.execute(
        "SELECT * FROM projects WHERE id=? AND is_valid=1", (name_or_id,)
    ).fetchone()
    if row:
        return dict(row)
    # ID prefix match (≥4 chars)
    if len(name_or_id) >= 4:
        row = conn.execute(
            "SELECT * FROM projects WHERE id LIKE ? AND is_valid=1 LIMIT 1",
            (name_or_id + "%",),
        ).fetchone()
        if row:
            return dict(row)
    # Name match (case-insensitive)
    row = conn.execute(
        "SELECT * FROM projects WHERE lower(name)=lower(?) AND is_valid=1 LIMIT 1",
        (name_or_id,),
    ).fetchone()
    if row:
        return dict(row)
    return None


def search_projects(keyword: str) -> list[dict]:
    """Search projects by name, stack, status_summary, or path (case-insensitive)."""
    conn = _connect()
    pattern = f"%{keyword.lower()}%"
    rows = conn.execute(
        """SELECT * FROM projects
           WHERE is_valid=1
             AND (lower(name) LIKE ?
                  OR lower(coalesce(stack,'')) LIKE ?
                  OR lower(coalesce(status_summary,'')) LIKE ?
                  OR lower(path) LIKE ?)
           ORDER BY last_seen DESC""",
        (pattern, pattern, pattern, pattern),
    ).fetchall()
    return [dict(r) for r in rows]


def get_snapshot(project_id: str, key: str) -> Optional[str]:
    """Read a cached snapshot value."""
    conn = _connect()
    row = conn.execute(
        "SELECT content FROM snapshots WHERE project_id=? AND key=?",
        (project_id, key),
    ).fetchone()
    return row["content"] if row else None


def unregister_project(path_or_id: str) -> bool:
    """Remove a project from the registry.

    Accepts an absolute path, a project ID, or a display name.
    Returns True if a row was deleted.
    """
    conn = _connect()

    # Try by path first
    p = Path(path_or_id)
    if p.exists() or "/" in path_or_id or "\\" in path_or_id:
        pid = _project_id(p)
        with _tx(conn):
            cur = conn.execute("DELETE FROM projects WHERE id=?", (pid,))
        return cur.rowcount > 0

    # Try by ID or name
    project = get_project(path_or_id)
    if project:
        with _tx(conn):
            cur = conn.execute("DELETE FROM projects WHERE id=?", (project["id"],))
        return cur.rowcount > 0

    return False


def gc_projects() -> tuple[int, int]:
    """Remove or invalidate registry entries whose paths no longer exist.

    Returns (marked_invalid, deleted) counts.
    """
    conn = _connect()
    rows = conn.execute(
        "SELECT id, path FROM projects WHERE is_valid=1"
    ).fetchall()

    marked = 0
    for row in rows:
        if not Path(row["path"]).exists():
            with _tx(conn):
                conn.execute(
                    "UPDATE projects SET is_valid=0 WHERE id=?", (row["id"],)
                )
            marked += 1

    # Delete entries already marked invalid for >7 days (cleanup)
    cutoff = datetime.now(timezone.utc)
    deleted = 0
    stale = conn.execute(
        "SELECT id, last_seen FROM projects WHERE is_valid=0"
    ).fetchall()
    for row in stale:
        try:
            ts = datetime.fromisoformat(row["last_seen"].replace("Z", "+00:00"))
            age_days = (cutoff - ts).days
            if age_days > 7:
                with _tx(conn):
                    conn.execute("DELETE FROM projects WHERE id=?", (row["id"],))
                deleted += 1
        except (ValueError, TypeError):
            pass

    return marked, deleted


def get_project_context_files(project: dict) -> dict[str, str]:
    """Read key .agent/ files from a registered project and return their contents.

    Returns a dict mapping relative path to content string.
    """
    agent_dir = Path(project["path"]) / ".agent"
    if not agent_dir.exists():
        return {}

    files_to_read = [
        "project/status.md",
        "project/context.md",
        "project/tech-stack.md",
        "project/commands.md",
        "project/known-issues.md",
    ]
    result: dict[str, str] = {}
    for rel in files_to_read:
        fpath = agent_dir / rel
        if fpath.exists():
            try:
                result[rel] = fpath.read_text(encoding="utf-8")
            except OSError:
                pass
    return result


def close() -> None:
    """Close the thread-local connection (call in tests for clean teardown)."""
    conn: Optional[sqlite3.Connection] = getattr(_local, "conn", None)
    if conn:
        conn.close()
        _local.conn = None
