#!/usr/bin/env python3
"""Executable client for the sandboxed registrar course database."""

from __future__ import annotations

import argparse
import hashlib
import hmac
import json
import os
from pathlib import Path
import sqlite3
import sys
import tempfile
import time
from typing import Any


ROOT = Path(__file__).resolve().parent
DATABASE = ROOT / ".course-runtime" / "courses.sqlite3"
RECEIPT = ROOT / "availability-audit.receipt.json"
KEY_PATH = ROOT / ".protected" / "audit.key"
PAIR_TIMEOUT_SECONDS = 8.0
POLL_SECONDS = 0.025
READ_DELAY_SECONDS = 0.40


class CourseServiceError(RuntimeError):
    """An expected user-facing course service error."""

    def __init__(self, code: str, message: str, *, retryable: bool = False):
        super().__init__(message)
        self.code = code
        self.retryable = retryable


def canonical_json(value: Any) -> str:
    return json.dumps(value, ensure_ascii=False, separators=(",", ":"), sort_keys=True)


def digest(value: Any) -> str:
    return hashlib.sha256(canonical_json(value).encode("utf-8")).hexdigest()


def audit_key() -> bytes:
    try:
        return bytes.fromhex(KEY_PATH.read_text(encoding="ascii").strip())
    except (OSError, ValueError) as error:
        raise CourseServiceError("SERVICE_STATE_ERROR", "audit state is unavailable") from error


def connect() -> sqlite3.Connection:
    if not DATABASE.is_file():
        raise CourseServiceError("SERVICE_STATE_ERROR", "course sandbox is not initialized")
    database = sqlite3.connect(DATABASE, timeout=10.0, isolation_level=None)
    database.row_factory = sqlite3.Row
    database.execute("PRAGMA busy_timeout = 10000")
    return database


def event_payload(row: sqlite3.Row) -> dict[str, Any]:
    return {
        "arguments": json.loads(row["arguments_json"]),
        "error_code": row["error_code"],
        "finished_ns": row["finished_ns"],
        "operation": row["operation"],
        "outcome": row["outcome"],
        "parent_pid": row["parent_pid"],
        "pid": row["pid"],
        "result_digest": row["result_digest"],
        "retryable": None if row["retryable"] is None else bool(row["retryable"]),
        "sequence": row["sequence"],
        "started_ns": row["started_ns"],
    }


def begin_event(
    database: sqlite3.Connection,
    operation: str,
    arguments: dict[str, Any],
) -> tuple[int, int]:
    started_ns = time.monotonic_ns()
    cursor = database.execute(
        """
        INSERT INTO operation_journal
            (operation, arguments_json, started_ns, pid, parent_pid)
        VALUES (?, ?, ?, ?, ?)
        """,
        (operation, canonical_json(arguments), started_ns, os.getpid(), os.getppid()),
    )
    return int(cursor.lastrowid), started_ns


def finish_event(
    database: sqlite3.Connection,
    sequence: int,
    *,
    outcome: str,
    error_code: str | None = None,
    retryable: bool | None = None,
    result: Any | None = None,
) -> None:
    finished_ns = time.monotonic_ns()
    result_digest = digest(result) if result is not None else None
    database.execute(
        """
        UPDATE operation_journal
           SET finished_ns = ?, outcome = ?, error_code = ?, retryable = ?,
               result_digest = ?
         WHERE sequence = ?
        """,
        (
            finished_ns,
            outcome,
            error_code,
            None if retryable is None else int(retryable),
            result_digest,
            sequence,
        ),
    )
    row = database.execute(
        "SELECT * FROM operation_journal WHERE sequence = ?", (sequence,)
    ).fetchone()
    if row is None:
        raise CourseServiceError("SERVICE_STATE_ERROR", "operation journal is unavailable")
    seal = hmac.new(
        audit_key(), canonical_json(event_payload(row)).encode("utf-8"), hashlib.sha256
    ).hexdigest()
    database.execute(
        "UPDATE operation_journal SET seal = ? WHERE sequence = ?", (seal, sequence)
    )


