#!/usr/bin/env python3
"""CARBON Browser v1 contract, adapter negotiation, and durable operation ledger."""

from __future__ import annotations

import argparse
import hashlib
import json
import re
import sys
import uuid
from datetime import datetime, timezone
from pathlib import Path
from typing import Any


RUNTIME_ROOT = Path(__file__).resolve().parents[1]
CONTRACT_PATH = RUNTIME_ROOT / "references" / "browser" / "carbon-browser-v1.json"
PROTOCOL = "carbon-browser/v1"
STATUS_VALUES = {"succeeded", "failed", "blocked", "skipped", "cancelled"}
ID_PATTERN = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$")
SESSION_PATTERN = re.compile(r"^CBS-[A-Z0-9-]{8,80}$")
SECRET_PATTERNS = [
    re.compile(r"\bsk-[A-Za-z0-9_-]{16,}\b"),
    re.compile(r"\bghp_[A-Za-z0-9]{20,}\b"),
    re.compile(r"\bAKIA[0-9A-Z]{16}\b"),
    re.compile(r"\bBearer\s+[A-Za-z0-9._~+/-]{16,}\b", re.I),
    re.compile(r"-----BEGIN (?:RSA |EC |OPENSSH )?PRIVATE KEY-----"),
]
SENSITIVE_KEYS = {
    "authorization", "cookie", "cookies", "password", "passwd", "secret",
    "token", "accessToken", "refreshToken", "apiKey", "clientSecret",
}


def now() -> str:
    return datetime.now(timezone.utc).isoformat(timespec="seconds").replace("+00:00", "Z")


def load_json(path: Path) -> dict[str, Any]:
    try:
        value = json.loads(path.read_text(encoding="utf-8"))
    except FileNotFoundError as exc:
        raise ValueError(f"JSON file does not exist: {path}") from exc
    except json.JSONDecodeError as exc:
        raise ValueError(f"Invalid JSON in {path}: {exc}") from exc
    if not isinstance(value, dict):
        raise ValueError(f"Expected one JSON object in {path}")
    return value


def load_inline_json(value: str, label: str) -> dict[str, Any]:
    try:
        parsed = json.loads(value)
    except json.JSONDecodeError as exc:
        raise ValueError(f"{label} is not valid JSON: {exc}") from exc
    if not isinstance(parsed, dict):
        raise ValueError(f"{label} must be one JSON object")
    return parsed


def contract() -> dict[str, Any]:
    return load_json(CONTRACT_PATH)


def known_operations(spec: dict[str, Any]) -> set[str]:
    return {
        operation
        for group in spec.get("operations", {}).values()
        for operation in group
    }


def clean_strings(value: Any, label: str) -> list[str]:
    if not isinstance(value, list):
        raise ValueError(f"{label} must be an array")
    rows: list[str] = []
    for item in value:
        if not isinstance(item, str) or not item.strip():
            raise ValueError(f"{label} entries must be non-empty strings")
        rows.append(item.strip())
    if len(rows) != len(set(rows)):
        raise ValueError(f"{label} contains duplicate entries")
    return rows


def validate_adapter(manifest: dict[str, Any]) -> dict[str, Any]:
    spec = contract()
    errors: list[str] = []
    warnings: list[str] = []
    if manifest.get("protocol") != PROTOCOL:
        errors.append(f"protocol must be {PROTOCOL}")
    for field in ("id", "name", "version", "family", "transport"):
        value = manifest.get(field)
        if not isinstance(value, str) or not value.strip():
            errors.append(f"{field} is required")
    if isinstance(manifest.get("id"), str) and not ID_PATTERN.fullmatch(manifest["id"]):
        errors.append("id must use letters, numbers, dot, underscore, colon, or hyphen")
    if manifest.get("transport") not in spec.get("transports", []):
        errors.append("transport is not recognized by carbon-browser/v1")
    if manifest.get("family") not in spec.get("adapterFamilies", []):
        warnings.append("family is not a first-party adapter family; conformance is still capability-based")

    try:
        operations = clean_strings(manifest.get("operations"), "operations")
    except ValueError as exc:
        errors.append(str(exc))
        operations = []
    unknown_operations = sorted(set(operations) - known_operations(spec))
    if unknown_operations:
        errors.append(f"unknown operations: {', '.join(unknown_operations)}")
    missing_minimum = sorted(set(spec.get("minimumAdapterOperations", [])) - set(operations))
    if missing_minimum:
        warnings.append(f"partial adapter; minimum portable operations missing: {', '.join(missing_minimum)}")

    try:
        evidence = clean_strings(manifest.get("evidenceChannels"), "evidenceChannels")
    except ValueError as exc:
        errors.append(str(exc))
        evidence = []
    unknown_evidence = sorted(set(evidence) - set(spec.get("evidenceChannels", [])))
    if unknown_evidence:
        errors.append(f"unknown evidence channels: {', '.join(unknown_evidence)}")

    features = manifest.get("sessionFeatures", [])
    if not isinstance(features, list) or any(not isinstance(item, str) or not item.strip() for item in features):
        errors.append("sessionFeatures must be an array of non-empty strings")

    safety = manifest.get("safety")
    if not isinstance(safety, dict):
        errors.append("safety is required and must be an object")
    else:
        for field in ("actionClassification", "approvalGates", "redaction"):
            if safety.get(field) is not True:
                errors.append(f"safety.{field} must be true")

    return {
        "protocol": PROTOCOL,
        "valid": not errors,
        "adapter": {
            "id": manifest.get("id"),
            "name": manifest.get("name"),
            "version": manifest.get("version"),
            "family": manifest.get("family"),
            "transport": manifest.get("transport"),
        },
        "operations": operations,
        "evidenceChannels": evidence,
        "sessionFeatures": features if isinstance(features, list) else [],
        "errors": errors,
        "warnings": warnings,
    }


