"""Local manifest — maps SHA-256 hashes to file paths and metadata.

The manifest is a SQLite database (WAL mode) stored alongside the scanned
directory, providing the bridge between CertiSigma attestations (hash-only)
and the local filesystem (path + size + mtime).

Schema version 2 (SQLite) replaces the original JSON format (version 1).
Legacy JSON manifests are auto-detected and migrated on load.
"""

from __future__ import annotations

import json
import sqlite3
import time
from dataclasses import dataclass
from pathlib import Path
from typing import Iterator, Optional

SCHEMA_VERSION = 2

_SCHEMA_SQL = """\
CREATE TABLE IF NOT EXISTS meta (
    key   TEXT PRIMARY KEY,
    value TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS entries (
    hash_hex       TEXT PRIMARY KEY,
    path           TEXT NOT NULL,
    size           INTEGER NOT NULL,
    mtime          REAL NOT NULL,
    attested       INTEGER NOT NULL DEFAULT 0,
    attestation_id TEXT,
    attested_at    TEXT
);
CREATE INDEX IF NOT EXISTS idx_entries_path ON entries(path);
CREATE INDEX IF NOT EXISTS idx_entries_attested ON entries(attested);
"""


@dataclass
class ManifestEntry:
    hash_hex: str
    path: str
    size: int
    mtime: float
    attested: bool = False
    attestation_id: Optional[str] = None
    attested_at: Optional[str] = None


def _is_json_file(path: Path) -> bool:
    """Return True if *path* looks like a JSON manifest (legacy v1)."""
    if not path.exists():
        return False
    try:
        with path.open("rb") as f:
            first = f.read(1)
            return first == b"{"
    except OSError:
        return False


def _is_sqlite_file(path: Path) -> bool:
    """Return True if *path* is a SQLite database."""
    if not path.exists():
        return False
    try:
        with path.open("rb") as f:
            header = f.read(16)
            return header[:6] == b"SQLite"
    except OSError:
        return False