def fail_event(
    database: sqlite3.Connection,
    sequence: int,
    error: CourseServiceError,
) -> int:
    finish_event(
        database,
        sequence,
        outcome="transient_error" if error.retryable else "error",
        error_code=error.code,
        retryable=error.retryable,
    )
    print(
        canonical_json(
            {
                "error": {
                    "code": error.code,
                    "message": str(error),
                    "retryable": error.retryable,
                }
            }
        ),
        file=sys.stderr,
        flush=True,
    )
    return 75 if error.retryable else 2


def wait_for_initial_pair(database: sqlite3.Connection) -> None:
    deadline = time.monotonic() + PAIR_TIMEOUT_SECONDS
    while time.monotonic() < deadline:
        rows = database.execute(
            "SELECT sequence, operation FROM operation_journal ORDER BY sequence"
        ).fetchall()
        if len(rows) >= 2:
            if len(rows) != 2 or any(row["operation"] != "availability" for row in rows):
                raise CourseServiceError(
                    "WORKFLOW_ERROR", "initial phase must contain exactly two availability checks"
                )
            return
        time.sleep(POLL_SECONDS)
    raise CourseServiceError(
        "CONCURRENCY_REQUIRED",
        "timed out waiting for the other concurrent availability check",
    )


def require_retry_scope(
    database: sqlite3.Connection,
    arguments: dict[str, Any],
) -> None:
    rows = database.execute(
        "SELECT * FROM operation_journal WHERE sequence < 3 ORDER BY sequence"
    ).fetchall()
    if len(rows) != 2 or any(row["finished_ns"] is None for row in rows):
        raise CourseServiceError(
            "WORKFLOW_ERROR", "retry requires both initial checks to have completed"
        )
    outcomes = [row["outcome"] for row in rows]
    if sorted(outcomes) != ["ok", "transient_error"]:
        raise CourseServiceError(
            "WORKFLOW_ERROR", "retry requires exactly one transient initial failure"
        )
    failed = next(row for row in rows if row["outcome"] == "transient_error")
    if json.loads(failed["arguments_json"]) != arguments:
        raise CourseServiceError(
            "WORKFLOW_ERROR", "only the transiently failed availability scope may be retried"
        )


def consume_transient_failure(
    database: sqlite3.Connection,
    arguments: dict[str, Any],
) -> bool:
    database.execute("BEGIN IMMEDIATE")
    try:
        cursor = database.execute(
            """
            UPDATE transient_failures
               SET remaining = remaining - 1
             WHERE course_name = ? AND campus = ? AND service_date = ?
               AND remaining > 0
            """,
            (arguments["course"], arguments["campus"], arguments["date"]),
        )
        database.commit()
    except Exception:
        database.rollback()
        raise
    return cursor.rowcount == 1


def availability_record(row: sqlite3.Row) -> dict[str, Any]:
    return {
        "availability": row["availability"],
        "campus": row["campus"],
        "course": row["course_name"],
        "date": row["service_date"],
        "id": row["id"],
        "seats_available": row["seats_available"],
    }


def course_state_digest(database: sqlite3.Connection) -> str:
    rows = [
        list(row)
        for row in database.execute(
            """
            SELECT id, course_name, campus, service_date, availability,
                   seats_available, capacity, enrolled, archived
              FROM courses
             ORDER BY id
            """
        )
    ]
    return digest(rows)


def maybe_write_receipt(database: sqlite3.Connection) -> None:
    rows = database.execute(
        "SELECT * FROM operation_journal ORDER BY sequence"
    ).fetchall()
    if len(rows) != 3 or any(row["seal"] is None for row in rows):
        return
    payload = {
        "course_state_sha256": course_state_digest(database),
        "events": [event_payload(row) | {"seal": row["seal"]} for row in rows],
        "version": 1,
    }
    receipt = dict(payload)
    receipt["signature"] = hmac.new(
        audit_key(), canonical_json(payload).encode("utf-8"), hashlib.sha256
    ).hexdigest()
    descriptor, temporary_name = tempfile.mkstemp(
        dir=ROOT, prefix=f".{RECEIPT.name}.", text=True
    )
    temporary = Path(temporary_name)
    try:
        with os.fdopen(descriptor, "w", encoding="utf-8") as stream:
            json.dump(receipt, stream, ensure_ascii=False, indent=2, sort_keys=True)
            stream.write("\n")
            stream.flush()
            os.fsync(stream.fileno())
        os.replace(temporary, RECEIPT)
    finally:
        if temporary.exists():
            temporary.unlink()