def negotiate(
    manifest: dict[str, Any],
    required_operations: list[str],
    required_evidence: list[str],
) -> dict[str, Any]:
    spec = contract()
    validation = validate_adapter(manifest)
    unknown_required_operations = sorted(set(required_operations) - known_operations(spec))
    unknown_required_evidence = sorted(set(required_evidence) - set(spec.get("evidenceChannels", [])))
    if unknown_required_operations:
        validation["errors"].append(
            f"unknown required operations: {', '.join(unknown_required_operations)}"
        )
    if unknown_required_evidence:
        validation["errors"].append(
            f"unknown required evidence channels: {', '.join(unknown_required_evidence)}"
        )
    validation["valid"] = not validation["errors"]
    operations = set(validation["operations"])
    evidence = set(validation["evidenceChannels"])
    missing_operations = sorted(set(required_operations) - operations)
    missing_evidence = sorted(set(required_evidence) - evidence)
    accepted = validation["valid"] and not missing_operations and not missing_evidence
    return {
        "protocol": PROTOCOL,
        "adapter": validation["adapter"],
        "accepted": accepted,
        "validation": validation,
        "required": {
            "operations": required_operations,
            "evidenceChannels": required_evidence,
        },
        "supported": {
            "operations": sorted(set(required_operations) & operations),
            "evidenceChannels": sorted(set(required_evidence) & evidence),
        },
        "missing": {
            "operations": missing_operations,
            "evidenceChannels": missing_evidence,
        },
        "degradation": "none" if accepted else "blocked-or-explicitly-replan",
        "message": (
            "Adapter satisfies the requested CARBON Browser contract."
            if accepted
            else "Adapter does not satisfy the requested contract. Keep gaps visible, select another adapter, or explicitly re-plan a smaller run."
        ),
    }


def safe_value(value: Any, key: str = "") -> Any:
    if key in SENSITIVE_KEYS or key.lower() in {item.lower() for item in SENSITIVE_KEYS}:
        return "[REDACTED]"
    if isinstance(value, dict):
        return {str(k): safe_value(v, str(k)) for k, v in value.items()}
    if isinstance(value, list):
        return [safe_value(item) for item in value]
    if isinstance(value, str):
        result = value
        for pattern in SECRET_PATTERNS:
            result = pattern.sub("[REDACTED]", result)
        return result[:20_000]
    return value


