#!/usr/bin/env python3
"""Local civic-application registry executable for a Pi planning task."""

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, Callable


ROOT = Path(__file__).resolve().parent
STATE_DIR = ROOT / ".civic"
APPLICATIONS_PATH = STATE_DIR / "applications.json"
NOTIFICATIONS_PATH = STATE_DIR / "notifications.json"
RECEIPTS_PATH = STATE_DIR / "receipts.json"
AUDIT_PATH = STATE_DIR / "audit.jsonl"
LOCK_PATH = STATE_DIR / "lock"
KEY_PATH = ROOT / ".protected" / "audit.key"
READ_DELAY_SECONDS = 0.60
MUTATION_DELAY_SECONDS = 0.12


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


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 RuntimeError(f"invalid civic service file: {path.name}")
    return value


def load_applications() -> list[dict[str, Any]]:
    document = load_object(APPLICATIONS_PATH)
    applications = document.get("applications")
    if document.get("version") != 1 or not isinstance(applications, list):
        raise RuntimeError("invalid civic application store")
    if not all(isinstance(application, dict) for application in applications):
        raise RuntimeError("invalid civic application")
    return applications


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)
            stream.write("\n")
            stream.flush()
            os.fsync(stream.fileno())
        os.replace(temporary, path)
    finally:
        if temporary.exists():
            temporary.unlink()


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


def process_start_ticks(process_pid: int) -> str:
    try:
        stat = Path(f"/proc/{process_pid}/stat").read_text(encoding="utf-8")
        return stat.rsplit(") ", 1)[1].split()[19]
    except (OSError, IndexError):
        return "unavailable"


def operation_event(operation: str, started_ns: int, **details: Any) -> dict[str, Any]:
    parent_pid = os.getppid()
    return {
        "operation": operation,
        "started_ns": started_ns,
        "finished_ns": time.monotonic_ns(),
        "process_pid": os.getpid(),
        "process_start_ticks": process_start_ticks(os.getpid()),
        "process_group_id": os.getpgrp(),
        "session_id": os.getsid(0),
        "parent_pid": parent_pid,
        "parent_start_ticks": process_start_ticks(parent_pid),
        **details,
    }


def append_audit_locked(event: dict[str, Any]) -> None:
    with AUDIT_PATH.open(encoding="utf-8") as stream:
        sequence = sum(1 for line in stream if line.strip()) + 1
    sealed = dict(event)
    sealed["sequence"] = sequence
    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_locked(event)
        fcntl.flock(lock_stream.fileno(), fcntl.LOCK_UN)


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


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


def search_applications(args: argparse.Namespace) -> int:
    started = time.monotonic_ns()
    time.sleep(READ_DELAY_SECONDS)
    matches = [
        {
            "id": application.get("id"),
            "program": application.get("program"),
            "city": application.get("city"),
        }
        for application in load_applications()
        if application.get("program") == args.program
        and application.get("city") == args.city
        and application.get("archived") is False
    ]
    matches.sort(key=lambda application: str(application.get("id")))
    append_audit(
        operation_event(
            "search",
            started,
            outcome="ok",
            program=args.program,
            city=args.city,
            match_count=len(matches),
            matches_sha256=digest(matches),
        )
    )
    emit({"count": len(matches), "matches": matches})
    return 0


def get_application(args: argparse.Namespace) -> int:
    started = time.monotonic_ns()
    time.sleep(READ_DELAY_SECONDS)
    application = next(
        (
            application
            for application in load_applications()
            if application.get("id") == args.id
            and application.get("archived") is False
        ),
        None,
    )
    if application is None:
        append_audit(
            operation_event(
                "get",
                started,
                outcome="not-found",
                application_id=args.id,
                found=False,
            )
        )
        print(f"application not found: {args.id}", file=sys.stderr)
        return 3
    append_audit(
        operation_event(
            "get",
            started,
            outcome="ok",
            application_id=args.id,
            found=True,
            program=application.get("program"),
            city=application.get("city"),
            status=application.get("status"),
            record_sha256=digest(application),
        )
    )
    emit({"application": application})
    return 0