class Manifest:
    """SQLite-backed manifest with the same public API as the legacy JSON version."""

    def __init__(self, root_dir: str = "", *, _conn: sqlite3.Connection | None = None):
        self._root_dir = root_dir
        self._created_at = time.time()
        self._updated_at = self._created_at
        if _conn is not None:
            self._conn = _conn
        else:
            self._conn = sqlite3.connect(":memory:")
            self._init_schema()
            self._set_meta("root_dir", root_dir)
            self._set_meta("created_at", str(self._created_at))
            self._set_meta("updated_at", str(self._updated_at))

    def _init_schema(self) -> None:
        self._conn.executescript(f"PRAGMA journal_mode = WAL;\n{_SCHEMA_SQL}")
        uv = self._conn.execute("PRAGMA user_version").fetchone()[0]
        if uv == 0:
            self._conn.execute(f"PRAGMA user_version = {SCHEMA_VERSION}")
            self._conn.commit()

    def _set_meta(self, key: str, value: str) -> None:
        self._conn.execute(
            "INSERT OR REPLACE INTO meta (key, value) VALUES (?, ?)", (key, value)
        )
        self._conn.commit()

    def _get_meta(self, key: str, default: str = "") -> str:
        row = self._conn.execute(
            "SELECT value FROM meta WHERE key = ?", (key,)
        ).fetchone()
        return row[0] if row else default

    # ── Properties ────────────────────────────────────────────────────

    @property
    def version(self) -> str:
        return str(SCHEMA_VERSION)

    @property
    def root_dir(self) -> str:
        return self._root_dir

    @property
    def created_at(self) -> float:
        return self._created_at

    @property
    def updated_at(self) -> float:
        return self._updated_at

    @property
    def total(self) -> int:
        row = self._conn.execute("SELECT COUNT(*) FROM entries").fetchone()
        return row[0]

    @property
    def attested_count(self) -> int:
        row = self._conn.execute(
            "SELECT COUNT(*) FROM entries WHERE attested = 1"
        ).fetchone()
        return row[0]

    @property
    def entries(self) -> dict[str, ManifestEntry]:
        """Backward-compatible dict view. Prefer iter_entries() for large manifests."""
        result: dict[str, ManifestEntry] = {}
        for row in self._conn.execute("SELECT * FROM entries"):
            entry = self._row_to_entry(row)
            result[entry.hash_hex] = entry
        return result

    # ── Core operations ───────────────────────────────────────────────

    def add(self, entry: ManifestEntry) -> None:
        self._conn.execute(
            "INSERT OR REPLACE INTO entries "
            "(hash_hex, path, size, mtime, attested, attestation_id, attested_at) "
            "VALUES (?, ?, ?, ?, ?, ?, ?)",
            (
                entry.hash_hex, entry.path, entry.size, entry.mtime,
                int(entry.attested), entry.attestation_id, entry.attested_at,
            ),
        )
        self._touch()

    def get(self, hash_hex: str) -> Optional[ManifestEntry]:
        row = self._conn.execute(
            "SELECT * FROM entries WHERE hash_hex = ?", (hash_hex,)
        ).fetchone()
        return self._row_to_entry(row) if row else None

    def has_path(self, rel_path: str) -> Optional[ManifestEntry]:
        """Find an entry by relative path (indexed, O(log n))."""
        row = self._conn.execute(
            "SELECT * FROM entries WHERE path = ?", (rel_path,)
        ).fetchone()
        return self._row_to_entry(row) if row else None

    def mark_attested(
        self, hash_hex: str, attestation_id: str, attested_at: str
    ) -> None:
        self._conn.execute(
            "UPDATE entries SET attested = 1, attestation_id = ?, attested_at = ? "
            "WHERE hash_hex = ?",
            (attestation_id, attested_at, hash_hex),
        )
        self._touch()

    def remove_by_path(self, rel_path: str) -> bool:
        """Remove entry by path. Returns True if a row was deleted."""
        cursor = self._conn.execute(
            "DELETE FROM entries WHERE path = ?", (rel_path,)
        )
        if cursor.rowcount > 0:
            self._touch()
            return True
        return False

    def unattested_hashes(self) -> list[str]:
        """Return hash_hex values for entries not yet attested."""
        rows = self._conn.execute(
            "SELECT hash_hex FROM entries WHERE attested = 0"
        ).fetchall()
        return [r[0] for r in rows]

    def iter_entries(self) -> Iterator[ManifestEntry]:
        """Memory-efficient iterator over all entries."""
        for row in self._conn.execute("SELECT * FROM entries"):
            yield self._row_to_entry(row)

    def merge(self, other: Manifest) -> int:
        """Merge *other* into this manifest, preserving attestation state.

        New entries are added. Existing entries are updated only if the
        incoming entry has a different path or size (file changed).
        Attestation state from *self* is preserved unless the file changed.

        Returns the number of entries added or updated.
        """
        changed = 0
        for entry in other.iter_entries():
            existing = self.get(entry.hash_hex)
            if existing is None:
                self.add(entry)
                changed += 1
            elif existing.path != entry.path or existing.size != entry.size:
                entry.attested = existing.attested
                entry.attestation_id = existing.attestation_id
                entry.attested_at = existing.attested_at
                self.add(entry)
                changed += 1
        self._conn.commit()
        return changed

    # ── Persistence ───────────────────────────────────────────────────

    def save(self, path: Path) -> None:
        """Persist the manifest to a SQLite file at *path*.

        If the in-memory DB is used (e.g. from constructor), this copies
        the database to disk. If already on disk, this commits and
        checkpoints WAL.
        """
        target = self._resolve_db_path(path)
        db_str = str(target)
        if self._conn_is_memory():
            disk_conn = sqlite3.connect(db_str)
            try:
                self._conn.backup(disk_conn)
            finally:
                disk_conn.close()
        else:
            self._conn.commit()
            self._conn.execute("PRAGMA wal_checkpoint(TRUNCATE)")

    @classmethod
    def load(cls, path: Path) -> Manifest:
        """Load a manifest from disk.

        Auto-detects format:
        - SQLite (.db or SQLite header): opens directly
        - JSON (.json or JSON content): migrates to SQLite, renames .json to .json.bak
        """
        if _is_json_file(path):
            return cls._load_from_json(path)
        db_path = cls._resolve_db_path(path)
        if not db_path.exists():
            raise FileNotFoundError(f"Manifest not found: {path}")
        return cls._load_from_sqlite(db_path)

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

    def __enter__(self) -> Manifest:
        return self

    def __exit__(self, *exc) -> None:
        self.close()

    # ── Private helpers ───────────────────────────────────────────────

    def _touch(self) -> None:
        self._updated_at = time.time()
        self._conn.execute(
            "INSERT OR REPLACE INTO meta (key, value) VALUES ('updated_at', ?)",
            (str(self._updated_at),),
        )
        self._conn.commit()

    def _conn_is_memory(self) -> bool:
        try:
            row = self._conn.execute("PRAGMA database_list").fetchone()
            return row is not None and (row[2] == "" or row[2] == ":memory:")
        except sqlite3.ProgrammingError:
            return True

    @staticmethod
    def _row_to_entry(row: tuple) -> ManifestEntry:
        return ManifestEntry(
            hash_hex=row[0],
            path=row[1],
            size=row[2],
            mtime=row[3],
            attested=bool(row[4]),
            attestation_id=row[5],
            attested_at=row[6],
        )

    @staticmethod
    def _resolve_db_path(path: Path) -> Path:
        """Resolve the SQLite file path from a user-provided path.

        - .db → use as-is
        - .json → sibling .db file
        - other → append .db
        """
        if path.suffix == ".db":
            return path
        if path.suffix == ".json":
            return path.with_suffix(".db")
        return path.with_suffix(path.suffix + ".db")

    @classmethod
    def _load_from_sqlite(cls, db_path: Path) -> Manifest:
        conn = sqlite3.connect(str(db_path))
        try:
            conn.execute("PRAGMA journal_mode = WAL")
            m = cls.__new__(cls)
            m._conn = conn
            m._root_dir = m._get_meta("root_dir")
            try:
                m._created_at = float(m._get_meta("created_at", str(time.time())))
            except (ValueError, TypeError):
                m._created_at = time.time()
            try:
                m._updated_at = float(m._get_meta("updated_at", str(time.time())))
            except (ValueError, TypeError):
                m._updated_at = time.time()
            return m
        except Exception:
            conn.close()
            raise

    @classmethod
    def _load_from_json(cls, json_path: Path) -> Manifest:
        """Import a legacy JSON manifest into a new SQLite manifest."""
        data = json.loads(json_path.read_text(encoding="utf-8"))
        if not isinstance(data, dict):
            raise ValueError(f"Legacy manifest is not a JSON object: {json_path}")
        entries = data.get("entries", {})
        if not isinstance(entries, dict):
            raise ValueError(f"Legacy manifest 'entries' is not a dict: {json_path}")
        db_path = json_path.with_suffix(".db")
        conn = sqlite3.connect(str(db_path))
        try:
            m = cls(root_dir=str(data.get("root_dir", "")), _conn=conn)
            m._init_schema()
            m._created_at = data.get("created_at", time.time())
            m._updated_at = data.get("updated_at", time.time())
            m._set_meta("root_dir", m._root_dir)
            m._set_meta("created_at", str(m._created_at))
            m._set_meta("updated_at", str(m._updated_at))
            for hash_hex, entry_data in entries.items():
                if not isinstance(entry_data, dict):
                    raise ValueError(f"Entry for {hash_hex!r} is not a dict")
                entry = ManifestEntry(**entry_data)
                m.add(entry)
            m._conn.commit()
            backup = json_path.with_suffix(".json.bak")
            json_path.rename(backup)
            return m
        except Exception:
            conn.close()
            raise