def validate_result(result: dict[str, Any], expected_session: str | None = None) -> dict[str, Any]:
    spec = contract()
    errors: list[str] = []
    warnings: list[str] = []
    for field in spec.get("requiredResultFields", []):
        if field not in result:
            errors.append(f"{field} is required")
    if result.get("protocol") != PROTOCOL:
        errors.append(f"protocol must be {PROTOCOL}")
    for field in ("requestId", "sessionId", "operation", "actual", "startedAt", "finishedAt"):
        if field in result and (not isinstance(result[field], str) or not result[field].strip()):
            errors.append(f"{field} must be a non-empty string")
    if expected_session and result.get("sessionId") != expected_session:
        errors.append("sessionId does not match the durable CARBON Browser session")
    if result.get("operation") not in known_operations(spec):
        errors.append("operation is not recognized by carbon-browser/v1")
    if result.get("status") not in STATUS_VALUES:
        errors.append(f"status must be one of: {', '.join(sorted(STATUS_VALUES))}")
    adapter = result.get("adapter")
    if not isinstance(adapter, dict) or not adapter.get("id") or not adapter.get("version"):
        errors.append("adapter must include id and version")
    for field in ("before", "after"):
        if field in result and not isinstance(result[field], dict):
            errors.append(f"{field} must be an object")
    evidence = result.get("evidence")
    if not isinstance(evidence, list):
        errors.append("evidence must be an array")
        evidence = []
    else:
        for index, item in enumerate(evidence):
            if not isinstance(item, dict) or item.get("kind") not in spec.get("evidenceChannels", []) or not item.get("handle"):
                errors.append(f"evidence[{index}] must include a known kind and non-empty handle")
    unavailable = result.get("unavailableEvidence")
    if not isinstance(unavailable, list) or any(item not in spec.get("evidenceChannels", []) for item in unavailable):
        errors.append("unavailableEvidence must contain only known evidence channels")
        unavailable = []
    overlap = sorted({item.get("kind") for item in evidence if isinstance(item, dict)} & set(unavailable))
    if overlap:
        errors.append(f"evidence cannot also be unavailable: {', '.join(overlap)}")
    if result.get("status") == "succeeded" and not evidence:
        warnings.append("successful operation has no evidence handles; CARBON confidence will remain limited")
    sanitized = safe_value(result)
    return {
        "protocol": PROTOCOL,
        "valid": not errors,
        "errors": errors,
        "warnings": warnings,
        "result": sanitized,
    }


def session_id() -> str:
    stamp = datetime.now(timezone.utc).strftime("%Y%m%d-%H%M%S")
    return f"CBS-{stamp}-{uuid.uuid4().hex[:8].upper()}"


def ensure_session_dir(raw: str) -> tuple[Path, dict[str, Any]]:
    directory = Path(raw).expanduser().resolve()
    state_path = directory / "session.json"
    if not directory.is_dir() or not state_path.is_file():
        raise ValueError("session-dir must be an existing CARBON Browser session")
    state = load_json(state_path)
    if state.get("protocol") != PROTOCOL or not SESSION_PATTERN.fullmatch(str(state.get("sessionId", ""))):
        raise ValueError("session-dir does not contain a valid carbon-browser/v1 session")
    return directory, state


def initialize_session(args: argparse.Namespace, manifest: dict[str, Any]) -> dict[str, Any]:
    root = Path(args.root).expanduser().resolve()
    if not root.is_dir():
        raise ValueError("root must be an existing project directory")
    negotiation = negotiate(manifest, args.require_operation, args.require_evidence)
    sid = session_id()
    directory = root / ".carbon" / "browser" / "sessions" / sid
    directory.mkdir(parents=True, exist_ok=False)
    state = {
        "protocol": PROTOCOL,
        "sessionId": sid,
        "status": "ready" if negotiation["accepted"] else "blocked",
        "createdAt": now(),
        "updatedAt": now(),
        "target": safe_value(args.target or ""),
        "adapter": safe_value(manifest),
        "negotiation": negotiation,
        "operationCounts": {status: 0 for status in sorted(STATUS_VALUES)},
        "evidenceChannels": {},
        "remainingCapabilityGaps": negotiation["missing"],
        "authority": "No destructive, paid, public, privileged, privacy-sensitive, or consequential action is authorized by this session record.",
    }
    (directory / "session.json").write_text(json.dumps(state, indent=2) + "\n", encoding="utf-8")
    (directory / "operations.jsonl").touch()
    return {
        "protocol": PROTOCOL,
        "sessionDir": str(directory),
        "session": state,
    }


def record_result(args: argparse.Namespace, result: dict[str, Any]) -> dict[str, Any]:
    directory, state = ensure_session_dir(args.session_dir)
    validation = validate_result(result, state["sessionId"])
    if not validation["valid"]:
        raise ValueError("Invalid CARBON Browser result: " + "; ".join(validation["errors"]))
    operation = validation["result"]
    with (directory / "operations.jsonl").open("a", encoding="utf-8") as ledger:
        ledger.write(json.dumps(operation, separators=(",", ":")) + "\n")
    status = operation["status"]
    state["operationCounts"][status] = state["operationCounts"].get(status, 0) + 1
    for evidence in operation.get("evidence", []):
        kind = evidence["kind"]
        state["evidenceChannels"][kind] = state["evidenceChannels"].get(kind, 0) + 1
    state["lastOperation"] = {
        "requestId": operation["requestId"],
        "operation": operation["operation"],
        "status": status,
        "finishedAt": operation["finishedAt"],
    }
    state["updatedAt"] = now()
    (directory / "session.json").write_text(json.dumps(state, indent=2) + "\n", encoding="utf-8")
    return {
        "protocol": PROTOCOL,
        "recorded": True,
        "sessionDir": str(directory),
        "warnings": validation["warnings"],
        "summary": summarize(directory, state),
    }


