#!/usr/bin/env python3
"""Local CRM registry executable for the Pi dependency-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 / ".crm"
RECORDS_PATH = STATE_DIR / "records.json"
AUDIT_PATH = STATE_DIR / "audit.jsonl"
NOTIFICATIONS_PATH = STATE_DIR / "notifications.json"
LOCK_PATH = STATE_DIR / "lock"
KEY_PATH = ROOT / ".protected" / "audit.key"
OPERATION_DELAY_SECONDS = 0.4


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


def load_json(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 CRM file: {path.name}")
    return value


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


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 record_digest(record: dict[str, Any]) -> str:
    return hashlib.sha256(canonical(record)).hexdigest()


def append_audit_locked(event: dict[str, Any]) -> None:
    AUDIT_PATH.touch(exist_ok=True)
    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:
    LOCK_PATH.touch(exist_ok=True)
    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 operation_event(operation: str, started: int, **details: Any) -> dict[str, Any]:
    return {
        "operation": operation,
        "started_ns": started,
        "finished_ns": time.monotonic_ns(),
        **details,
    }


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


def get_record(args: argparse.Namespace) -> int:
    started = time.monotonic_ns()
    time.sleep(OPERATION_DELAY_SECONDS)
    record = next(
        (record for record in load_records() if record.get("id") == args.id), None
    )
    if record is None:
        append_audit(
            operation_event(
                "get", started, record_id=args.id, outcome="not-found", found=False
            )
        )
        print(f"record not found: {args.id}", file=sys.stderr)
        return 3
    append_audit(
        operation_event(
            "get",
            started,
            record_id=args.id,
            outcome="ok",
            found=True,
            name=record.get("name"),
            status=record.get("status"),
            record_sha256=record_digest(record),
        )
    )
    emit({"record": record})
    return 0


def update_record(args: argparse.Namespace) -> int:
    started = time.monotonic_ns()
    time.sleep(OPERATION_DELAY_SECONDS)
    LOCK_PATH.touch(exist_ok=True)
    with LOCK_PATH.open("a+", encoding="utf-8") as lock_stream:
        fcntl.flock(lock_stream.fileno(), fcntl.LOCK_EX)
        document = load_json(RECORDS_PATH)
        records = document.get("records")
        if not isinstance(records, list):
            raise RuntimeError("invalid CRM record store")
        record = next(
            (record for record in records if record.get("id") == args.id), None
        )
        if record is None:
            before = None
            outcome = "not-found"
            updated = 0
        else:
            before = record.get("status")
            record["status"] = args.status
            atomic_json_write(RECORDS_PATH, document)
            outcome = "ok"
            updated = 1
        append_audit_locked(
            operation_event(
                "update",
                started,
                record_id=args.id,
                before_status=before,
                after_status=args.status,
                outcome=outcome,
                updated=updated,
            )
        )
        fcntl.flock(lock_stream.fileno(), fcntl.LOCK_UN)
    if record is None:
        print(f"record not found: {args.id}", file=sys.stderr)
        return 3
    emit({"before_status": before, "record": record, "updated": updated})
    return 0


def read_operation(args: argparse.Namespace) -> int:
    started = time.monotonic_ns()
    time.sleep(OPERATION_DELAY_SECONDS)
    records = load_records()
    if args.command == "search":
        matches = [
            record
            for record in records
            if record.get("name") == args.name
            and (args.location is None or record.get("location") == args.location)
        ]
        result: dict[str, Any] = {"count": len(matches), "matches": matches}
    elif args.command == "list":
        matches = [
            record
            for record in records
            if args.status is None or record.get("status") == args.status
        ]
        result = {"count": len(matches), "records": matches}
    elif args.command == "profile":
        record = next(
            (record for record in records if record.get("id") == args.id), None
        )
        result = {"profile": record}
    else:
        record = next(
            (record for record in records if record.get("id") == args.id), None
        )
        result = {
            "available": bool(record and record.get("status") in {"active", "prospect"}),
            "record_id": args.id,
        }
    append_audit(operation_event(args.command, started, outcome="ok"))
    emit(result)
    return 0


def create_record(args: argparse.Namespace) -> int:
    started = time.monotonic_ns()
    time.sleep(OPERATION_DELAY_SECONDS)
    LOCK_PATH.touch(exist_ok=True)
    with LOCK_PATH.open("a+", encoding="utf-8") as lock_stream:
        fcntl.flock(lock_stream.fileno(), fcntl.LOCK_EX)
        document = load_json(RECORDS_PATH)
        records = document.get("records")
        if not isinstance(records, list):
            raise RuntimeError("invalid CRM record store")
        record = {
            "id": f"crm-created-{time.monotonic_ns()}",
            "name": args.name,
            "location": args.location,
            "status": args.status,
        }
        records.append(record)
        atomic_json_write(RECORDS_PATH, document)
        append_audit_locked(
            operation_event(
                "create", started, outcome="ok", record_id=record["id"], created=1
            )
        )
        fcntl.flock(lock_stream.fileno(), fcntl.LOCK_UN)
    emit({"created": 1, "record": record})
    return 0


def cancel_record(args: argparse.Namespace) -> int:
    started = time.monotonic_ns()
    time.sleep(OPERATION_DELAY_SECONDS)
    LOCK_PATH.touch(exist_ok=True)
    with LOCK_PATH.open("a+", encoding="utf-8") as lock_stream:
        fcntl.flock(lock_stream.fileno(), fcntl.LOCK_EX)
        document = load_json(RECORDS_PATH)
        records = document.get("records")
        if not isinstance(records, list):
            raise RuntimeError("invalid CRM record store")
        record = next(
            (record for record in records if record.get("id") == args.id), None
        )
        if record is not None:
            record["status"] = "cancelled"
            atomic_json_write(RECORDS_PATH, document)
        append_audit_locked(
            operation_event(
                "cancel",
                started,
                outcome="ok" if record is not None else "not-found",
                record_id=args.id,
                cancelled=1 if record is not None else 0,
            )
        )
        fcntl.flock(lock_stream.fileno(), fcntl.LOCK_UN)
    emit({"cancelled": 1 if record is not None else 0, "record_id": args.id})
    return 0 if record is not None else 3


def notify(args: argparse.Namespace) -> int:
    started = time.monotonic_ns()
    time.sleep(OPERATION_DELAY_SECONDS)
    LOCK_PATH.touch(exist_ok=True)
    with LOCK_PATH.open("a+", encoding="utf-8") as lock_stream:
        fcntl.flock(lock_stream.fileno(), fcntl.LOCK_EX)
        document = load_json(NOTIFICATIONS_PATH)
        notifications = document.get("notifications")
        if not isinstance(notifications, list):
            raise RuntimeError("invalid CRM notification store")
        notifications.append(
            {
                "ordinal": len(notifications) + 1,
                "record_id": args.id,
                "message": args.message,
            }
        )
        atomic_json_write(NOTIFICATIONS_PATH, document)
        append_audit_locked(
            operation_event("notify", started, outcome="ok", record_id=args.id)
        )
        fcntl.flock(lock_stream.fileno(), fcntl.LOCK_UN)
    emit({"notified": 1, "record_id": args.id})
    return 0


def build_parser() -> argparse.ArgumentParser:
    parser = argparse.ArgumentParser(
        prog="crmctl",
        description="Retrieve and manage records in the local CRM registry.",
    )
    subparsers = parser.add_subparsers(dest="command", required=True)

    get_parser = subparsers.add_parser("get", help="retrieve one complete record by ID")
    get_parser.add_argument("--id", required=True)
    get_parser.set_defaults(handler=get_record)

    update_parser = subparsers.add_parser("update", help="change one record status")
    update_parser.add_argument("--id", required=True)
    update_parser.add_argument("--status", required=True)
    update_parser.set_defaults(handler=update_record)

    search_parser = subparsers.add_parser("search", help="search by exact name")
    search_parser.add_argument("--name", required=True)
    search_parser.add_argument("--location")
    search_parser.set_defaults(handler=read_operation)

    list_parser = subparsers.add_parser("list", help="list CRM records")
    list_parser.add_argument("--status")
    list_parser.set_defaults(handler=read_operation)

    profile_parser = subparsers.add_parser("profile", help="read a saved profile")
    profile_parser.add_argument("--id", required=True)
    profile_parser.set_defaults(handler=read_operation)

    availability_parser = subparsers.add_parser(
        "availability", help="check record availability"
    )
    availability_parser.add_argument("--id", required=True)
    availability_parser.set_defaults(handler=read_operation)

    create_parser = subparsers.add_parser("create", help="create a CRM record")
    create_parser.add_argument("--name", required=True)
    create_parser.add_argument("--location", required=True)
    create_parser.add_argument("--status", required=True)
    create_parser.set_defaults(handler=create_record)

    cancel_parser = subparsers.add_parser("cancel", help="cancel a CRM record")
    cancel_parser.add_argument("--id", required=True)
    cancel_parser.set_defaults(handler=cancel_record)

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


def main() -> int:
    try:
        args = build_parser().parse_args()
        handler: Callable[[argparse.Namespace], int] = args.handler
        return handler(args)
    except (OSError, RuntimeError, json.JSONDecodeError) as error:
        print(f"crmctl: {error}", file=sys.stderr)
        return 2


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