"""Composition point for the Filigree issue tracker database.

Assembles DB mixins (db_files, db_issues, db_events, db_workflow, db_meta,
db_planning, db_observations) into the ``FiligreeDB`` class. Also provides
convention-based ``.filigree/`` directory discovery, configuration I/O,
template seeding, and shared file helpers.
"""

from __future__ import annotations

import contextlib
import json
import logging
import os
import shutil
import sqlite3
import sys
import uuid as _uuid
from pathlib import Path
from typing import TYPE_CHECKING, Any

from filigree.db_base import _now_iso
from filigree.db_events import EventsMixin
from filigree.db_files import (
    VALID_ASSOC_TYPES,
    VALID_FINDING_STATUSES,
    VALID_SEVERITIES,
    FilesMixin,
    _normalize_scan_path,
)
from filigree.db_issues import IssuesMixin
from filigree.db_meta import MetaMixin
from filigree.db_observations import ObservationsMixin
from filigree.db_planning import PlanningMixin
from filigree.db_scans import ScansMixin
from filigree.db_schema import CURRENT_SCHEMA_VERSION, SCHEMA_SQL
from filigree.db_workflow import WorkflowMixin
from filigree.models import _EMPTY_TS, FileRecord, Issue, ScanFinding
from filigree.types.core import (
    AssocType,
    FileRecordDict,
    FindingStatus,
    ISOTimestamp,
    IssueDict,
    PaginatedResult,
    ProjectConfig,
    ScanFindingDict,
    Severity,
)

if TYPE_CHECKING:
    from filigree.templates import TemplateRegistry

logger = logging.getLogger(__name__)

# Re-exported names from db_files, models, and types.core for backward compatibility.
__all__ = [
    "VALID_ASSOC_TYPES",
    "VALID_FINDING_STATUSES",
    "VALID_SEVERITIES",
    "_EMPTY_TS",
    "AssocType",
    "FileRecord",
    "FileRecordDict",
    "FindingStatus",
    "ISOTimestamp",
    "Issue",
    "IssueDict",
    "PaginatedResult",
    "ProjectConfig",
    "ScanFinding",
    "ScanFindingDict",
    "Severity",
    "_normalize_scan_path",
]


# ---------------------------------------------------------------------------
# Convention-based discovery
# ---------------------------------------------------------------------------

FILIGREE_DIR_NAME = ".filigree"
DB_FILENAME = "filigree.db"
CONFIG_FILENAME = "config.json"
SUMMARY_FILENAME = "context.md"


def find_filigree_root(start: Path | None = None) -> Path:
    """Walk up from start (default cwd) looking for .filigree/ directory.

    Returns the .filigree/ directory path (not the project root).
    """
    current = (start or Path.cwd()).resolve()
    for parent in [current, *current.parents]:
        candidate = parent / FILIGREE_DIR_NAME
        if candidate.is_dir():
            return candidate
    msg = f"No {FILIGREE_DIR_NAME}/ directory found in {current} or any parent"
    raise FileNotFoundError(msg)


def read_config(filigree_dir: Path) -> ProjectConfig:
    """Read .filigree/config.json. Returns defaults if missing or corrupt."""
    defaults = ProjectConfig(prefix="filigree", version=1, enabled_packs=["core", "planning", "release"])
    config_path = filigree_dir / CONFIG_FILENAME
    if not config_path.exists():
        return defaults
    try:
        raw: Any = json.loads(config_path.read_text())
        if not isinstance(raw, dict):
            logger.warning("Config %s is not a JSON object, using defaults", config_path)
            return defaults
        result: ProjectConfig = raw  # type: ignore[assignment]
        # Ensure required keys have defaults (config.json may predate these fields)
        if "prefix" not in result:
            result["prefix"] = defaults["prefix"]
        if "version" not in result:
            result["version"] = defaults["version"]
        return result
    except (json.JSONDecodeError, OSError) as exc:
        logger.warning("Failed to read %s, using defaults: %s", config_path, exc)
        return defaults


def write_config(filigree_dir: Path, config: dict[str, Any] | ProjectConfig) -> None:
    """Write .filigree/config.json."""
    config_path = filigree_dir / CONFIG_FILENAME
    write_atomic(config_path, json.dumps(config, indent=2) + "\n")


VALID_MODES: frozenset[str] = frozenset({"ethereal", "server"})


def get_mode(filigree_dir: Path) -> str:
    """Return the installation mode for a project. Defaults to 'ethereal'.

    Raises ValueError if the config contains an explicit but invalid mode string.
    """
    config = read_config(filigree_dir)
    mode: str = config.get("mode", "ethereal")
    if mode not in VALID_MODES:
        raise ValueError(f"Unknown mode {mode!r} in config. Valid modes: {sorted(VALID_MODES)}")
    return mode


# ---------------------------------------------------------------------------
# Shared CLI / file helpers
# ---------------------------------------------------------------------------


