#!/usr/bin/env python3
"""Executable client for the sandboxed meeting register."""

from __future__ import annotations

import argparse
import hashlib
import json
from pathlib import Path
import shutil
import sys
from typing import Any


ROOT = Path(__file__).resolve().parent
CANONICAL_DATA = ROOT / "data" / "meetings.jsonl"
RUNTIME_DIR = ROOT / ".meeting-runtime"
RUNTIME_DATA = RUNTIME_DIR / "meetings.jsonl"
AUDIT_LOG = ROOT / "audit.log"
AUDITED_OPERATIONS = {
    "search",
    "get",
    "list",
    "availability",
    "update",
    "cancel",
    "notify",
}


def emit(value: object) -> None:
    print(json.dumps(value, sort_keys=True, separators=(",", ":")))


def active_data(*, write: bool = False) -> Path:
    if write:
        RUNTIME_DIR.mkdir(exist_ok=True)
        if not RUNTIME_DATA.exists():
            shutil.copy2(CANONICAL_DATA, RUNTIME_DATA)
        return RUNTIME_DATA
    return RUNTIME_DATA if RUNTIME_DATA.exists() else CANONICAL_DATA


def load_records(*, write: bool = False) -> list[dict[str, str]]:
    records: list[dict[str, str]] = []
    for number, line in enumerate(
        active_data(write=write).read_text(encoding="utf-8").splitlines(), 1
    ):
        if not line:
            continue
        value = json.loads(line)
        if not isinstance(value, dict):
            raise ValueError(f"record {number} is not a JSON object")
        records.append({str(key): str(item) for key, item in value.items()})
    return records


def save_records(records: list[dict[str, str]]) -> None:
    destination = active_data(write=True)
    temporary = destination.with_suffix(".tmp")
    temporary.write_text(
        "".join(
            json.dumps(record, sort_keys=True, separators=(",", ":")) + "\n"
            for record in records
        ),
        encoding="utf-8",
    )
    temporary.replace(destination)


def digest(record: dict[str, str] | None) -> str | None:
    if record is None:
        return None
    return hashlib.sha256(
        json.dumps(record, sort_keys=True, separators=(",", ":")).encode()
    ).hexdigest()


def record_event(event: dict[str, Any]) -> None:
    if event.get("operation") not in AUDITED_OPERATIONS:
        return
    with AUDIT_LOG.open("a", encoding="utf-8") as stream:
        stream.write(json.dumps(event, sort_keys=True, separators=(",", ":")) + "\n")


def execute(args: argparse.Namespace) -> tuple[object, dict[str, object]]:
    records = load_records(write=args.operation in {"update", "cancel"})

    if args.operation == "search":
        matches = [
            {
                "id": record["id"],
                "name": record["name"],
                "location": record["location"],
                "status": record["status"],
            }
            for record in records
            if record["name"] == args.name and record["location"] == args.location
        ]
        matches.sort(key=lambda record: record["id"])
        return (
            {"match_count": len(matches), "matches": matches},
            {
                "name": args.name,
                "location": args.location,
                "match_count": len(matches),
                "stable_ids": [record["id"] for record in matches],
            },
        )

    if args.operation == "get":
        record = next(
            (record for record in records if record["id"] == args.stable_id), None
        )
        return (
            {"record": record},
            {
                "stable_id": args.stable_id,
                "found": record is not None,
                "record_digest": digest(record),
            },
        )

    if args.operation == "list":
        snippets = [
            {
                "id": record["id"],
                "name": record["name"],
                "location": record["location"],
            }
            for record in sorted(records, key=lambda record: record["id"])
        ]
        return ({"meetings": snippets}, {"row_count": len(snippets)})

    if args.operation == "availability":
        collisions = sum(
            record["location"] == args.location and record["date"] == args.date
            for record in records
        )
        return (
            {"available": collisions == 0},
            {"location": args.location, "date": args.date, "collisions": collisions},
        )

    if args.operation == "update":
        changed = 0
        for record in records:
            if record["id"] == args.stable_id:
                record["status"] = args.status
                changed += 1
        save_records(records)
        return (
            {"updated": changed},
            {"stable_id": args.stable_id, "changed": changed},
        )

    if args.operation == "cancel":
        changed = 0
        for record in records:
            if record["id"] == args.stable_id:
                record["status"] = "cancelled"
                changed += 1
        save_records(records)
        return (
            {"cancelled": changed},
            {"stable_id": args.stable_id, "changed": changed},
        )

    if args.operation == "notify":
        notifications = RUNTIME_DIR / "notifications.jsonl"
        RUNTIME_DIR.mkdir(exist_ok=True)
        with notifications.open("a", encoding="utf-8") as stream:
            stream.write(
                json.dumps(
                    {"stable_id": args.stable_id, "message": args.message},
                    sort_keys=True,
                    separators=(",", ":"),
                )
                + "\n"
            )
        return (
            {"recorded": True},
            {"stable_id": args.stable_id, "message_digest": hashlib.sha256(args.message.encode()).hexdigest()},
        )

    raise AssertionError(f"unhandled operation: {args.operation}")


def build_parser() -> argparse.ArgumentParser:
    parser = argparse.ArgumentParser(
        prog="meetingctl",
        description="Query or manage the sandboxed meeting register.",
    )
    commands = parser.add_subparsers(dest="operation", required=True)

    search = commands.add_parser(
        "search", help="search an exact, case-sensitive meeting name and location"
    )
    search.add_argument("--name", required=True)
    search.add_argument("--location", required=True)

    get = commands.add_parser("get", help="retrieve one full record by stable ID")
    get.add_argument("--id", dest="stable_id", required=True)

    commands.add_parser("list", help="list all meeting snippets")

    availability = commands.add_parser(
        "availability", help="check whether a location has a meeting on a date"
    )
    availability.add_argument("--location", required=True)
    availability.add_argument("--date", required=True)

    update = commands.add_parser("update", help="update a meeting status")
    update.add_argument("--id", dest="stable_id", required=True)
    update.add_argument(
        "--status",
        choices=("active", "pending", "closed", "cancelled"),
        required=True,
    )

    cancel = commands.add_parser("cancel", help="cancel a meeting")
    cancel.add_argument("--id", dest="stable_id", required=True)

    notify = commands.add_parser("notify", help="record a meeting notification")
    notify.add_argument("--id", dest="stable_id", required=True)
    notify.add_argument("--message", required=True)
    return parser


def main() -> int:
    args = build_parser().parse_args()
    try:
        result, evidence = execute(args)
    except (OSError, ValueError, json.JSONDecodeError) as error:
        record_event(
            {
                "operation": args.operation,
                "ok": False,
                "error_type": type(error).__name__,
            }
        )
        print(f"meetingctl: {error}", file=sys.stderr)
        return 1
    record_event(
        {
            "operation": args.operation,
            "ok": True,
            "evidence": evidence,
        }
    )
    emit(result)
    return 0


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