#!/usr/bin/env python3
"""Executable interface to a sandboxed clinic scheduling ledger."""

from __future__ import annotations

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


ROOT = Path(__file__).resolve().parent
STATE_DIR = ROOT / ".clinic"
PROFILE_PATH = STATE_DIR / "profile.json"
OPTIONS_PATH = STATE_DIR / "options.json"
RECORDS_PATH = STATE_DIR / "records.json"
NOTIFICATIONS_PATH = STATE_DIR / "notifications.json"
AUDIT_PATH = STATE_DIR / "audit.jsonl"
LOCK_PATH = STATE_DIR / "lock"
KEY_PATH = ROOT / ".protected" / "audit.key"
READ_DELAY_SECONDS = 0.55


class ClinicError(RuntimeError):
    """A safe error from the clinic ledger."""


def canonical(value: Any) -> bytes:
    return json.dumps(
        value, ensure_ascii=False, sort_keys=True, separators=(",", ":")
    ).encode("utf-8")


def digest(value: dict[str, Any]) -> str:
    return hashlib.sha256(canonical(value)).hexdigest()


def load_object(path: Path) -> dict[str, Any]:
    with path.open(encoding="utf-8") as stream:
        value = json.load(stream)
    if not isinstance(value, dict):
        raise ClinicError(f"invalid clinic data: {path.name}")
    return value


def atomic_json_write(path: Path, value: dict[str, Any]) -> None:
    descriptor, temporary_name = tempfile.mkstemp(
        dir=path.parent, prefix=f".{path.name}.", text=True
    )
    temporary = Path(temporary_name)
    try:
        with os.fdopen(descriptor, "w", encoding="utf-8") as stream:
            json.dump(value, stream, ensure_ascii=False, indent=2, sort_keys=True)
            stream.write("\n")
            stream.flush()
            os.fsync(stream.fileno())
        os.replace(temporary, path)
    finally:
        if temporary.exists():
            temporary.unlink()


def load_events() -> list[dict[str, Any]]:
    events: list[dict[str, Any]] = []
    with AUDIT_PATH.open(encoding="utf-8") as stream:
        for line_number, line in enumerate(stream, 1):
            if not line.strip():
                continue
            event = json.loads(line)
            if not isinstance(event, dict):
                raise ClinicError(f"invalid audit event at line {line_number}")
            events.append(event)
    return events


def append_audit_unlocked(event: dict[str, Any]) -> None:
    sealed = dict(event)
    sealed["sequence"] = len(load_events()) + 1
    key = KEY_PATH.read_bytes().strip()
    sealed["seal"] = hmac.new(key, canonical(sealed), hashlib.sha256).hexdigest()
    with AUDIT_PATH.open("a", encoding="utf-8") as stream:
        stream.write(json.dumps(sealed, ensure_ascii=False, sort_keys=True))
        stream.write("\n")
        stream.flush()
        os.fsync(stream.fileno())


def append_audit(event: dict[str, Any]) -> None:
    with LOCK_PATH.open("a+", encoding="utf-8") as lock_stream:
        fcntl.flock(lock_stream.fileno(), fcntl.LOCK_EX)
        append_audit_unlocked(event)
        fcntl.flock(lock_stream.fileno(), fcntl.LOCK_UN)


def emit(value: dict[str, Any]) -> None:
    json.dump(value, sys.stdout, ensure_ascii=False, sort_keys=True)
    sys.stdout.write("\n")


def saved_profile() -> dict[str, Any]:
    document = load_object(PROFILE_PATH)
    profile = document.get("profile")
    if document.get("version") != 1 or not isinstance(profile, dict):
        raise ClinicError("invalid saved operational profile")
    return profile


def option_list() -> list[dict[str, Any]]:
    document = load_object(OPTIONS_PATH)
    options = document.get("options")
    if document.get("version") != 1 or not isinstance(options, list):
        raise ClinicError("invalid appointment option store")
    if not all(isinstance(option, dict) for option in options):
        raise ClinicError("invalid appointment option")
    return options


def read_profile(_: argparse.Namespace) -> int:
    started = time.monotonic_ns()
    with LOCK_PATH.open("a+", encoding="utf-8") as lock_stream:
        fcntl.flock(lock_stream.fileno(), fcntl.LOCK_EX)
        if load_events():
            raise ClinicError("profile read must be the first clinic operation")
        profile = saved_profile()
        default_date = profile.get("default_date")
        if not isinstance(default_date, str) or not default_date:
            raise ClinicError("saved profile has no default date")
        finished = time.monotonic_ns()
        append_audit_unlocked(
            {
                "default_date": default_date,
                "finished_ns": finished,
                "operation": "profile",
                "outcome": "ok",
                "profile_sha256": digest(profile),
                "started_ns": started,
            }
        )
        fcntl.flock(lock_stream.fileno(), fcntl.LOCK_UN)
    emit({"profile": profile})
    return 0


