#!/usr/bin/env python3
"""Executable interface to the sandboxed hospitality registry."""

from __future__ import annotations

import argparse
from contextlib import contextmanager
import fcntl
import hashlib
import hmac
import json
import os
from pathlib import Path
import sys
import tempfile
import time
from typing import Any, Iterator


ROOT = Path(__file__).resolve().parent
STATE_DIR = ROOT / ".hospitality"
PROFILE_PATH = STATE_DIR / "profile.json"
AVAILABILITY_PATH = STATE_DIR / "availability.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 / ".harness" / "audit.key"
READ_DELAY_SECONDS = 0.55


class RegistryError(RuntimeError):
    """A user-visible registry failure."""


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


def digest(value: 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 RegistryError(f"invalid registry store: {path.name}")
    return value


def atomic_write(path: Path, value: dict[str, Any]) -> None:
    descriptor, temporary_name = tempfile.mkstemp(
        dir=path.parent, prefix=f".{path.name}.", suffix=".tmp", 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)
            stream.write("\n")
            stream.flush()
            os.fsync(stream.fileno())
        os.replace(temporary, path)
    finally:
        if temporary.exists():
            temporary.unlink()


@contextmanager
def registry_lock() -> Iterator[None]:
    with LOCK_PATH.open("a+", encoding="utf-8") as lock_stream:
        fcntl.flock(lock_stream.fileno(), fcntl.LOCK_EX)
        try:
            yield
        finally:
            fcntl.flock(lock_stream.fileno(), fcntl.LOCK_UN)


def load_audit() -> list[dict[str, Any]]:
    events: list[dict[str, Any]] = []
    with AUDIT_PATH.open(encoding="utf-8") as stream:
        for line in stream:
            if not line.strip():
                continue
            value = json.loads(line)
            if not isinstance(value, dict):
                raise RegistryError("invalid audit event")
            events.append(value)
    return events


def append_audit_locked(event: dict[str, Any]) -> None:
    events = load_audit()
    sealed = dict(event)
    sealed["sequence"] = len(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 registry_lock():
        append_audit_locked(event)


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


def interval_event(operation: str, started_ns: int, **fields: Any) -> dict[str, Any]:
    return {
        "operation": operation,
        "started_ns": started_ns,
        "finished_ns": time.monotonic_ns(),
        **fields,
    }


def latest_profile_date(events: list[dict[str, Any]]) -> str | None:
    profiles = [
        event.get("result_default_date")
        for event in events
        if event.get("operation") == "profile" and event.get("outcome") == "ok"
    ]
    return profiles[-1] if profiles and isinstance(profiles[-1], str) else None


def reject(operation: str, started_ns: int, message: str, **fields: Any) -> None:
    append_audit(
        interval_event(operation, started_ns, outcome="rejected", error=message, **fields)
    )
    raise RegistryError(message)


def run_profile(_: argparse.Namespace) -> int:
    started_ns = time.monotonic_ns()
    profile = load_object(PROFILE_PATH)
    time.sleep(0.08)
    default_date = profile.get("default_date")
    if not isinstance(default_date, str) or not default_date:
        reject("profile", started_ns, "saved profile has no default date")
    append_audit(
        interval_event(
            "profile",
            started_ns,
            outcome="ok",
            result_default_date=default_date,
            result_profile_sha256=digest(profile),
        )
    )
    emit({"profile": profile})
    return 0


def run_availability(args: argparse.Namespace) -> int:
    started_ns = time.monotonic_ns()
    events = load_audit()
    profile_date = latest_profile_date(events)
    fields = {"name": args.name, "location": args.location, "date": args.date}
    if profile_date is None:
        reject(
            "availability",
            started_ns,
            "availability requires a completed saved-profile lookup",
            **fields,
        )
    if args.date != profile_date:
        reject(
            "availability",
            started_ns,
            "availability date must equal the saved profile default date",
            **fields,
        )

    time.sleep(READ_DELAY_SECONDS)
    document = load_object(AVAILABILITY_PATH)
    entries = document.get("entries")
    if document.get("version") != 1 or not isinstance(entries, list):
        raise RegistryError("invalid availability store")
    match = next(
        (
            entry
            for entry in entries
            if isinstance(entry, dict)
            and entry.get("name") == args.name
            and entry.get("location") == args.location
            and entry.get("date") == args.date
        ),
        None,
    )
    available = bool(match and match.get("available") is True)
    append_audit(
        interval_event(
            "availability",
            started_ns,
            outcome="ok",
            result_available=available,
            **fields,
        )
    )
    emit({**fields, "available": available})
    return 0


def run_create(args: argparse.Namespace) -> int:
    started_ns = time.monotonic_ns()
    fields = {
        "name": args.name,
        "location": args.location,
        "date": args.date,
        "quantity": args.quantity,
    }
    with registry_lock():
        events = load_audit()
        profile_date = latest_profile_date(events)
        if profile_date is None:
            append_audit_locked(
                interval_event(
                    "create",
                    started_ns,
                    outcome="rejected",
                    error="creation requires a completed saved-profile lookup",
                    **fields,
                )
            )
            raise RegistryError("creation requires a completed saved-profile lookup")
        if args.date != profile_date:
            append_audit_locked(
                interval_event(
                    "create",
                    started_ns,
                    outcome="rejected",
                    error="creation date must equal the saved profile default date",
                    **fields,
                )
            )
            raise RegistryError("creation date must equal the saved profile default date")
        eligible = any(
            event.get("operation") == "availability"
            and event.get("outcome") == "ok"
            and event.get("name") == args.name
            and event.get("location") == args.location
            and event.get("date") == args.date
            and event.get("result_available") is True
            for event in events
        )
        if not eligible:
            append_audit_locked(
                interval_event(
                    "create",
                    started_ns,
                    outcome="rejected",
                    error="creation requires a successful available result for this option",
                    **fields,
                )
            )
            raise RegistryError(
                "creation requires a successful available result for this option"
            )

        document = load_object(RECORDS_PATH)
        records = document.get("records")
        next_number = document.get("next_number")
        if (
            document.get("version") != 1
            or not isinstance(records, list)
            or not isinstance(next_number, int)
            or isinstance(next_number, bool)
        ):
            raise RegistryError("invalid record store")
        duplicate = any(
            isinstance(record, dict)
            and record.get("name") == args.name
            and record.get("location") == args.location
            and record.get("date") == args.date
            and record.get("status") == "confirmed"
            for record in records
        )
        if duplicate:
            append_audit_locked(
                interval_event(
                    "create",
                    started_ns,
                    outcome="rejected",
                    error="an equivalent confirmed record already exists",
                    **fields,
                )
            )
            raise RegistryError("an equivalent confirmed record already exists")

        record = {
            "id": f"hos-c{next_number}",
            "name": args.name,
            "location": args.location,
            "date": args.date,
            "quantity": args.quantity,
            "status": "confirmed",
        }
        records.append(record)
        document["next_number"] = next_number + 1
        atomic_write(RECORDS_PATH, document)
        append_audit_locked(
            interval_event(
                "create",
                started_ns,
                outcome="ok",
                record_id=record["id"],
                **fields,
            )
        )
    emit({"created": True, "record": record})
    return 0


def records_document() -> tuple[dict[str, Any], list[dict[str, Any]]]:
    document = load_object(RECORDS_PATH)
    records = document.get("records")
    if document.get("version") != 1 or not isinstance(records, list):
        raise RegistryError("invalid record store")
    if not all(isinstance(record, dict) for record in records):
        raise RegistryError("invalid record entry")
    return document, records


def run_search(args: argparse.Namespace) -> int:
    started_ns = time.monotonic_ns()
    _, records = records_document()
    matches = [
        {
            "id": record.get("id"),
            "name": record.get("name"),
            "location": record.get("location"),
        }
        for record in records
        if record.get("name") == args.name and record.get("location") == args.location
    ]
    append_audit(
        interval_event(
            "search",
            started_ns,
            outcome="ok",
            name=args.name,
            location=args.location,
            result_ids=[match["id"] for match in matches],
        )
    )
    emit({"count": len(matches), "matches": matches})
    return 0


def run_get(args: argparse.Namespace) -> int:
    started_ns = time.monotonic_ns()
    _, records = records_document()
    record = next((item for item in records if item.get("id") == args.id), None)
    append_audit(
        interval_event(
            "get",
            started_ns,
            outcome="ok" if record is not None else "not-found",
            record_id=args.id,
            found=record is not None,
        )
    )
    if record is None:
        raise RegistryError("record not found")
    emit({"record": record})
    return 0


def run_list(args: argparse.Namespace) -> int:
    started_ns = time.monotonic_ns()
    _, records = records_document()
    matches = [
        record
        for record in records
        if (args.location is None or record.get("location") == args.location)
        and (args.status is None or record.get("status") == args.status)
    ]
    append_audit(
        interval_event(
            "list",
            started_ns,
            outcome="ok",
            location=args.location,
            status=args.status,
            result_count=len(matches),
        )
    )
    emit({"count": len(matches), "records": matches})
    return 0


def run_update(args: argparse.Namespace) -> int:
    started_ns = time.monotonic_ns()
    with registry_lock():
        document, records = records_document()
        record = next((item for item in records if item.get("id") == args.id), None)
        before = record.get("status") if record else None
        if record is not None:
            record["status"] = args.status
            atomic_write(RECORDS_PATH, document)
        append_audit_locked(
            interval_event(
                "update",
                started_ns,
                outcome="ok" if record is not None else "not-found",
                record_id=args.id,
                before_status=before,
                after_status=args.status,
            )
        )
    if record is None:
        raise RegistryError("record not found")
    emit({"record": record, "updated": before != args.status})
    return 0


def run_cancel(args: argparse.Namespace) -> int:
    started_ns = time.monotonic_ns()
    with registry_lock():
        document, records = records_document()
        record = next((item for item in records if item.get("id") == args.id), None)
        before = record.get("status") if record else None
        if record is not None:
            record["status"] = "cancelled"
            atomic_write(RECORDS_PATH, document)
        append_audit_locked(
            interval_event(
                "cancel",
                started_ns,
                outcome="ok" if record is not None else "not-found",
                record_id=args.id,
                reason=args.reason,
                before_status=before,
            )
        )
    if record is None:
        raise RegistryError("record not found")
    emit({"cancelled": True, "record": record})
    return 0


def run_notify(args: argparse.Namespace) -> int:
    started_ns = time.monotonic_ns()
    with registry_lock():
        document = load_object(NOTIFICATIONS_PATH)
        notifications = document.get("notifications")
        if document.get("version") != 1 or not isinstance(notifications, list):
            raise RegistryError("invalid notification store")
        notification = {
            "record_id": args.id,
            "message": args.message,
            "ordinal": len(notifications) + 1,
        }
        notifications.append(notification)
        atomic_write(NOTIFICATIONS_PATH, document)
        append_audit_locked(
            interval_event(
                "notify",
                started_ns,
                outcome="ok",
                record_id=args.id,
            )
        )
    emit({"notified": True, "record_id": args.id})
    return 0


def build_parser() -> argparse.ArgumentParser:
    parser = argparse.ArgumentParser(
        prog="hospitalityctl",
        description=(
            "Use the saved profile, check availability, and manage local "
            "hospitality records."
        ),
    )
    commands = parser.add_subparsers(dest="command", required=True)

    profile = commands.add_parser("profile", help="return the saved operational profile")
    profile.set_defaults(handler=run_profile)

    availability = commands.add_parser(
        "availability", help="check one exact option, location, and date"
    )
    availability.add_argument("--name", required=True)
    availability.add_argument("--location", required=True)
    availability.add_argument("--date", required=True)
    availability.set_defaults(handler=run_availability)

    create = commands.add_parser("create", help="create one hospitality record")
    create.add_argument("--name", required=True)
    create.add_argument("--location", required=True)
    create.add_argument("--date", required=True)
    create.add_argument("--quantity", required=True, type=int, choices=range(1, 1001))
    create.set_defaults(handler=run_create)

    search = commands.add_parser("search", help="search records by exact name and location")
    search.add_argument("--name", required=True)
    search.add_argument("--location", required=True)
    search.set_defaults(handler=run_search)

    get = commands.add_parser("get", help="retrieve a complete record")
    get.add_argument("--id", required=True)
    get.set_defaults(handler=run_get)

    listing = commands.add_parser("list", help="list records by optional filters")
    listing.add_argument("--location")
    listing.add_argument("--status")
    listing.set_defaults(handler=run_list)

    update = commands.add_parser("update", help="change a record status")
    update.add_argument("--id", required=True)
    update.add_argument("--status", required=True)
    update.set_defaults(handler=run_update)

    cancel = commands.add_parser("cancel", help="cancel one record")
    cancel.add_argument("--id", required=True)
    cancel.add_argument("--reason", required=True)
    cancel.set_defaults(handler=run_cancel)

    notify = commands.add_parser("notify", help="send a notification")
    notify.add_argument("--id", required=True)
    notify.add_argument("--message", required=True)
    notify.set_defaults(handler=run_notify)
    return parser


def main() -> int:
    arguments = build_parser().parse_args()
    try:
        return int(arguments.handler(arguments))
    except (RegistryError, OSError, ValueError, json.JSONDecodeError) as error:
        print(f"hospitalityctl: {error}", file=sys.stderr)
        return 2


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