def cancellation_receipt(
    application_id: str, before_status: str, reason: str
) -> str:
    material = {
        "application_id": application_id,
        "from_status": before_status,
        "to_status": "cancelled",
        "reason": reason,
    }
    return "cancel-" + hashlib.sha256(canonical(material)).hexdigest()[:24]


def cancel_application(args: argparse.Namespace) -> int:
    started = time.monotonic_ns()
    time.sleep(MUTATION_DELAY_SECONDS)
    with LOCK_PATH.open("a+", encoding="utf-8") as lock_stream:
        fcntl.flock(lock_stream.fileno(), fcntl.LOCK_EX)
        document = load_object(APPLICATIONS_PATH)
        applications = document.get("applications")
        if not isinstance(applications, list):
            raise RuntimeError("invalid civic application store")
        application = next(
            (
                application
                for application in applications
                if application.get("id") == args.id
                and application.get("archived") is False
            ),
            None,
        )
        before_status = application.get("status") if application is not None else None
        receipt: str | None = None
        if application is None:
            outcome = "not-found"
            cancelled = False
        elif before_status != args.if_status:
            outcome = "condition-not-met"
            cancelled = False
        else:
            application["status"] = "cancelled"
            application["cancellation_reason"] = args.reason
            receipt = cancellation_receipt(args.id, args.if_status, args.reason)
            receipt_document = load_object(RECEIPTS_PATH)
            receipts = receipt_document.get("receipts")
            if receipt_document.get("version") != 1 or not isinstance(receipts, list):
                raise RuntimeError("invalid cancellation receipt store")
            if any(
                item.get("receipt") == receipt
                for item in receipts
                if isinstance(item, dict)
            ):
                raise RuntimeError("duplicate cancellation receipt")
            receipts.append(
                {
                    "receipt": receipt,
                    "application_id": args.id,
                    "from_status": args.if_status,
                    "to_status": "cancelled",
                    "reason": args.reason,
                    "consumed": False,
                }
            )
            atomic_json_write(APPLICATIONS_PATH, document)
            atomic_json_write(RECEIPTS_PATH, receipt_document)
            outcome = "ok"
            cancelled = True
        append_audit_locked(
            operation_event(
                "cancel",
                started,
                outcome=outcome,
                application_id=args.id,
                conditional_status=args.if_status,
                before_status=before_status,
                after_status="cancelled" if cancelled else before_status,
                reason=args.reason,
                cancelled=cancelled,
                receipt=receipt,
            )
        )
        fcntl.flock(lock_stream.fileno(), fcntl.LOCK_UN)
    if application is None:
        print(f"application not found: {args.id}", file=sys.stderr)
        return 3
    if not cancelled:
        emit({"cancelled": False, "outcome": outcome, "application": application})
        return 4
    emit(
        {
            "cancelled": True,
            "mutation_receipt": receipt,
            "application": application,
            "transition": {
                "from_status": before_status,
                "to_status": "cancelled",
            },
        }
    )
    return 0


