"""Durable, account-scoped unfiled issue drafts and ordered evidence."""

import base64
import binascii
import json
import os
import re
import sqlite3
from datetime import datetime
from pathlib import Path

from cryptography.exceptions import InvalidTag
from cryptography.hazmat.primitives.ciphers.aead import AESGCM

from src.private_state import connect_private_sqlite


_DRAFT_ID = re.compile(r"^[A-Za-z0-9_-]{1,100}$")
_REPOSITORY = re.compile(r"^[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+$")
_CONTENT_TYPES = {"image/png", "image/jpeg", "image/webp"}
_KEY_ID = re.compile(r"^[A-Za-z0-9_-]{1,32}$")


class UnfiledDraftConflict(ValueError):
    """Raised when a client attempts to replace a stale draft collection."""

    def __init__(self, snapshot: dict):
        super().__init__("unfiled drafts changed on another device")
        self.snapshot = snapshot


class UnfiledDraftEncryptionError(RuntimeError):
    """Raised when private draft state cannot be authenticated and decrypted."""


def decode_unfiled_draft_encryption_key(encoded: str) -> bytes:
    """Decode the independently injected 256-bit draft-encryption key."""
    try:
        key = base64.b64decode(encoded, validate=True)
    except (binascii.Error, ValueError) as error:
        raise UnfiledDraftEncryptionError(
            "unfiled draft encryption key is invalid"
        ) from error
    if len(key) != 32:
        raise UnfiledDraftEncryptionError(
            "unfiled draft encryption key must decode to exactly 32 bytes"
        )
    return key


def decode_unfiled_draft_encryption_keyring(
    encoded: str, active_key_id: str
) -> tuple[dict[str, bytes], str]:
    """Decode a bounded JSON object of named keys without exposing key material."""
    def unique_object(pairs):
        value = {}
        for key, item in pairs:
            if key in value:
                raise ValueError("duplicate key id")
            value[key] = item
        return value

    try:
        raw = json.loads(encoded, object_pairs_hook=unique_object)
        if not isinstance(raw, dict) or not raw or len(raw) > 8:
            raise ValueError("invalid keyring size")
        if active_key_id not in raw:
            raise ValueError("active key is unavailable")
        keys = {}
        for key_id, value in raw.items():
            if not isinstance(key_id, str) or not _KEY_ID.fullmatch(key_id):
                raise ValueError("invalid key id")
            if not isinstance(value, str):
                raise ValueError("invalid key value")
            keys[key_id] = decode_unfiled_draft_encryption_key(value)
        if len(set(keys.values())) != len(keys):
            raise ValueError("duplicate encryption key")
        return keys, active_key_id
    except (json.JSONDecodeError, TypeError, ValueError, UnfiledDraftEncryptionError) as error:
        raise UnfiledDraftEncryptionError(
            "unfiled draft encryption keyring is invalid"
        ) from error