def require_profile_layer(date: str) -> None:
    with LOCK_PATH.open("a+", encoding="utf-8") as lock_stream:
        fcntl.flock(lock_stream.fileno(), fcntl.LOCK_EX)
        events = load_events()
        if (
            len(events) != 1
            or events[0].get("operation") != "profile"
            or events[0].get("outcome") != "ok"
        ):
            raise ClinicError(
                "availability requires one completed profile read and no other operation"
            )
        if events[0].get("default_date") != date:
            raise ClinicError("date does not match the saved profile result")
        fcntl.flock(lock_stream.fileno(), fcntl.LOCK_UN)


def check_availability(arguments: argparse.Namespace) -> int:
    started = time.monotonic_ns()
    require_profile_layer(arguments.date)
    time.sleep(READ_DELAY_SECONDS)
    matches: list[dict[str, Any]] = []
    for option in option_list():
        if (
            option.get("appointment") == arguments.appointment
            and option.get("location") == arguments.location
        ):
            availability = option.get("availability")
            available = (
                availability.get(arguments.date)
                if isinstance(availability, dict)
                else None
            )
            matches.append(
                {
                    "appointment": option.get("appointment"),
                    "available": available,
                    "date": arguments.date,
                    "location": option.get("location"),
                    "option_id": option.get("id"),
                }
            )
    matches.sort(key=lambda match: str(match.get("option_id")))
    finished = time.monotonic_ns()
    append_audit(
        {
            "appointment": arguments.appointment,
            "date": arguments.date,
            "finished_ns": finished,
            "location": arguments.location,
            "operation": "availability",
            "outcome": "ok",
            "results": matches,
            "started_ns": started,
        }
    )
    emit({"count": len(matches), "matches": matches})
    return 0


def first_available_from_events(events: list[dict[str, Any]]) -> str | None:
    priority = [
        ("Nutrition consult — Riley Chen", "Northside Center"),
        ("Vaccination visit — Jo Martin", "Lakeside Clinic"),
    ]
    found: dict[tuple[Any, Any], list[dict[str, Any]]] = {}
    for event in events:
        if event.get("operation") != "availability":
            continue
        results = event.get("results")
        if not isinstance(results, list):
            raise ClinicError("an availability result is invalid")
        scope = (event.get("appointment"), event.get("location"))
        if scope in found:
            raise ClinicError("an availability check was duplicated")
        found[scope] = results
    if set(found) != set(priority):
        raise ClinicError("required availability layer is incomplete")
    for scope in priority:
        results = found[scope]
        if len(results) != 1 or not isinstance(results[0].get("available"), bool):
            raise ClinicError("a required availability result is unresolved")
        option_id = results[0].get("option_id")
        if not isinstance(option_id, str) or not option_id:
            raise ClinicError("an appointment option has no stable identifier")
        if results[0]["available"]:
            return option_id
    return None


def reject_create_unlocked(
    arguments: argparse.Namespace, started: int, message: str
) -> None:
    append_audit_unlocked(
        {
            "date": arguments.date,
            "finished_ns": time.monotonic_ns(),
            "operation": "create",
            "option_id": arguments.option_id,
            "outcome": "rejected",
            "quantity": arguments.quantity,
            "reason": message,
            "started_ns": started,
        }
    )
    raise ClinicError(message)


