"""Durable, account-scoped ordered Today plans."""

import json
import sqlite3
import time
from datetime import date
from pathlib import Path

from src.private_state import connect_private_sqlite


class TodayPlanFull(ValueError):
    """Raised when an add would exceed the bounded Today plan."""


class TodaySessionConflict(ValueError):
    """Raised when a device updates an obsolete active-session revision."""

    def __init__(self, session: dict):
        super().__init__("active Today session changed on another device")
        self.session = session


class TodayStore:
    def __init__(
        self,
        path: str | Path,
        *,
        limit: int = 5,
        timeout: float = 1.0,
        operation_limit: int = 4096,
        operation_retention_seconds: float = 30 * 24 * 60 * 60,
        recap_limit: int = 100,
        clock=time.time,
    ):
        self.path = Path(path)
        self.limit = limit
        self.timeout = timeout
        self.operation_limit = operation_limit
        self.operation_retention_seconds = operation_retention_seconds
        self.recap_limit = recap_limit
        self.clock = clock
        self._initialize()

    def _initialize(self) -> None:
        connection = connect_private_sqlite(self.path, timeout=self.timeout)
        if connection.execute("PRAGMA user_version").fetchone()[0] >= 3:
            connection.close()
            return
        connection.execute("PRAGMA journal_mode=WAL")
        connection.execute(
            """
            CREATE TABLE IF NOT EXISTS today_plans (
                login TEXT PRIMARY KEY,
                revision INTEGER NOT NULL,
                ids TEXT NOT NULL,
                capacity_minutes INTEGER,
                estimates TEXT NOT NULL DEFAULT '{}',
                plan_date TEXT,
                timezone TEXT
            )
            """
        )
        plan_columns = {row[1] for row in connection.execute("PRAGMA table_info(today_plans)")}
        if "capacity_minutes" not in plan_columns:
            connection.execute("ALTER TABLE today_plans ADD COLUMN capacity_minutes INTEGER")
        if "estimates" not in plan_columns:
            connection.execute("ALTER TABLE today_plans ADD COLUMN estimates TEXT NOT NULL DEFAULT '{}'")
        if "plan_date" not in plan_columns:
            connection.execute("ALTER TABLE today_plans ADD COLUMN plan_date TEXT")
        if "timezone" not in plan_columns:
            connection.execute("ALTER TABLE today_plans ADD COLUMN timezone TEXT")
        connection.execute(
            """
            CREATE TABLE IF NOT EXISTS today_operations (
                login TEXT NOT NULL,
                operation_id TEXT NOT NULL,
                created_at REAL NOT NULL,
                PRIMARY KEY (login, operation_id)
            )
            """
        )
        columns = {row[1] for row in connection.execute("PRAGMA table_info(today_operations)")}
        if "created_at" not in columns:
            connection.execute("ALTER TABLE today_operations ADD COLUMN created_at REAL")
        connection.execute(
            "UPDATE today_operations SET created_at = ? WHERE created_at IS NULL",
            (self.clock(),),
        )
        connection.execute(
            """
            CREATE TABLE IF NOT EXISTS today_recaps (
                login TEXT NOT NULL,
                session_id TEXT NOT NULL,
                created_at REAL NOT NULL,
                items TEXT NOT NULL,
                PRIMARY KEY (login, session_id)
            )
            """
        )
        connection.execute(
            "CREATE INDEX IF NOT EXISTS today_recaps_recent "
            "ON today_recaps(login, created_at DESC)"
        )
        connection.execute(
            """
            CREATE TABLE IF NOT EXISTS today_time_logs (
                login TEXT NOT NULL,
                session_id TEXT NOT NULL,
                identity TEXT NOT NULL,
                actual_minutes INTEGER NOT NULL,
                status TEXT NOT NULL,
                PRIMARY KEY (login, session_id, identity)
            )
            """
        )
        connection.execute(
            """
            CREATE TABLE IF NOT EXISTS today_sessions (
                login TEXT PRIMARY KEY,
                revision INTEGER NOT NULL,
                device_id TEXT NOT NULL,
                identity TEXT NOT NULL,
                elapsed_ms INTEGER NOT NULL,
                running INTEGER NOT NULL,
                break_deadline_at INTEGER,
                updated_at REAL NOT NULL
            )
            """
        )
        session_columns = {row[1] for row in connection.execute("PRAGMA table_info(today_sessions)")}
        if "break_deadline_at" not in session_columns:
            connection.execute("ALTER TABLE today_sessions ADD COLUMN break_deadline_at INTEGER")
        connection.execute("PRAGMA user_version = 3")
        connection.commit()
        connection.close()

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

    def _record_operation(self, connection: sqlite3.Connection, login: str, operation_id: str) -> None:
        now = self.clock()
        connection.execute(
            "INSERT INTO today_operations(login, operation_id, created_at) VALUES (?, ?, ?)",
            (login, operation_id, now),
        )
        connection.execute(
            "DELETE FROM today_operations WHERE login = ? AND created_at < ?",
            (login, now - self.operation_retention_seconds),
        )
        connection.execute(
            "DELETE FROM today_operations WHERE login = ? AND rowid NOT IN "
            "(SELECT rowid FROM today_operations WHERE login = ? "
            "ORDER BY created_at DESC, rowid DESC LIMIT ?)",
            (login, login, self.operation_limit),
        )

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

    @staticmethod
    def _snapshot(row) -> dict:
        if row is None:
            return {"revision": 0, "ids": [], "capacity_minutes": None, "estimates": {}}
        ids = json.loads(row[1])
        estimates = json.loads(row[3] or "{}")
        snapshot = {
            "revision": int(row[0]),
            "ids": ids,
            "capacity_minutes": row[2],
            "estimates": {item_id: minutes for item_id, minutes in estimates.items() if item_id in ids},
        }
        if len(row) > 4 and row[4]:
            snapshot["plan_date"] = row[4]
            snapshot["timezone"] = row[5]
        return snapshot

    def get(self, login: str) -> dict:
        with self._connect() as connection:
            row = connection.execute(
                "SELECT revision, ids, capacity_minutes, estimates, plan_date, timezone "
                "FROM today_plans WHERE login = ?",
                (self._normalize_login(login),),
            ).fetchone()
        return self._snapshot(row)

    @staticmethod
    def _empty_session() -> dict:
        return {
            "revision": 0, "device_id": "", "identity": "",
            "elapsed_ms": 0, "running": False, "break_deadline_at": None, "updated_at": None,
        }

    def get_session(self, login: str) -> dict:
        with self._connect() as connection:
            row = connection.execute(
                "SELECT revision, device_id, identity, elapsed_ms, running, break_deadline_at, updated_at "
                "FROM today_sessions WHERE login = ?",
                (self._normalize_login(login),),
            ).fetchone()
        if row is None:
            return self._empty_session()
        return {
            "revision": int(row[0]), "device_id": row[1], "identity": row[2],
            "elapsed_ms": int(row[3]), "running": bool(row[4]),
            "break_deadline_at": row[5], "updated_at": row[6],
        }

    def update_session(
        self, login: str, *, base_revision: int, device_id: str,
        identity: str, elapsed_ms: int, running: bool, break_deadline_at: int | None = None,
    ) -> dict:
        login = self._normalize_login(login)
        updated_at = self.clock()
        with self._connect() as connection:
            connection.execute("BEGIN IMMEDIATE")
            current = connection.execute(
                "SELECT revision, device_id, identity, elapsed_ms, running, break_deadline_at, updated_at "
                "FROM today_sessions WHERE login = ?", (login,)
            ).fetchone()
            current_revision = int(current[0]) if current else 0
            if base_revision != current_revision:
                session = self._empty_session() if current is None else {
                    "revision": current_revision, "device_id": current[1], "identity": current[2],
                    "elapsed_ms": int(current[3]), "running": bool(current[4]),
                    "break_deadline_at": current[5], "updated_at": current[6],
                }
                raise TodaySessionConflict(session)
            revision = current_revision + 1
            connection.execute(
                "INSERT INTO today_sessions(login, revision, device_id, identity, elapsed_ms, running, break_deadline_at, updated_at) "
                "VALUES (?, ?, ?, ?, ?, ?, ?, ?) "
                "ON CONFLICT(login) DO UPDATE SET revision=excluded.revision, device_id=excluded.device_id, "
                "identity=excluded.identity, elapsed_ms=excluded.elapsed_ms, running=excluded.running, "
                "break_deadline_at=excluded.break_deadline_at, updated_at=excluded.updated_at",
                (login, revision, device_id, identity, elapsed_ms, int(running), break_deadline_at, updated_at),
            )
        return self.get_session(login)

    @staticmethod
    def _normalize_recap_items(items: list[dict]) -> list[dict]:
        if not isinstance(items, list) or not items:
            raise ValueError("recap requires at least one item")
        if len(items) > 20:
            raise ValueError("recap is limited to 20 items")
        normalized = []
        seen = set()
        for item in items:
            identity = item.get("identity", "") if isinstance(item, dict) else ""
            if not isinstance(identity, str) or not identity.strip() or len(identity) > 500:
                raise ValueError("recap item identity is required and bounded")
            identity = identity.strip()
            if identity in seen:
                raise ValueError("recap item identities must be unique")
            actual = item.get("actual_minutes")
            estimate = item.get("estimate_minutes")
            if not isinstance(actual, int) or isinstance(actual, bool) or actual < 0 or actual > 1440:
                raise ValueError("actual minutes must be between 0 and 1440")
            if estimate is not None and (
                not isinstance(estimate, int) or isinstance(estimate, bool)
                or estimate < 5 or estimate > 1440
            ):
                raise ValueError("estimate minutes must be between 5 and 1440")
            seen.add(identity)
            normalized.append({
                "identity": identity,
                "estimate_minutes": estimate,
                "actual_minutes": actual,
            })
        return normalized

    @staticmethod
    def _recap_snapshot(session_id: str, created_at: float, serialized: str) -> dict:
        items = json.loads(serialized)
        estimated = sum(item["estimate_minutes"] for item in items if item["estimate_minutes"] is not None)
        actual = sum(item["actual_minutes"] for item in items)
        return {
            "session_id": session_id,
            "created_at": created_at,
            "items": items,
            "estimated_minutes": estimated,
            "actual_minutes": actual,
            "variance_minutes": actual - estimated,
        }

    def save_recap(self, login: str, session_id: str, items: list[dict]) -> dict:
        login = self._normalize_login(login)
        if not isinstance(session_id, str) or not session_id.strip() or len(session_id) > 100:
            raise ValueError("session_id is required and bounded")
        session_id = session_id.strip()
        normalized = self._normalize_recap_items(items)
        serialized = json.dumps(normalized, separators=(",", ":"))
        with self._connect() as connection:
            connection.execute("BEGIN IMMEDIATE")
            existing = connection.execute(
                "SELECT created_at, items FROM today_recaps WHERE login = ? AND session_id = ?",
                (login, session_id),
            ).fetchone()
            if existing is not None:
                return self._recap_snapshot(session_id, existing[0], existing[1])
            created_at = self.clock()
            connection.execute(
                "INSERT INTO today_recaps(login, session_id, created_at, items) VALUES (?, ?, ?, ?)",
                (login, session_id, created_at, serialized),
            )
            connection.execute(
                "DELETE FROM today_recaps WHERE login = ? AND rowid NOT IN "
                "(SELECT rowid FROM today_recaps WHERE login = ? "
                "ORDER BY created_at DESC, rowid DESC LIMIT ?)",
                (login, login, self.recap_limit),
            )
        return self._recap_snapshot(session_id, created_at, serialized)

    def list_recaps(self, login: str, *, limit: int = 30) -> list[dict]:
        bounded_limit = max(1, min(int(limit), self.recap_limit, 100))
        with self._connect() as connection:
            rows = connection.execute(
                "SELECT session_id, created_at, items FROM today_recaps WHERE login = ? "
                "ORDER BY created_at DESC, rowid DESC LIMIT ?",
                (self._normalize_login(login), bounded_limit),
            ).fetchall()
        return [self._recap_snapshot(*row) for row in rows]

    def begin_time_log(self, login: str, session_id: str, identity: str, actual_minutes: int) -> str:
        """Claim one recap item for upstream logging, returning its current state."""
        login = self._normalize_login(login)
        with self._connect() as connection:
            connection.execute("BEGIN IMMEDIATE")
            row = connection.execute(
                "SELECT actual_minutes, status FROM today_time_logs "
                "WHERE login = ? AND session_id = ? AND identity = ?",
                (login, session_id, identity),
            ).fetchone()
            if row is None:
                connection.execute(
                    "INSERT INTO today_time_logs(login, session_id, identity, actual_minutes, status) "
                    "VALUES (?, ?, ?, ?, 'pending')",
                    (login, session_id, identity, actual_minutes),
                )
                return "claimed"
            if row[0] != actual_minutes:
                raise ValueError("logged recap time cannot be changed")
            if row[1] == "failed":
                connection.execute(
                    "UPDATE today_time_logs SET status = 'pending' "
                    "WHERE login = ? AND session_id = ? AND identity = ?",
                    (login, session_id, identity),
                )
                return "claimed"
            return row[1]

    def finish_time_log(self, login: str, session_id: str, identity: str, *, succeeded: bool) -> None:
        with self._connect() as connection:
            connection.execute(
                "UPDATE today_time_logs SET status = ? "
                "WHERE login = ? AND session_id = ? AND identity = ? AND status = 'pending'",
                ("logged" if succeeded else "failed", self._normalize_login(login), session_id, identity),
            )

    def apply(
        self,
        login: str,
        operation_id: str,
        action: str,
        item_id: str,
        *,
        direction: str | None = None,
    ) -> dict:
        login = self._normalize_login(login)
        if not operation_id or not item_id:
            raise ValueError("operation_id and item_id are required")
        if action not in {"add", "remove", "move"}:
            raise ValueError("unsupported Today action")
        if action == "move" and direction not in {"up", "down"}:
            raise ValueError("move direction must be up or down")

        with self._connect() as connection:
            connection.execute("BEGIN IMMEDIATE")
            row = connection.execute(
                "SELECT revision, ids, capacity_minutes, estimates, plan_date, timezone "
                "FROM today_plans WHERE login = ?", (login,)
            ).fetchone()
            snapshot = self._snapshot(row)
            duplicate = connection.execute(
                "SELECT 1 FROM today_operations WHERE login = ? AND operation_id = ?",
                (login, operation_id),
            ).fetchone()
            if duplicate:
                return snapshot

            ids = list(snapshot["ids"])
            estimates = dict(snapshot["estimates"])
            changed = False
            if action == "add":
                if item_id not in ids:
                    if len(ids) >= self.limit:
                        raise TodayPlanFull("Today is limited to five items")
                    ids.append(item_id)
                    changed = True
            elif action == "remove":
                if item_id in ids:
                    ids.remove(item_id)
                    estimates.pop(item_id, None)
                    changed = True
            else:
                try:
                    index = ids.index(item_id)
                except ValueError:
                    index = -1
                target = index - 1 if direction == "up" else index + 1
                if index >= 0 and 0 <= target < len(ids):
                    ids[index], ids[target] = ids[target], ids[index]
                    changed = True

            revision = snapshot["revision"] + (1 if changed else 0)
            serialized_ids = json.dumps(ids, separators=(",", ":"))
            serialized_estimates = json.dumps(estimates, separators=(",", ":"))
            if row is None:
                connection.execute(
                    "INSERT INTO today_plans(login, revision, ids, capacity_minutes, estimates, plan_date, timezone) "
                    "VALUES (?, ?, ?, ?, ?, ?, ?)",
                    (login, revision, serialized_ids, snapshot["capacity_minutes"], serialized_estimates,
                     snapshot.get("plan_date"), snapshot.get("timezone")),
                )
            elif changed:
                connection.execute(
                    "UPDATE today_plans SET revision = ?, ids = ?, estimates = ? WHERE login = ?",
                    (revision, serialized_ids, serialized_estimates, login),
                )
            self._record_operation(connection, login, operation_id)
            result = {
                "revision": revision,
                "ids": ids,
                "capacity_minutes": snapshot["capacity_minutes"],
                "estimates": estimates,
            }
            if snapshot.get("plan_date"):
                result["plan_date"] = snapshot["plan_date"]
                result["timezone"] = snapshot["timezone"]
            return result

    def apply_batch(self, login: str, operations: list[dict]) -> dict:
        """Apply an ordered batch with one lock and receipt per operation."""
        login = self._normalize_login(login)
        with self._connect() as connection:
            connection.execute("BEGIN IMMEDIATE")
            row = connection.execute(
                "SELECT revision, ids, capacity_minutes, estimates, plan_date, timezone "
                "FROM today_plans WHERE login = ?", (login,)
            ).fetchone()
            snapshot = self._snapshot(row)
            ids = list(snapshot["ids"])
            capacity_minutes = snapshot["capacity_minutes"]
            estimates = dict(snapshot["estimates"])
            plan_date = snapshot.get("plan_date")
            timezone = snapshot.get("timezone")
            revision = snapshot["revision"]
            accepted: list[str] = []
            duplicates: list[str] = []
            rejected: list[dict[str, str]] = []

            for operation in operations:
                operation_id = operation.get("operation_id", "")
                action = operation.get("action", "")
                item_id = operation.get("item_id", "")
                direction = operation.get("direction")
                if not operation_id or not item_id:
                    raise ValueError("operation_id and item_id are required")
                if action not in {"add", "remove", "move", "configure", "rollover"}:
                    raise ValueError("unsupported Today action")
                if action == "move" and direction not in {"up", "down"}:
                    raise ValueError("move direction must be up or down")
                duplicate = connection.execute(
                    "SELECT 1 FROM today_operations WHERE login = ? AND operation_id = ?",
                    (login, operation_id),
                ).fetchone()
                if duplicate:
                    duplicates.append(operation_id)
                    continue

                base_revision = operation.get("base_revision")
                if base_revision is None:
                    base_revision = snapshot["revision"]
                if not isinstance(base_revision, int) or isinstance(base_revision, bool) or base_revision < 0:
                    raise ValueError("base_revision must be a non-negative integer")
                if base_revision < snapshot["revision"]:
                    self._record_operation(connection, login, operation_id)
                    rejected.append({"operation_id": operation_id, "reason": "stale_intent"})
                    continue

                changed = False
                if action == "add":
                    if item_id not in ids:
                        if len(ids) >= self.limit:
                            rejected.append({"operation_id": operation_id, "reason": "today_full"})
                            continue
                        ids.append(item_id)
                        changed = True
                elif action == "remove":
                    if item_id in ids:
                        ids.remove(item_id)
                        estimates.pop(item_id, None)
                        changed = True
                elif action == "move":
                    try:
                        index = ids.index(item_id)
                    except ValueError:
                        index = -1
                    target = index - 1 if direction == "up" else index + 1
                    if index >= 0 and 0 <= target < len(ids):
                        ids[index], ids[target] = ids[target], ids[index]
                        changed = True
                elif action == "configure":
                    proposed_capacity = operation.get("capacity_minutes")
                    if proposed_capacity is not None and (
                        not isinstance(proposed_capacity, int) or isinstance(proposed_capacity, bool)
                        or proposed_capacity < 15 or proposed_capacity > 1440
                    ):
                        raise ValueError("capacity_minutes must be between 15 and 1440")
                    proposed_estimates = operation.get("estimates", {})
                    if not isinstance(proposed_estimates, dict):
                        raise ValueError("estimates must be an object")
                    normalized_estimates = {}
                    for estimate_id, minutes in proposed_estimates.items():
                        if estimate_id not in ids:
                            continue
                        if not isinstance(minutes, int) or isinstance(minutes, bool) or minutes < 5 or minutes > 1440:
                            raise ValueError("estimate minutes must be between 5 and 1440")
                        normalized_estimates[estimate_id] = minutes
                    changed = capacity_minutes != proposed_capacity or estimates != normalized_estimates
                    capacity_minutes = proposed_capacity
                    estimates = normalized_estimates
                else:
                    proposed_date = operation.get("plan_date")
                    proposed_timezone = operation.get("timezone")
                    try:
                        if date.fromisoformat(proposed_date or "").isoformat() != proposed_date:
                            raise ValueError
                    except (TypeError, ValueError):
                        raise ValueError("plan_date must be an ISO calendar date") from None
                    if not isinstance(proposed_timezone, str) or not proposed_timezone.strip() or len(proposed_timezone) > 100:
                        raise ValueError("timezone is required and bounded")
                    proposed_ids = operation.get("ids")
                    if not isinstance(proposed_ids, list) or len(proposed_ids) > self.limit or any(
                        not isinstance(candidate, str) or not candidate or len(candidate) > 500
                        for candidate in proposed_ids
                    ):
                        raise ValueError("rollover IDs are invalid or exceed the Today limit")
                    if len(set(proposed_ids)) != len(proposed_ids):
                        raise ValueError("rollover IDs must be unique")
                    proposed_capacity = operation.get("capacity_minutes")
                    if proposed_capacity is not None and (
                        not isinstance(proposed_capacity, int) or isinstance(proposed_capacity, bool)
                        or proposed_capacity < 15 or proposed_capacity > 1440
                    ):
                        raise ValueError("capacity_minutes must be between 15 and 1440")
                    proposed_estimates = operation.get("estimates", {})
                    if not isinstance(proposed_estimates, dict):
                        raise ValueError("estimates must be an object")
                    normalized_estimates = {}
                    for estimate_id, minutes in proposed_estimates.items():
                        if estimate_id not in proposed_ids:
                            continue
                        if not isinstance(minutes, int) or isinstance(minutes, bool) or minutes < 5 or minutes > 1440:
                            raise ValueError("estimate minutes must be between 5 and 1440")
                        normalized_estimates[estimate_id] = minutes
                    changed = (ids != proposed_ids or capacity_minutes != proposed_capacity or
                               estimates != normalized_estimates or plan_date != proposed_date or
                               timezone != proposed_timezone.strip())
                    ids = list(proposed_ids)
                    capacity_minutes = proposed_capacity
                    estimates = normalized_estimates
                    plan_date = proposed_date
                    timezone = proposed_timezone.strip()

                revision += 1 if changed else 0
                self._record_operation(connection, login, operation_id)
                accepted.append(operation_id)

            serialized = json.dumps(ids, separators=(",", ":"))
            serialized_estimates = json.dumps(estimates, separators=(",", ":"))
            if row is None:
                connection.execute(
                    "INSERT INTO today_plans(login, revision, ids, capacity_minutes, estimates, plan_date, timezone) "
                    "VALUES (?, ?, ?, ?, ?, ?, ?)",
                    (login, revision, serialized, capacity_minutes, serialized_estimates, plan_date, timezone),
                )
            elif accepted:
                connection.execute(
                    "UPDATE today_plans SET revision = ?, ids = ?, capacity_minutes = ?, estimates = ?, "
                    "plan_date = ?, timezone = ? WHERE login = ?",
                    (revision, serialized, capacity_minutes, serialized_estimates, plan_date, timezone, login),
                )
            result = {
                "revision": revision,
                "ids": ids,
                "capacity_minutes": capacity_minutes,
                "estimates": estimates,
                "accepted_operation_ids": accepted,
                "duplicate_operation_ids": duplicates,
                "rejected_operations": rejected,
            }
            if plan_date:
                result["plan_date"] = plan_date
                result["timezone"] = timezone
            return result
