"""Durable, account-scoped saved Search views."""

import re
import sqlite3
from pathlib import Path

from src.private_state import connect_private_sqlite
from src.state_encryption import PrivateStateCipher, PrivateStateEncryptionError, private_state_encryption_key


_REPOSITORY = re.compile(r"^[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+$")
_VIEW_ID = re.compile(r"^[A-Za-z0-9_-]{1,64}$")


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

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


class SavedSearchStore:
    def __init__(
        self,
        path: str | Path,
        *,
        limit: int = 20,
        timeout: float = 1.0,
        encryption_key: bytes | None = None,
    ):
        self.path = Path(path)
        self.limit = limit
        self.timeout = timeout
        self._cipher = PrivateStateCipher(
            encryption_key if encryption_key is not None else private_state_encryption_key(),
            store="saved-searches",
        )
        self._initialize()

    def _initialize(self) -> None:
        with self._connect() as connection:
            connection.execute("PRAGMA journal_mode=WAL")
            connection.execute(
                """
                CREATE TABLE IF NOT EXISTS saved_searches (
                    login TEXT PRIMARY KEY,
                    revision INTEGER NOT NULL,
                    views 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

    def _snapshot(self, row, login: str) -> tuple[dict, bool]:
        if row is None:
            return {"revision": 0, "views": []}, False
        views, legacy = self._cipher.open(row[1], binding=f"views:{login}")
        if not isinstance(views, list):
            raise PrivateStateEncryptionError("private state could not be decrypted")
        return {"revision": int(row[0]), "views": views}, legacy

    def _sealed_views(self, login: str, views: list[dict]) -> str:
        return self._cipher.seal(views, binding=f"views:{login}")

    def get(self, login: str) -> dict:
        login = self._login(login)
        with self._connect() as connection:
            row = connection.execute(
                "SELECT revision, views FROM saved_searches WHERE login = ?",
                (login,),
            ).fetchone()
            snapshot, legacy = self._snapshot(row, login)
            if row is not None and legacy:
                connection.execute(
                    "UPDATE saved_searches SET views = ? WHERE login = ? AND views = ?",
                    (self._sealed_views(login, snapshot["views"]), login, row[1]),
                )
        return snapshot

    def _normalize(self, views: list[dict]) -> list[dict]:
        if not isinstance(views, list):
            raise ValueError("views must be a list")
        if len(views) > self.limit:
            raise ValueError(f"saved searches are limited to {self.limit}")
        normalized = []
        seen = set()
        for raw in views:
            if not isinstance(raw, dict):
                raise ValueError("saved search must be an object")
            view_id = raw.get("id")
            if not isinstance(view_id, str) or not _VIEW_ID.fullmatch(view_id):
                raise ValueError("id is invalid")
            if view_id in seen:
                raise ValueError("saved search ids must be unique")
            name = raw.get("name")
            if not isinstance(name, str) or not name.strip():
                raise ValueError("name is required")
            name = name.strip()
            if len(name) > 60:
                raise ValueError("name must be at most 60 characters")
            query = raw.get("query")
            if not isinstance(query, str) or not 2 <= len(query.strip()) <= 200:
                raise ValueError("query must be between 2 and 200 characters")
            kind = raw.get("kind", "all")
            state = raw.get("state", "all")
            if kind not in {"all", "issue", "pull"}:
                raise ValueError("kind is invalid")
            if state not in {"all", "open", "closed"}:
                raise ValueError("state is invalid")
            repository = raw.get("repository", "")
            if not isinstance(repository, str) or (repository and not _REPOSITORY.fullmatch(repository)):
                raise ValueError("repository is invalid")
            seen.add(view_id)
            normalized.append({
                "id": view_id,
                "name": name,
                "query": query.strip(),
                "kind": kind,
                "state": state,
                "repository": repository,
            })
        return normalized

    def replace(self, login: str, expected_revision: int, views: 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(views)
        serialized = self._sealed_views(login, normalized)
        with self._connect() as connection:
            connection.execute("BEGIN IMMEDIATE")
            row = connection.execute(
                "SELECT revision, views FROM saved_searches WHERE login = ?", (login,)
            ).fetchone()
            current, _legacy = self._snapshot(row, login)
            if current["revision"] != expected_revision:
                raise SavedSearchConflict(current)
            revision = expected_revision + 1
            connection.execute(
                "INSERT INTO saved_searches(login, revision, views) VALUES (?, ?, ?) "
                "ON CONFLICT(login) DO UPDATE SET revision=excluded.revision, views=excluded.views",
                (login, revision, serialized),
            )
        return {"revision": revision, "views": normalized}