def find_filigree_command() -> list[str]:
    """Locate the filigree CLI command as a list of argument tokens.

    Resolution order:
    1. uv tool binary (~/.local/bin/filigree) -- stable global install
    2. shutil.which("filigree") -- absolute path if on PATH
    3. Sibling of running Python interpreter (covers venv case)
    4. sys.executable -m filigree -- module invocation fallback
    """
    # Prefer uv tool install — stable path that survives venv changes
    uv_tool_bin = Path.home() / ".local" / "bin" / "filigree"
    if uv_tool_bin.is_file():
        return [str(uv_tool_bin)]

    which = shutil.which("filigree")
    if which:
        return [which]

    # Check sibling of Python interpreter (common in venvs)
    python_dir = Path(sys.executable).parent
    candidate = python_dir / "filigree"
    if candidate.is_file():
        return [str(candidate)]

    return [sys.executable, "-m", "filigree"]


def write_atomic(path: Path, content: str) -> None:
    """Write content to path atomically via temp file + os.replace()."""
    tmp = path.with_suffix(path.suffix + ".tmp")
    try:
        tmp.write_text(content, encoding="utf-8")
        os.replace(tmp, path)
    except BaseException:
        with contextlib.suppress(OSError):
            tmp.unlink()
        raise


def _seed_builtin_packs(conn: sqlite3.Connection, now: str) -> int:
    """Seed built-in packs and type templates into the database.

    Returns the number of type templates seeded.
    """
    from filigree.templates_data import BUILT_IN_PACKS

    count = 0
    default_enabled = {"core", "planning", "release"}

    for pack_name, pack_data in BUILT_IN_PACKS.items():
        enabled = 1 if pack_name in default_enabled else 0
        conn.execute(
            "INSERT OR IGNORE INTO packs (name, version, definition, is_builtin, enabled) VALUES (?, ?, ?, 1, ?)",
            (pack_name, pack_data.get("version", "1.0"), json.dumps(pack_data), enabled),
        )
        logger.debug("Seeded pack: %s (enabled=%d)", pack_name, enabled)

        for type_name, type_data in pack_data.get("types", {}).items():
            conn.execute(
                "INSERT OR REPLACE INTO type_templates (type, pack, definition, is_builtin, created_at, updated_at) "
                "VALUES (?, ?, ?, 1, ?, ?)",
                (type_name, pack_name, json.dumps(type_data), now, now),
            )
            count += 1
            logger.debug("Seeded type template: %s (pack=%s)", type_name, pack_name)

    return count


# ---------------------------------------------------------------------------
# FiligreeDB — the core
# ---------------------------------------------------------------------------


