"""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 3 (SQLite) replaces the original JSON format (version 1).
Version 2→3 adds file attribution columns (owner, group, mode).
Legacy JSON manifests are auto-detected and migrated on load.
"""

from __future__ import annotations

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

logger = logging.getLogger(__name__)

SCHEMA_VERSION = 3

_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,
    owner          TEXT,
    file_group     TEXT,
    file_mode      INTEGER
);
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
    owner: Optional[str] = None
    file_group: Optional[str] = None
    file_mode: Optional[int] = 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
        self._temp_db_path: str | None = None
        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()
        elif uv < SCHEMA_VERSION:
            self._migrate(uv)

    def _migrate(self, from_version: int) -> None:
        """Run forward migrations from *from_version* to SCHEMA_VERSION."""
        if from_version < 3:
            cols = {
                row[1]
                for row in self._conn.execute("PRAGMA table_info(entries)")
            }
            for col, typ in [("owner", "TEXT"), ("file_group", "TEXT"), ("file_mode", "INTEGER")]:
                if col not in cols:
                    self._conn.execute(f"ALTER TABLE entries ADD COLUMN {col} {typ}")
        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 int(row[0])

    @property
    def attested_count(self) -> int:
        row = self._conn.execute(
            "SELECT COUNT(*) FROM entries WHERE attested = 1"
        ).fetchone()
        return int(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,"
            " owner, file_group, file_mode) "
            "VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
            (
                entry.hash_hex, entry.path, entry.size, entry.mtime,
                int(entry.attested), entry.attestation_id, entry.attested_at,
                entry.owner, entry.file_group, entry.file_mode,
            ),
        )
        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, *, encryption_key: str | None = None) -> None:
        """Persist the manifest to a SQLite file at *path*.

        If *encryption_key* is provided (64 hex chars), the SQLite file is
        encrypted with AES-256-GCM and written as ``<path>.enc``.
        """
        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()
            try:
                target.chmod(0o600)
            except OSError:
                pass
        else:
            self._conn.commit()
            self._conn.execute("PRAGMA wal_checkpoint(TRUNCATE)")

        if encryption_key:
            from .encryption import encrypt_file
            enc_path = target.with_suffix(target.suffix + ".enc")
            encrypt_file(target, enc_path, encryption_key)
            for suffix in ("", "-wal", "-shm"):
                p = Path(str(target) + suffix)
                if p.exists():
                    try:
                        p.unlink()
                    except OSError:
                        logger.warning(
                            "Cannot remove plaintext file after encryption: %s", p
                        )

    @classmethod
    def load(cls, path: Path, *, encryption_key: str | None = None) -> Manifest:
        """Load a manifest from disk.

        Auto-detects format:
        - Encrypted (``.db.enc`` or CENSUS_ENC header): decrypts to temp SQLite, loads
        - SQLite (.db or SQLite header): opens directly
        - JSON (.json or JSON content): migrates to SQLite, renames .json to .json.bak
        """
        from .encryption import is_encrypted_file

        enc_path = cls._resolve_enc_path(path)
        if enc_path.exists() and is_encrypted_file(enc_path):
            if not encryption_key:
                raise ValueError(
                    f"Manifest is encrypted ({enc_path.name}). "
                    "Provide --encryption-key or set CENSUS_ENCRYPTION_KEY."
                )
            return cls._load_from_encrypted(enc_path, encryption_key)

        if _is_json_file(path):
            return cls._load_from_json(path)
        db_path = cls._resolve_db_path(path)

        if db_path.exists() and is_encrypted_file(db_path):
            if not encryption_key:
                raise ValueError(
                    f"Manifest is encrypted ({db_path.name}). "
                    "Provide --encryption-key or set CENSUS_ENCRYPTION_KEY."
                )
            return cls._load_from_encrypted(db_path, encryption_key)

        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 and clean up temp files."""
        if self._conn:
            self._conn.close()
        tmp = getattr(self, "_temp_db_path", None)
        if tmp is not None:
            try:
                os.unlink(tmp)
            except OSError as exc:
                logger.warning(
                    "Cannot remove temporary decrypted manifest database %s: %s",
                    tmp,
                    exc,
                )
            self._temp_db_path = None

    def __enter__(self) -> Manifest:
        return self

    def __exit__(self, *exc: object) -> 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],
            owner=row[7] if len(row) > 7 else None,
            file_group=row[8] if len(row) > 8 else None,
            file_mode=row[9] if len(row) > 9 else None,
        )

    @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")

    @staticmethod
    def _resolve_enc_path(path: Path) -> Path:
        """Resolve the encrypted manifest path from a user-provided path."""
        db_path = Manifest._resolve_db_path(path)
        return db_path.with_suffix(db_path.suffix + ".enc")

    @classmethod
    def _load_from_encrypted(cls, enc_path: Path, key_hex: str) -> Manifest:
        """Decrypt an encrypted manifest to a temporary SQLite file and load it.

        The temp file is tracked and deleted when ``close()`` is called.
        """
        import tempfile
        from .encryption import decrypt_file

        plaintext = decrypt_file(enc_path, key_hex)
        tmp_fd, tmp_path = tempfile.mkstemp(suffix=".db", prefix="census_")
        try:
            os.close(tmp_fd)
            Path(tmp_path).write_bytes(plaintext)
            m = cls._load_from_sqlite(Path(tmp_path))
            m._encrypted_source = str(enc_path)  # type: ignore[attr-defined]
            m._temp_db_path = tmp_path
            return m
        except BaseException:
            try:
                os.unlink(tmp_path)
            except OSError as exc:
                logger.warning(
                    "Cannot remove temporary decrypted manifest database %s: %s",
                    tmp_path, exc,
                )
            raise

    @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._temp_db_path = None
            uv = conn.execute("PRAGMA user_version").fetchone()[0]
            if uv < SCHEMA_VERSION:
                m._migrate(uv)
            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