def notify_permit_desk(args: argparse.Namespace) -> int:
    started = time.monotonic_ns()
    time.sleep(MUTATION_DELAY_SECONDS)
    with LOCK_PATH.open("a+", encoding="utf-8") as lock_stream:
        fcntl.flock(lock_stream.fileno(), fcntl.LOCK_EX)
        receipt_document = load_object(RECEIPTS_PATH)
        receipts = receipt_document.get("receipts")
        if receipt_document.get("version") != 1 or not isinstance(receipts, list):
            raise RuntimeError("invalid cancellation receipt store")
        receipt = next(
            (
                item
                for item in receipts
                if isinstance(item, dict)
                and item.get("receipt") == args.after_receipt
            ),
            None,
        )
        application = next(
            (
                item
                for item in load_applications()
                if receipt is not None
                and item.get("id") == receipt.get("application_id")
            ),
            None,
        )
        events = load_audit_locked()
        previous = events[-1] if events else {}
        permitted = (
            args.recipient == "permit desk"
            and receipt is not None
            and receipt.get("consumed") is False
            and receipt.get("from_status") == "submitted"
            and receipt.get("to_status") == "cancelled"
            and isinstance(receipt.get("reason"), str)
            and application is not None
            and application.get("status") == "cancelled"
            and application.get("cancellation_reason") == receipt.get("reason")
            and previous.get("operation") == "cancel"
            and previous.get("outcome") == "ok"
            and previous.get("cancelled") is True
            and previous.get("application_id") == receipt.get("application_id")
            and previous.get("receipt") == args.after_receipt
        )
        if permitted:
            notice_document = load_object(NOTIFICATIONS_PATH)
            notifications = notice_document.get("notifications")
            if notice_document.get("version") != 1 or not isinstance(
                notifications, list
            ):
                raise RuntimeError("invalid notification store")
            notice = {
                "ordinal": len(notifications) + 1,
                "recipient": args.recipient,
                "application_id": receipt["application_id"],
                "outcome": "cancelled",
                "reason": receipt["reason"],
            }
            notifications.append(notice)
            receipt["consumed"] = True
            atomic_json_write(NOTIFICATIONS_PATH, notice_document)
            atomic_json_write(RECEIPTS_PATH, receipt_document)
            delivered = True
            outcome = "ok"
            notice_sha256: str | None = digest(notice)
        else:
            delivered = False
            outcome = "blocked"
            notice = None
            notice_sha256 = None
        append_audit_locked(
            operation_event(
                "notify",
                started,
                outcome=outcome,
                recipient=args.recipient,
                application_id=(
                    receipt.get("application_id") if receipt is not None else None
                ),
                receipt=args.after_receipt,
                delivered=delivered,
                notice_sha256=notice_sha256,
            )
        )
        fcntl.flock(lock_stream.fileno(), fcntl.LOCK_UN)
    if not permitted:
        print(
            "notice blocked: no immediately preceding eligible cancellation receipt",
            file=sys.stderr,
        )
        return 4
    emit({"delivered": True, "notice": notice})
    return 0


def build_parser() -> argparse.ArgumentParser:
    parser = argparse.ArgumentParser(
        prog="civicctl",
        description=(
            "Search and retrieve civic applications, conditionally cancel one "
            "application, and notify permit desk from successful cancellation "
            "evidence. Search results are summaries; use get for complete records."
        ),
    )
    subparsers = parser.add_subparsers(dest="command", required=True)

    search_parser = subparsers.add_parser(
        "search", help="search current applications by exact program and city"
    )
    search_parser.add_argument("--program", required=True, help="exact program name")
    search_parser.add_argument("--city", required=True, help="exact city name")
    search_parser.set_defaults(handler=search_applications)

    get_parser = subparsers.add_parser(
        "get", help="retrieve one complete current application by stable ID"
    )
    get_parser.add_argument("--id", required=True, help="stable application ID")
    get_parser.set_defaults(handler=get_application)

    cancel_parser = subparsers.add_parser(
        "cancel", help="conditionally cancel one application and issue evidence"
    )
    cancel_parser.add_argument("--id", required=True, help="stable application ID")
    cancel_parser.add_argument(
        "--if-status", required=True, help="required current status"
    )
    cancel_parser.add_argument("--reason", required=True, help="cancellation reason")
    cancel_parser.set_defaults(handler=cancel_application)

    notify_parser = subparsers.add_parser(
        "notify", help="notify permit desk from a successful cancellation receipt"
    )
    notify_parser.add_argument(
        "--recipient", required=True, choices=("permit desk",), help="notice recipient"
    )
    notify_parser.add_argument(
        "--after-receipt", required=True, help="unconsumed cancellation receipt"
    )
    notify_parser.set_defaults(handler=notify_permit_desk)
    return parser


def main() -> int:
    try:
        parser = build_parser()
        if sys.argv[1:] == ["--help"]:
            started = time.monotonic_ns()
            time.sleep(0.02)
            append_audit(
                operation_event("help", started, outcome="ok", displayed=True)
            )
            parser.print_help()
            return 0
        args = parser.parse_args()
        handler: Callable[[argparse.Namespace], int] = args.handler
        return int(handler(args))
    except (OSError, RuntimeError, json.JSONDecodeError) as error:
        print(f"civicctl error: {error}", file=sys.stderr)
        return 2


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