class FiligreeDB(FilesMixin, ScansMixin, IssuesMixin, EventsMixin, WorkflowMixin, MetaMixin, PlanningMixin, ObservationsMixin):
    """Direct SQLite operations. No daemon, no sync. Importable by CLI and MCP."""

    def __init__(
        self,
        db_path: str | Path,
        *,
        prefix: str = "filigree",
        enabled_packs: list[str] | None = None,
        template_registry: TemplateRegistry | None = None,
        check_same_thread: bool = True,
    ) -> None:
        self.db_path = Path(db_path)
        self.prefix = prefix
        if enabled_packs is not None and isinstance(enabled_packs, str):
            msg = f"enabled_packs must be a list of strings, not a bare string: {enabled_packs!r}"
            raise TypeError(msg)
        self._enabled_packs_override = list(enabled_packs) if enabled_packs is not None else None
        self.enabled_packs = self._enabled_packs_override if self._enabled_packs_override is not None else ["core", "planning", "release"]
        self._conn: sqlite3.Connection | None = None
        self._check_same_thread = check_same_thread
        self._template_registry: TemplateRegistry | None = template_registry

    @classmethod
    def from_filigree_dir(cls, filigree_dir: Path, *, check_same_thread: bool = True) -> FiligreeDB:
        """Create a FiligreeDB from an existing ``.filigree/`` directory."""
        config = read_config(filigree_dir)
        db = cls(
            filigree_dir / DB_FILENAME,
            prefix=config.get("prefix", "filigree"),
            enabled_packs=config.get("enabled_packs"),
            check_same_thread=check_same_thread,
        )
        db.initialize()
        return db

    @classmethod
    def from_project(cls, project_path: Path | None = None) -> FiligreeDB:
        """Create a FiligreeDB by discovering .filigree/ from project_path (or cwd)."""
        filigree_dir = find_filigree_root(project_path)
        return cls.from_filigree_dir(filigree_dir)

    def __enter__(self) -> FiligreeDB:
        return self

    def __exit__(self, exc_type: type[BaseException] | None, *exc: object) -> None:
        if exc_type is not None and self._conn is not None:
            try:
                self._conn.rollback()
            except Exception:
                logger.error("Rollback failed during __exit__", exc_info=True)
            # After rollback, skip the commit in close() — the rolled-back
            # transaction's changes are lost. Skipping the commit avoids
            # accidentally committing any stray implicit transaction.
            try:
                self._close_no_commit()
            except Exception:
                logger.error("Close failed during __exit__", exc_info=True)
        else:
            self.close()

    @property
    def conn(self) -> sqlite3.Connection:
        if self._conn is None:
            self._conn = sqlite3.connect(
                str(self.db_path),
                isolation_level="DEFERRED",
                check_same_thread=self._check_same_thread,
            )
            self._conn.row_factory = sqlite3.Row
            self._conn.execute("PRAGMA journal_mode=WAL")
            self._conn.execute("PRAGMA foreign_keys=ON")
            self._conn.execute("PRAGMA busy_timeout=5000")
        return self._conn

    def initialize(self) -> None:
        """Create tables (if new) or migrate (if existing), then seed templates.

        For a fresh database (user_version == 0), creates all tables from
        SCHEMA_SQL and stamps the current version. For an existing database,
        applies any pending migrations to bring it up to CURRENT_SCHEMA_VERSION.
        """
        current_version = self.get_schema_version()

        if current_version == 0:
            # Fresh database — create everything from scratch
            self.conn.executescript(SCHEMA_SQL)
            self.conn.execute(f"PRAGMA user_version = {CURRENT_SCHEMA_VERSION}")
        elif current_version > CURRENT_SCHEMA_VERSION:
            msg = (
                f"Database schema v{current_version} is newer than this version of "
                f"filigree (expects v{CURRENT_SCHEMA_VERSION}). Downgrade is not supported."
            )
            raise ValueError(msg)
        elif current_version < CURRENT_SCHEMA_VERSION:
            # Existing database — apply pending migrations
            from filigree.migrations import apply_pending_migrations

            apply_pending_migrations(self.conn, CURRENT_SCHEMA_VERSION)

        self._seed_templates()
        self._seed_future_release()
        self.conn.commit()

    def _seed_future_release(self) -> None:
        """Create the "Future" release singleton if it doesn't exist.

        Only runs when the ``release`` pack is enabled. Uses raw SQL to
        avoid circular validation during init. Idempotent — skips if a
        release with ``version == "Future"`` already exists.
        """
        if "release" not in self.enabled_packs:
            return

        if self.templates.get_type("release") is None:
            logger.warning("Release pack enabled but 'release' type not registered — skipping Future release seed")
            return

        existing = self.conn.execute(
            "SELECT id FROM issues WHERE type = 'release' AND json_extract(fields, '$.version') = 'Future'"
        ).fetchone()
        if existing is not None:
            return

        initial_state = self.templates.get_initial_state("release")
        issue_id = f"{self.prefix}-{_uuid.uuid4().hex[:10]}"
        now = _now_iso()
        self.conn.execute(
            "INSERT INTO issues (id, title, status, priority, type, assignee, "
            "created_at, updated_at, description, notes, fields) "
            "VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
            (issue_id, "Future", initial_state, 4, "release", "", now, now, "", "", '{"version": "Future"}'),
        )
        logger.info("Seeded Future release singleton: %s", issue_id)

    def get_schema_version(self) -> int:
        """Return the current schema version from PRAGMA user_version."""
        result: int = self.conn.execute("PRAGMA user_version").fetchone()[0]
        return result

    def reconnect(self, *, check_same_thread: bool = True) -> None:
        """Close the current connection so the next access reopens it with a new ``check_same_thread`` setting.

        The reconnection is lazy — it happens on the next access to the
        ``self.conn`` property, which re-applies PRAGMAs at that point.

        If the connection has an in-flight transaction, it is rolled back to
        avoid persisting partial state.  Callers should ideally avoid calling
        this with an active transaction, as uncommitted work will be lost.

        Useful in tests where a DB created with the default
        ``check_same_thread=True`` needs to be shared across threads
        (e.g. async FastAPI test clients).
        """
        try:
            if self._conn is not None:
                try:
                    if self._conn.in_transaction:
                        logger.warning("reconnect: rolling back in-flight transaction")
                        self._conn.rollback()
                finally:
                    try:
                        self._conn.close()
                    finally:
                        self._conn = None
        finally:
            self._check_same_thread = check_same_thread

    def close(self) -> None:
        """Close the database connection.

        If an uncommitted transaction is active, it is rolled back with a
        warning — all mixin methods commit their own transactions, so this
        indicates a bug rather than normal operation.  When no transaction
        is active, a final commit is issued (a no-op in practice).
        """
        if self._conn is not None:
            try:
                if self._conn.in_transaction:
                    logger.warning("close: rolling back in-flight transaction")
                    self._conn.rollback()
                else:
                    self._conn.commit()
            finally:
                try:
                    self._conn.close()
                finally:
                    self._conn = None

    def _close_no_commit(self) -> None:
        """Close the connection without committing (used after rollback)."""
        if self._conn is not None:
            try:
                self._conn.close()
            finally:
                self._conn = None