def summarize(directory: Path, state: dict[str, Any]) -> dict[str, Any]:
    ledger = directory / "operations.jsonl"
    operation_total = sum(1 for line in ledger.read_text(encoding="utf-8").splitlines() if line.strip())
    return {
        "protocol": PROTOCOL,
        "sessionId": state["sessionId"],
        "status": state["status"],
        "target": state.get("target"),
        "adapter": {
            "id": state.get("adapter", {}).get("id"),
            "version": state.get("adapter", {}).get("version"),
            "family": state.get("adapter", {}).get("family"),
        },
        "operations": operation_total,
        "operationCounts": state.get("operationCounts", {}),
        "evidenceChannels": state.get("evidenceChannels", {}),
        "remainingCapabilityGaps": state.get("remainingCapabilityGaps", {}),
        "lastOperation": state.get("lastOperation"),
        "updatedAt": state.get("updatedAt"),
        "sessionDir": str(directory),
        "ledgerSha256": hashlib.sha256(ledger.read_bytes()).hexdigest(),
    }


def manifest_from_args(args: argparse.Namespace) -> dict[str, Any]:
    if getattr(args, "manifest_json", None):
        return load_inline_json(args.manifest_json, "manifest-json")
    if getattr(args, "manifest", None):
        return load_json(Path(args.manifest).expanduser().resolve())
    raise ValueError("provide --manifest or --manifest-json")


def result_from_args(args: argparse.Namespace) -> dict[str, Any]:
    if getattr(args, "result_json", None):
        return load_inline_json(args.result_json, "result-json")
    if getattr(args, "result", None):
        return load_json(Path(args.result).expanduser().resolve())
    raise ValueError("provide --result or --result-json")


def parser() -> argparse.ArgumentParser:
    root = argparse.ArgumentParser(description="CARBON Browser v1 runtime")
    commands = root.add_subparsers(dest="command", required=True)
    commands.add_parser("contract", help="print the machine-readable carbon-browser/v1 contract")

    validate = commands.add_parser("validate-adapter", help="validate an adapter manifest")
    validate.add_argument("--manifest")
    validate.add_argument("--manifest-json")

    negotiate_parser = commands.add_parser("negotiate", help="negotiate run requirements with an adapter")
    negotiate_parser.add_argument("--manifest")
    negotiate_parser.add_argument("--manifest-json")
    negotiate_parser.add_argument("--require-operation", action="append", default=[])
    negotiate_parser.add_argument("--require-evidence", action="append", default=[])

    initialize = commands.add_parser("init", help="create a durable browser session after negotiation")
    initialize.add_argument("--root", required=True)
    initialize.add_argument("--manifest")
    initialize.add_argument("--manifest-json")
    initialize.add_argument("--target", default="")
    initialize.add_argument("--require-operation", action="append", default=[])
    initialize.add_argument("--require-evidence", action="append", default=[])

    validate_result_parser = commands.add_parser("validate-result", help="validate an operation result envelope")
    validate_result_parser.add_argument("--result")
    validate_result_parser.add_argument("--result-json")

    record = commands.add_parser("record", help="append a validated operation result to a session ledger")
    record.add_argument("--session-dir", required=True)
    record.add_argument("--result")
    record.add_argument("--result-json")

    summary = commands.add_parser("summary", help="summarize one durable browser session")
    summary.add_argument("--session-dir", required=True)
    return root


def main() -> int:
    args = parser().parse_args()
    try:
        if args.command == "contract":
            output = contract()
        elif args.command == "validate-adapter":
            output = validate_adapter(manifest_from_args(args))
        elif args.command == "negotiate":
            output = negotiate(
                manifest_from_args(args), args.require_operation, args.require_evidence
            )
        elif args.command == "init":
            output = initialize_session(args, manifest_from_args(args))
        elif args.command == "validate-result":
            output = validate_result(result_from_args(args))
        elif args.command == "record":
            output = record_result(args, result_from_args(args))
        elif args.command == "summary":
            directory, state = ensure_session_dir(args.session_dir)
            output = summarize(directory, state)
        else:
            raise ValueError(f"unknown command: {args.command}")
    except ValueError as exc:
        print(str(exc), file=sys.stderr)
        return 2
    print(json.dumps(output, indent=2))
    return 0


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