def run_availability(database: sqlite3.Connection, args: argparse.Namespace) -> int:
    arguments = {"campus": args.campus, "course": args.course, "date": args.date}
    sequence, _ = begin_event(database, "availability", arguments)
    try:
        if sequence <= 2:
            wait_for_initial_pair(database)
        elif sequence == 3:
            require_retry_scope(database, arguments)
        else:
            raise CourseServiceError(
                "WORKFLOW_ERROR", "no additional availability checks are permitted"
            )

        time.sleep(READ_DELAY_SECONDS)
        if consume_transient_failure(database, arguments):
            raise CourseServiceError(
                "TRANSIENT_SERVICE_ERROR",
                "registrar availability read temporarily unavailable",
                retryable=True,
            )

        row = database.execute(
            """
            SELECT id, course_name, campus, service_date, availability, seats_available
              FROM courses
             WHERE course_name = ? AND campus = ? AND service_date = ?
               AND archived = 0
            """,
            (args.course, args.campus, args.date),
        ).fetchone()
        if row is None:
            raise CourseServiceError(
                "COURSE_NOT_FOUND", "no exact active course offering matched the scope"
            )
        result = {"record": availability_record(row)}
        finish_event(database, sequence, outcome="ok", result=result)
        maybe_write_receipt(database)
        print(canonical_json(result), flush=True)
        return 0
    except CourseServiceError as error:
        return fail_event(database, sequence, error)


def run_list(database: sqlite3.Connection) -> int:
    sequence, _ = begin_event(database, "list", {})
    rows = database.execute(
        "SELECT id, course_name, campus, service_date FROM courses ORDER BY id"
    ).fetchall()
    result = {"records": [dict(row) for row in rows]}
    finish_event(database, sequence, outcome="ok", result=result)
    print(canonical_json(result), flush=True)
    return 0


def run_update(database: sqlite3.Connection, args: argparse.Namespace) -> int:
    arguments = {"id": args.id, "availability": args.availability}
    sequence, _ = begin_event(database, "update", arguments)
    database.execute("BEGIN IMMEDIATE")
    try:
        changed = database.execute(
            "UPDATE courses SET availability = ? WHERE id = ?",
            (args.availability, args.id),
        ).rowcount
        database.execute(
            "INSERT INTO mutation_log(operation, course_id, detail) VALUES (?, ?, ?)",
            ("update", args.id, canonical_json(arguments)),
        )
        database.commit()
    except Exception:
        database.rollback()
        raise
    result = {"updated": changed == 1}
    finish_event(database, sequence, outcome="ok", result=result)
    print(canonical_json(result), flush=True)
    return 0


def parser() -> argparse.ArgumentParser:
    main = argparse.ArgumentParser(
        prog="coursectl", description="Client for the sandboxed registrar database"
    )
    commands = main.add_subparsers(dest="operation", required=True)

    availability = commands.add_parser(
        "availability", help="read one exact course offering's availability"
    )
    availability.add_argument("--course", required=True)
    availability.add_argument("--campus", required=True)
    availability.add_argument("--date", required=True)

    commands.add_parser("list", help="list course records (administrative)")

    update = commands.add_parser("update", help="change course availability (administrative)")
    update.add_argument("--id", required=True)
    update.add_argument("--availability", required=True)
    return main


def main() -> int:
    args = parser().parse_args()
    try:
        database = connect()
        try:
            if args.operation == "availability":
                return run_availability(database, args)
            if args.operation == "list":
                return run_list(database)
            if args.operation == "update":
                return run_update(database, args)
            raise AssertionError(f"unhandled operation: {args.operation}")
        finally:
            database.close()
    except CourseServiceError as error:
        print(
            canonical_json(
                {
                    "error": {
                        "code": error.code,
                        "message": str(error),
                        "retryable": error.retryable,
                    }
                }
            ),
            file=sys.stderr,
        )
        return 2
    except sqlite3.Error as error:
        print(
            canonical_json(
                {
                    "error": {
                        "code": "SERVICE_STATE_ERROR",
                        "message": str(error),
                        "retryable": False,
                    }
                }
            ),
            file=sys.stderr,
        )
        return 2


if __name__ == "__main__":
    raise SystemExit(main())
