import json
import sqlite3
from datetime import datetime, timezone
from pathlib import Path
from typing import Optional

from cryptography.exceptions import InvalidTag

from .crypto import decrypt, derive_key, encrypt, generate_salt


class AuthError(Exception):
    pass


class EntryNotFoundError(Exception):
    pass


class EntryExistsError(Exception):
    pass


class Vault:
    def __init__(self, db_path: Path):
        self.db_path = db_path
        self._key: Optional[bytes] = None
        self._conn: Optional[sqlite3.Connection] = None

    def _connect(self):
        if self._conn is None:
            self.db_path.parent.mkdir(parents=True, exist_ok=True)
            self._conn = sqlite3.connect(str(self.db_path))
            self._conn.row_factory = sqlite3.Row
            self._init_schema()

    PASSWORD_HISTORY_LIMIT = 5

    def _init_schema(self):
        self._conn.executescript("""
            CREATE TABLE IF NOT EXISTS meta (
                key   TEXT PRIMARY KEY,
                value TEXT NOT NULL
            );
            CREATE TABLE IF NOT EXISTS entries (
                id         INTEGER PRIMARY KEY AUTOINCREMENT,
                name       TEXT NOT NULL UNIQUE,
                username   TEXT,
                password   BLOB NOT NULL,
                url        TEXT,
                notes      TEXT,
                folder     TEXT NOT NULL DEFAULT 'personal',
                entry_type TEXT NOT NULL DEFAULT 'login',
                metadata   BLOB,
                created_at TEXT NOT NULL,
                updated_at TEXT NOT NULL
            );
            CREATE TABLE IF NOT EXISTS password_history (
                id         INTEGER PRIMARY KEY AUTOINCREMENT,
                entry_name TEXT NOT NULL,
                password   BLOB NOT NULL,
                rotated_at TEXT NOT NULL
            );
        """)
        # Migrate existing vaults missing newer columns
        cols = {r[1] for r in self._conn.execute("PRAGMA table_info(entries)").fetchall()}
        if "folder" not in cols:
            self._conn.execute("ALTER TABLE entries ADD COLUMN folder TEXT NOT NULL DEFAULT 'personal'")
        if "entry_type" not in cols:
            self._conn.execute("ALTER TABLE entries ADD COLUMN entry_type TEXT NOT NULL DEFAULT 'login'")
        if "metadata" not in cols:
            self._conn.execute("ALTER TABLE entries ADD COLUMN metadata BLOB")
        self._conn.commit()

    def is_initialized(self) -> bool:
        self._connect()
        return self._conn.execute("SELECT value FROM meta WHERE key='salt'").fetchone() is not None

    def initialize(self, master_password: str):
        self._connect()
        if self.is_initialized():
            raise RuntimeError("Vault already initialized.")
        salt = generate_salt()
        key = derive_key(master_password, salt)
        token = encrypt("vault-ok", key)
        self._conn.executemany(
            "INSERT INTO meta (key, value) VALUES (?, ?)",
            [("salt", salt.hex()), ("verify", token.hex())],
        )
        self._conn.commit()
        self._key = key

    def unlock(self, master_password: str):
        self._connect()
        row = self._conn.execute("SELECT value FROM meta WHERE key='salt'").fetchone()
        if not row:
            raise AuthError("Vault not initialized. Run: pm init")
        salt = bytes.fromhex(row["value"])
        key = derive_key(master_password, salt)
        self._verify_key(key)
        self._key = key

    def unlock_with_key(self, key: bytes):
        """Unlock vault with a pre-derived key (from session cache)."""
        self._connect()
        self._verify_key(key)
        self._key = key

    def _verify_key(self, key: bytes):
        verify_row = self._conn.execute("SELECT value FROM meta WHERE key='verify'").fetchone()
        if not verify_row:
            raise AuthError("Vault not initialized. Run: pm init")
        try:
            result = decrypt(bytes.fromhex(verify_row["value"]), key)
            if result != "vault-ok":
                raise AuthError("Invalid master password.")
        except (InvalidTag, Exception) as exc:
            raise AuthError("Invalid master password.") from exc

    def _require_unlocked(self):
        if self._key is None:
            raise AuthError("Vault is locked.")

    # ── CRUD ────────────────────────────────────────────────────────────────

    def create(
        self,
        name: str,
        username: str,
        password: str,
        url: str = "",
        notes: str = "",
        folder: str = "personal",
        entry_type: str = "login",
        metadata: dict = None,
    ):
        self._require_unlocked()
        now = datetime.now(timezone.utc).isoformat()
        enc_password = encrypt(password, self._key)
        enc_metadata = encrypt(json.dumps(metadata or {}), self._key)
        try:
            self._conn.execute(
                "INSERT INTO entries"
                " (name, username, password, url, notes, folder, entry_type, metadata, created_at, updated_at)"
                " VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
                (name, username, enc_password, url, notes, folder, entry_type, enc_metadata, now, now),
            )
            self._conn.commit()
        except sqlite3.IntegrityError:
            raise EntryExistsError(f"Entry '{name}' already exists.")

    def get(self, name: str) -> dict:
        self._require_unlocked()
        row = self._conn.execute("SELECT * FROM entries WHERE name=?", (name,)).fetchone()
        if not row:
            raise EntryNotFoundError(f"Entry '{name}' not found.")
        return self._decrypt_row(row)

    def list_entries(self, search: str = "", folder: str = "") -> list[dict]:
        self._require_unlocked()
        conditions, params = [], []
        if search:
            q = f"%{search}%"
            conditions.append("(name LIKE ? OR username LIKE ? OR url LIKE ?)")
            params.extend([q, q, q])
        if folder:
            conditions.append("folder = ?")
            params.append(folder)
        where = f"WHERE {' AND '.join(conditions)}" if conditions else ""
        rows = self._conn.execute(
            f"SELECT id, name, username, url, folder, entry_type, created_at, updated_at"
            f" FROM entries {where} ORDER BY folder, entry_type, name",
            params,
        ).fetchall()
        return [dict(r) for r in rows]

    def get_stats(self) -> dict:
        """Vault summary: totals, by folder, by type."""
        self._require_unlocked()
        total = self._conn.execute("SELECT COUNT(*) FROM entries").fetchone()[0]
        by_folder = self._conn.execute(
            "SELECT folder, COUNT(*) as count FROM entries GROUP BY folder ORDER BY folder"
        ).fetchall()
        by_type = self._conn.execute(
            "SELECT entry_type, COUNT(*) as count FROM entries GROUP BY entry_type ORDER BY count DESC"
        ).fetchall()
        return {
            "total": total,
            "by_folder": [dict(r) for r in by_folder],
            "by_type": [dict(r) for r in by_type],
        }

    def get_stale_entries(self, days: int = 90) -> list[dict]:
        """Entries whose password/key hasn't been rotated in `days` days. Excludes notes."""
        self._require_unlocked()
        from datetime import timedelta
        cutoff = (datetime.now(timezone.utc) - timedelta(days=days)).isoformat()
        rows = self._conn.execute(
            "SELECT id, name, folder, entry_type, created_at, updated_at FROM entries"
            " WHERE updated_at < ? AND entry_type != 'note'"
            " ORDER BY updated_at ASC",
            (cutoff,),
        ).fetchall()
        now = datetime.now(timezone.utc)
        results = []
        for row in rows:
            updated = datetime.fromisoformat(row["updated_at"])
            if updated.tzinfo is None:
                updated = updated.replace(tzinfo=timezone.utc)
            results.append({**dict(row), "age_days": (now - updated).days})
        return results

    def search(self, query: str) -> list[dict]:
        """Search across all fields including decrypted metadata."""
        self._require_unlocked()
        rows = self._conn.execute("SELECT * FROM entries ORDER BY folder, name").fetchall()
        q = query.lower()
        results = []
        for row in rows:
            entry = self._decrypt_row(row)
            haystack = [
                entry["name"], entry["username"], entry["url"],
                entry["notes"], entry["folder"],
            ] + [str(v) for v in entry["metadata"].values()]
            if any(q in str(f).lower() for f in haystack):
                results.append(entry)
        return results

    def list_folders(self) -> list[dict]:
        self._require_unlocked()
        rows = self._conn.execute(
            "SELECT folder, COUNT(*) as count FROM entries GROUP BY folder ORDER BY folder"
        ).fetchall()
        return [{"folder": r["folder"], "count": r["count"]} for r in rows]

    def get_health_report(self) -> dict:
        """Analyse vault for security issues.

        Returns:
            reused_passwords  — groups of entries sharing the same password
            weak_passwords    — entries with short or all-alpha passwords
            never_rotated     — entries whose password has never changed (created_at == updated_at)
            no_totp           — login entries without a TOTP secret
        """
        self._require_unlocked()
        rows = self._conn.execute("SELECT * FROM entries WHERE entry_type != 'note'").fetchall()
        entries = [self._decrypt_row(r) for r in rows]

        # Reused passwords — group by password hash, exclude blanks
        from collections import defaultdict
        import hashlib
        pw_map: dict[str, list[str]] = defaultdict(list)
        for e in entries:
            if e["password"]:
                digest = hashlib.sha256(e["password"].encode()).hexdigest()
                pw_map[digest].append(e["name"])
        reused = [names for names in pw_map.values() if len(names) > 1]

        # Weak passwords — shorter than 8 chars or entirely alphabetic
        MIN_LENGTH = 8
        weak = []
        for e in entries:
            pw = e["password"]
            if pw and (len(pw) < MIN_LENGTH or pw.isalpha()):
                weak.append({"name": e["name"], "reason": "too short" if len(pw) < MIN_LENGTH else "no digits/symbols"})

        # Never rotated — created_at == updated_at (to the second)
        never_rotated = [
            {"name": e["name"], "folder": e["folder"], "entry_type": e["entry_type"], "created_at": e["created_at"]}
            for e in entries
            if e["created_at"][:19] == e["updated_at"][:19]
        ]

        # Login entries missing TOTP
        no_totp = [
            {"name": e["name"], "folder": e["folder"]}
            for e in entries
            if e["entry_type"] == "login" and not e["metadata"].get("totp")
        ]

        return {
            "reused_passwords": reused,
            "weak_passwords": weak,
            "never_rotated": never_rotated,
            "no_totp": no_totp,
        }

    def get_password_history(self, name: str) -> list[dict]:
        """Return archived passwords for *name*, newest first."""
        self._require_unlocked()
        rows = self._conn.execute(
            "SELECT password, rotated_at FROM password_history"
            " WHERE entry_name=? ORDER BY rotated_at DESC",
            (name,),
        ).fetchall()
        return [{"password": decrypt(r["password"], self._key), "rotated_at": r["rotated_at"]} for r in rows]

    def edit(self, name: str, **kwargs):
        self._require_unlocked()
        entry = self.get(name)
        now = datetime.now(timezone.utc).isoformat()
        new_password = kwargs.get("password", entry["password"])

        # Archive old password when it changes (non-note entries only)
        if new_password != entry["password"] and entry["entry_type"] != "note":
            self._conn.execute(
                "INSERT INTO password_history (entry_name, password, rotated_at) VALUES (?, ?, ?)",
                (name, encrypt(entry["password"], self._key), now),
            )
            # Keep only the most recent N entries
            self._conn.execute(
                "DELETE FROM password_history WHERE entry_name=? AND id NOT IN ("
                "  SELECT id FROM password_history WHERE entry_name=? ORDER BY rotated_at DESC LIMIT ?"
                ")",
                (name, name, self.PASSWORD_HISTORY_LIMIT),
            )

        enc_password = encrypt(new_password, self._key)
        new_metadata = kwargs.get("metadata", entry["metadata"])
        enc_metadata = encrypt(json.dumps(new_metadata or {}), self._key)
        self._conn.execute(
            "UPDATE entries"
            " SET username=?, password=?, url=?, notes=?, folder=?, entry_type=?, metadata=?, updated_at=?"
            " WHERE name=?",
            (
                kwargs.get("username", entry["username"]),
                enc_password,
                kwargs.get("url", entry["url"]),
                kwargs.get("notes", entry["notes"]),
                kwargs.get("folder", entry["folder"]),
                kwargs.get("entry_type", entry["entry_type"]),
                enc_metadata,
                now,
                name,
            ),
        )
        self._conn.commit()

    def rename(self, old_name: str, new_name: str):
        self._require_unlocked()
        if not self._conn.execute("SELECT 1 FROM entries WHERE name=?", (old_name,)).fetchone():
            raise EntryNotFoundError(f"Entry '{old_name}' not found.")
        try:
            self._conn.execute("UPDATE entries SET name=? WHERE name=?", (new_name, old_name))
            self._conn.commit()
        except sqlite3.IntegrityError:
            raise EntryExistsError(f"Entry '{new_name}' already exists.")

    def delete(self, name: str):
        self._require_unlocked()
        result = self._conn.execute("DELETE FROM entries WHERE name=?", (name,))
        if result.rowcount == 0:
            raise EntryNotFoundError(f"Entry '{name}' not found.")
        self._conn.execute("DELETE FROM password_history WHERE entry_name=?", (name,))
        self._conn.commit()

    # ── Export / Restore ────────────────────────────────────────────────────

    def export_entries(self) -> list[dict]:
        self._require_unlocked()
        rows = self._conn.execute("SELECT * FROM entries ORDER BY name").fetchall()
        return [self._decrypt_row(r) for r in rows]

    def import_entries(self, entries: list[dict]) -> tuple[int, int]:
        self._require_unlocked()
        imported, skipped = 0, 0
        for e in entries:
            try:
                self.create(
                    e["name"],
                    e.get("username", ""),
                    e.get("password", ""),
                    e.get("url", ""),
                    e.get("notes", ""),
                    e.get("folder", "personal"),
                    e.get("entry_type", "login"),
                    e.get("metadata", {}),
                )
                imported += 1
            except EntryExistsError:
                skipped += 1
        return imported, skipped

    def _decrypt_row(self, row) -> dict:
        keys = row.keys()
        meta_raw = row["metadata"] if "metadata" in keys and row["metadata"] else None
        metadata = json.loads(decrypt(meta_raw, self._key)) if meta_raw else {}
        return {
            "id": row["id"],
            "name": row["name"],
            "username": row["username"] or "",
            "password": decrypt(row["password"], self._key),
            "url": row["url"] or "",
            "notes": row["notes"] or "",
            "folder": row["folder"] if "folder" in keys else "personal",
            "entry_type": row["entry_type"] if "entry_type" in keys else "login",
            "metadata": metadata,
            "created_at": row["created_at"],
            "updated_at": row["updated_at"],
        }