class UnfiledDraftStore:
    def __init__(
        self,
        path: str | Path,
        *,
        encryption_key: bytes | None = None,
        encryption_keys: dict[str, bytes] | None = None,
        active_key_id: str | None = None,
        limit: int = 20,
        max_total_bytes: int = 12 * 1024 * 1024,
        timeout: float = 1.0,
    ):
        self.path = Path(path)
        if encryption_keys is None:
            if not isinstance(encryption_key, bytes) or len(encryption_key) != 32:
                raise UnfiledDraftEncryptionError(
                    "unfiled draft encryption requires exactly 32 key bytes"
                )
            self._ciphers = {"legacy": AESGCM(encryption_key)}
            self._active_key_id = None
        else:
            if (
                not encryption_keys
                or len(encryption_keys) > 8
                or active_key_id not in encryption_keys
                or any(
                    not isinstance(key_id, str)
                    or not _KEY_ID.fullmatch(key_id)
                    or not isinstance(key, bytes)
                    or len(key) != 32
                    for key_id, key in encryption_keys.items()
                )
            ):
                raise UnfiledDraftEncryptionError("unfiled draft encryption keyring is invalid")
            self._ciphers = {
                key_id: AESGCM(key) for key_id, key in encryption_keys.items()
            }
            self._active_key_id = active_key_id
        self.limit = limit
        self.max_total_bytes = max_total_bytes
        self.timeout = timeout
        self._initialize()

    def _initialize(self) -> None:
        with self._connect() as connection:
            connection.execute("PRAGMA journal_mode=WAL")
            connection.execute(
                """CREATE TABLE IF NOT EXISTS unfiled_drafts (
                    login TEXT PRIMARY KEY,
                    revision INTEGER NOT NULL,
                    drafts TEXT NOT NULL
                )"""
            )

    def _connect(self) -> sqlite3.Connection:
        return connect_private_sqlite(self.path, timeout=self.timeout)

    @staticmethod
    def _login(login: str) -> str:
        normalized = login.strip().lower()
        if not normalized:
            raise ValueError("login is required")
        return normalized

    @staticmethod
    def _aad(login: str, revision: int) -> bytes:
        return f"stackchain:unfiled-drafts:v1\0{login}\0{revision}".encode()

    @staticmethod
    def _v2_aad(login: str, revision: int, key_id: str) -> bytes:
        return f"stackchain:unfiled-drafts:v2\0{key_id}\0{login}\0{revision}".encode()

    def _encrypt(self, login: str, revision: int, drafts: list[dict]) -> str:
        plaintext = json.dumps(drafts, separators=(",", ":")).encode()
        nonce = os.urandom(12)
        if self._active_key_id is None:
            sealed = nonce + self._ciphers["legacy"].encrypt(
                nonce, plaintext, self._aad(login, revision)
            )
            return "v1:" + base64.urlsafe_b64encode(sealed).decode()
        key_id = self._active_key_id
        sealed = nonce + self._ciphers[key_id].encrypt(
            nonce, plaintext, self._v2_aad(login, revision, key_id)
        )
        return f"v2:{key_id}:" + base64.urlsafe_b64encode(sealed).decode()

    def _decrypt(self, login: str, revision: int, payload: str) -> list[dict]:
        try:
            if payload.startswith("v1:"):
                encoded = payload[3:]
                cipher = self._ciphers.get("legacy")
                aad = self._aad(login, revision)
            elif payload.startswith("v2:"):
                _version, key_id, encoded = payload.split(":", 2)
                cipher = self._ciphers.get(key_id)
                aad = self._v2_aad(login, revision, key_id)
            else:
                raise ValueError("encrypted payload version is invalid")
            if cipher is None:
                raise ValueError("encrypted payload key is unavailable")
            sealed = base64.b64decode(encoded, altchars=b"-_", validate=True)
            if len(sealed) < 12 + 16:
                raise ValueError("encrypted payload is too short")
            plaintext = cipher.decrypt(sealed[:12], sealed[12:], aad)
            drafts = json.loads(plaintext)
            if not isinstance(drafts, list):
                raise ValueError("decrypted payload is not a collection")
            return drafts
        except (binascii.Error, InvalidTag, UnicodeDecodeError, ValueError, json.JSONDecodeError) as error:
            raise UnfiledDraftEncryptionError(
                "unfiled drafts could not be decrypted"
            ) from error

    def _snapshot(self, login: str, row) -> tuple[dict, bool]:
        if row is None:
            return {"revision": 0, "drafts": []}, False
        revision, payload = int(row[0]), row[1]
        if payload.startswith(("v1:", "v2:")):
            drafts = self._decrypt(login, revision, payload)
            active_prefix = (
                "v1:" if self._active_key_id is None else f"v2:{self._active_key_id}:"
            )
            return {"revision": revision, "drafts": drafts}, not payload.startswith(active_prefix)
        try:
            drafts = json.loads(payload)
        except (TypeError, json.JSONDecodeError) as error:
            raise UnfiledDraftEncryptionError(
                "legacy unfiled drafts could not be decoded"
            ) from error
        if not isinstance(drafts, list):
            raise UnfiledDraftEncryptionError(
                "legacy unfiled drafts could not be decoded"
            )
        return {"revision": revision, "drafts": drafts}, True

    def get(self, login: str) -> dict:
        login = self._login(login)
        with self._connect() as connection:
            connection.execute("BEGIN IMMEDIATE")
            row = connection.execute(
                "SELECT revision, drafts FROM unfiled_drafts WHERE login = ?",
                (login,),
            ).fetchone()
            snapshot, legacy = self._snapshot(login, row)
            if legacy:
                connection.execute(
                    "UPDATE unfiled_drafts SET drafts = ? WHERE login = ?",
                    (
                        self._encrypt(login, snapshot["revision"], snapshot["drafts"]),
                        login,
                    ),
                )
        return snapshot

    def rewrap_all(self) -> dict[str, int]:
        """Rewrap every readable row and return aggregate counts only."""
        with self._connect() as connection:
            rows = connection.execute(
                "SELECT login, drafts FROM unfiled_drafts ORDER BY login"
            ).fetchall()
        result = {"total": len(rows), "migrated": 0, "current": 0, "failed": 0}
        active_prefix = (
            "v1:" if self._active_key_id is None else f"v2:{self._active_key_id}:"
        )
        for login, payload in rows:
            was_current = payload.startswith(active_prefix)
            try:
                self.get(login)
            except UnfiledDraftEncryptionError:
                result["failed"] += 1
            else:
                result["current" if was_current else "migrated"] += 1
        return result

    @staticmethod
    def _filing_plan(raw: object) -> dict | None:
        if raw is None:
            return None
        if not isinstance(raw, dict):
            raise ValueError("filing plan is invalid")
        repository = raw.get("repository")
        if not isinstance(repository, str) or not _REPOSITORY.fullmatch(repository):
            raise ValueError("filing repository is invalid")
        label_ids = raw.get("label_ids", [])
        if not isinstance(label_ids, list) or len(label_ids) > 20:
            raise ValueError("filing labels are invalid")
        if any(not isinstance(item, int) or isinstance(item, bool) or item < 1 for item in label_ids):
            raise ValueError("filing labels are invalid")
        if len(set(label_ids)) != len(label_ids):
            raise ValueError("filing labels are invalid")
        plan = {"repository": repository, "label_ids": label_ids}
        for key in ("milestone_id", "estimate_minutes"):
            value = raw.get(key)
            if value is None:
                continue
            upper = 1440 if key == "estimate_minutes" else None
            lower = 5 if key == "estimate_minutes" else 1
            if (
                not isinstance(value, int)
                or isinstance(value, bool)
                or value < lower
                or (upper is not None and value > upper)
            ):
                raise ValueError(f"filing {key.replace('_', ' ')} is invalid")
            plan[key] = value
        due_date = raw.get("due_date")
        if due_date is not None:
            try:
                datetime.strptime(due_date, "%Y-%m-%d")
            except (TypeError, ValueError) as error:
                raise ValueError("filing due date is invalid") from error
            plan["due_date"] = due_date
        for key, limit in {
            "template_name": 80,
            "template_id": 80,
            "captured_body": 10_000,
            "assignee_name": 255,
        }.items():
            value = raw.get(key)
            if value is not None:
                if not isinstance(value, str) or not value.strip() or len(value) > limit:
                    raise ValueError(f"filing {key.replace('_', ' ')} is invalid")
                plan[key] = value
        unassigned = raw.get("unassigned", False)
        if not isinstance(unassigned, bool):
            raise ValueError("filing owner intent is invalid")
        assignee = raw.get("assignee")
        if unassigned and assignee is not None:
            raise ValueError("filing owner intent is invalid")
        if unassigned:
            plan["unassigned"] = True
        elif assignee is not None:
            if not isinstance(assignee, str) or not re.fullmatch(r"[A-Za-z0-9_.-]+", assignee):
                raise ValueError("filing assignee is invalid")
            plan["assignee"] = assignee
        completion_intent = raw.get("completion_intent")
        if completion_intent is not None:
            if completion_intent not in {"create", "create-and-start"}:
                raise ValueError("filing completion intent is invalid")
            plan["completion_intent"] = completion_intent
        return plan

    def _normalize(self, drafts: list[dict]) -> list[dict]:
        if not isinstance(drafts, list):
            raise ValueError("drafts must be a list")
        if len(drafts) > self.limit:
            raise ValueError(f"unfiled drafts are limited to {self.limit}")
        normalized = []
        seen = set()
        decoded_total = 0
        for raw in drafts:
            if not isinstance(raw, dict):
                raise ValueError("draft must be an object")
            draft_id = raw.get("id")
            if not isinstance(draft_id, str) or not _DRAFT_ID.fullmatch(draft_id):
                raise ValueError("draft id is invalid")
            if draft_id in seen:
                raise ValueError("draft ids must be unique")
            title = raw.get("title")
            body = raw.get("body", "")
            saved_at = raw.get("saved_at")
            if not isinstance(title, str) or len(title.strip()) > 255:
                raise ValueError("title is invalid")
            if not isinstance(body, str) or len(body) > 10_000:
                raise ValueError("body is invalid")
            if not isinstance(saved_at, int) or isinstance(saved_at, bool) or saved_at < 0:
                raise ValueError("saved_at is invalid")
            filing_plan = self._filing_plan(raw.get("filing_plan"))
            blockers = raw.get("blockers", [])
            if not isinstance(blockers, list) or len(blockers) > 5:
                raise ValueError("blockers are invalid")
            clean_blockers = []
            for blocker in blockers:
                repository = blocker.get("repository") if isinstance(blocker, dict) else None
                number = blocker.get("number") if isinstance(blocker, dict) else None
                blocker_title = blocker.get("title", "") if isinstance(blocker, dict) else ""
                if not isinstance(repository, str) or not _REPOSITORY.fullmatch(repository):
                    raise ValueError("blocker repository is invalid")
                if not isinstance(number, int) or isinstance(number, bool) or number < 1:
                    raise ValueError("blocker number is invalid")
                if not isinstance(blocker_title, str) or len(blocker_title) > 255:
                    raise ValueError("blocker title is invalid")
                clean_blockers.append({"repository": repository, "number": number, "title": blocker_title})
            evidence = raw.get("evidence", [])
            if not isinstance(evidence, list) or len(evidence) > 5:
                raise ValueError("evidence is invalid")
            clean_evidence = []
            for item in evidence:
                if not isinstance(item, dict):
                    raise ValueError("evidence is invalid")
                filename = item.get("filename")
                content_type = item.get("content_type")
                note = item.get("note", "")
                data = item.get("data")
                if not isinstance(filename, str) or not filename or len(filename) > 255:
                    raise ValueError("evidence filename is invalid")
                if content_type not in _CONTENT_TYPES:
                    raise ValueError("evidence content type is invalid")
                if not isinstance(note, str) or len(note) > 240:
                    raise ValueError("evidence note is invalid")
                if not isinstance(data, str):
                    raise ValueError("evidence data is invalid")
                try:
                    decoded_total += len(base64.b64decode(data, validate=True))
                except (binascii.Error, ValueError) as error:
                    raise ValueError("evidence data must be valid base64") from error
                if decoded_total > self.max_total_bytes:
                    raise ValueError("synchronized evidence is too large")
                clean_evidence.append({
                    "filename": filename,
                    "content_type": content_type,
                    **({"note": note} if note else {}),
                    "data": data,
                })
            if not title.strip() and not clean_evidence:
                raise ValueError("title or evidence is required")
            seen.add(draft_id)
            normalized.append({
                "id": draft_id,
                "title": title.strip(),
                "body": body,
                "saved_at": saved_at,
                **({"filing_plan": filing_plan} if filing_plan else {}),
                **({"blockers": clean_blockers} if clean_blockers else {}),
                **({"evidence": clean_evidence} if clean_evidence else {}),
            })
        return normalized

    def replace(self, login: str, expected_revision: int, drafts: list[dict]) -> dict:
        login = self._login(login)
        if not isinstance(expected_revision, int) or isinstance(expected_revision, bool) or expected_revision < 0:
            raise ValueError("revision is invalid")
        normalized = self._normalize(drafts)
        with self._connect() as connection:
            connection.execute("BEGIN IMMEDIATE")
            row = connection.execute(
                "SELECT revision, drafts FROM unfiled_drafts WHERE login = ?", (login,)
            ).fetchone()
            current, _legacy = self._snapshot(login, row)
            if current["revision"] != expected_revision:
                raise UnfiledDraftConflict(current)
            revision = expected_revision + 1
            serialized = self._encrypt(login, revision, normalized)
            connection.execute(
                "INSERT INTO unfiled_drafts(login, revision, drafts) VALUES (?, ?, ?) "
                "ON CONFLICT(login) DO UPDATE SET revision=excluded.revision, drafts=excluded.drafts",
                (login, revision, serialized),
            )
        return {"revision": revision, "drafts": normalized}