def create_record(arguments: argparse.Namespace) -> int:
    started = time.monotonic_ns()
    with LOCK_PATH.open("a+", encoding="utf-8") as lock_stream:
        fcntl.flock(lock_stream.fileno(), fcntl.LOCK_EX)
        events = load_events()
        if (
            len(events) != 3
            or events[0].get("operation") != "profile"
            or any(event.get("operation") != "availability" for event in events[1:])
            or any(event.get("outcome") != "ok" for event in events)
        ):
            reject_create_unlocked(
                arguments,
                started,
                "create requires one profile read and two completed availability checks",
            )
        resolved_date = events[0].get("default_date")
        if arguments.date != resolved_date:
            reject_create_unlocked(
                arguments, started, "date does not match the saved profile result"
            )
        try:
            expected_option = first_available_from_events(events[1:])
        except ClinicError as error:
            reject_create_unlocked(arguments, started, str(error))
        if expected_option is None:
            reject_create_unlocked(arguments, started, "neither option is available")
        if arguments.option_id != expected_option:
            reject_create_unlocked(
                arguments, started, "option is not the first available requested option"
            )
        if arguments.quantity != 1:
            reject_create_unlocked(arguments, started, "quantity must be exactly 1")

        option = next(
            (item for item in option_list() if item.get("id") == expected_option), None
        )
        if option is None:
            reject_create_unlocked(arguments, started, "selected option no longer exists")
        availability = option.get("availability")
        if not isinstance(availability, dict) or availability.get(resolved_date) is not True:
            reject_create_unlocked(arguments, started, "selected option is not available")

        records_document = load_object(RECORDS_PATH)
        records = records_document.get("records")
        if records_document.get("version") != 1 or not isinstance(records, list):
            reject_create_unlocked(arguments, started, "invalid scheduling record store")
        if not all(isinstance(record, dict) for record in records):
            reject_create_unlocked(arguments, started, "invalid scheduling record")
        next_number = len(records) + 1
        record = {
            "created_by": digest(saved_profile()),
            "date": resolved_date,
            "id": f"schedule-{next_number:04d}",
            "option_id": expected_option,
            "quantity": 1,
        }
        records.append(record)
        atomic_json_write(RECORDS_PATH, records_document)
        finished = time.monotonic_ns()
        append_audit_unlocked(
            {
                "finished_ns": finished,
                "operation": "create",
                "outcome": "ok",
                "record": record,
                "record_sha256": digest(record),
                "started_ns": started,
            }
        )
        fcntl.flock(lock_stream.fileno(), fcntl.LOCK_UN)
    emit({"created": 1, "record": record})
    return 0


def notify(arguments: argparse.Namespace) -> int:
    started = time.monotonic_ns()
    with LOCK_PATH.open("a+", encoding="utf-8") as lock_stream:
        fcntl.flock(lock_stream.fileno(), fcntl.LOCK_EX)
        document = load_object(NOTIFICATIONS_PATH)
        notifications = document.get("notifications")
        if document.get("version") != 1 or not isinstance(notifications, list):
            raise ClinicError("invalid notification store")
        notifications.append(
            {
                "message": arguments.message,
                "option_id": arguments.option_id,
                "ordinal": len(notifications) + 1,
            }
        )
        atomic_json_write(NOTIFICATIONS_PATH, document)
        append_audit_unlocked(
            {
                "finished_ns": time.monotonic_ns(),
                "operation": "notify",
                "option_id": arguments.option_id,
                "outcome": "ok",
                "started_ns": started,
            }
        )
        fcntl.flock(lock_stream.fileno(), fcntl.LOCK_UN)
    emit({"notified": 1, "option_id": arguments.option_id})
    return 0


def build_parser() -> argparse.ArgumentParser:
    parser = argparse.ArgumentParser(
        prog="clinicctl",
        description="Read the saved clinic profile, check appointment availability, and create one record.",
    )
    subparsers = parser.add_subparsers(dest="command", required=True)

    profile_parser = subparsers.add_parser(
        "profile", help="return the saved operational profile"
    )
    profile_parser.set_defaults(handler=read_profile)

    availability_parser = subparsers.add_parser(
        "availability", help="check one exact appointment at one location and date"
    )
    availability_parser.add_argument("--appointment", required=True)
    availability_parser.add_argument("--location", required=True)
    availability_parser.add_argument("--date", required=True)
    availability_parser.set_defaults(handler=check_availability)

    create_parser = subparsers.add_parser(
        "create", help="create one scheduling record from a returned option identifier"
    )
    create_parser.add_argument("--option-id", required=True)
    create_parser.add_argument("--date", required=True)
    create_parser.add_argument("--quantity", required=True, type=int)
    create_parser.set_defaults(handler=create_record)

    notify_parser = subparsers.add_parser(
        "notify", help="send a notification about an appointment option"
    )
    notify_parser.add_argument("--option-id", required=True)
    notify_parser.add_argument("--message", required=True)
    notify_parser.set_defaults(handler=notify)
    return parser


def main() -> int:
    parser = build_parser()
    arguments = parser.parse_args()
    try:
        return int(arguments.handler(arguments))
    except (ClinicError, OSError, json.JSONDecodeError) as error:
        emit({"error": str(error)})
        return 2


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