"""Record lead activity and project it into final-report data."""
from __future__ import annotations

import argparse
import json
import re
import sys
from collections.abc import Mapping, Sequence
from datetime import datetime, timezone
from pathlib import Path
from typing import Any

from okstra_ctl.lead_events import (
    LeadEvent,
    append_activity_event,
    read_lead_events,
)
from okstra_ctl.worker_audit_ledger import (
    EvidenceCommand,
    parse_evidence_commands,
    read_evidence_commands,
)
from okstra_ctl.json_boundary import (
    JsonBoundaryError,
    load_owned_object,
    mutate_owned_object_atomic,
)


ACTIVITY_KINDS = frozenset({
    "worker-dispatched",
    "worker-completed",
    "verification-round-completed",
    "self-fix-applied",
    "user-decision-required",
    "user-decision-evaluated",
})
ACTIVITY_OUTCOMES = frozenset({
    "pending",
    "completed",
    "failed",
    "blocked",
    "resolved",
})
ACTIVITY_FIELDS = (
    "activityId",
    "kind",
    "agent",
    "summary",
    "planItemIds",
    "resultPath",
    "commands",
    "evidenceRefs",
    "outcome",
)
_ACTIVITY_ID_RE = re.compile(r"^A-(\d{3,})$")


class ActivityProjectionError(ValueError):
    """Raised when activity cannot be recorded or projected safely."""


def _read_json_object(path: Path) -> dict[str, Any]:
    try:
        return load_owned_object(path, artifact="agent activity artifact")
    except JsonBoundaryError as exc:
        raise ActivityProjectionError(str(exc)) from exc


def _resolve_project_path(project_root: Path, value: str | Path) -> Path:
    path = Path(value)
    return path if path.is_absolute() else project_root / path


def _require_string(payload: Mapping[str, Any], key: str) -> str:
    value = payload.get(key)
    if not isinstance(value, str) or not value.strip():
        raise ActivityProjectionError(f"required string missing: {key}")
    return value.strip()


def _manifest_run_seq(manifest: Mapping[str, Any]) -> str:
    sequences = manifest.get("runSequencesByCategory")
    if not isinstance(sequences, Mapping):
        raise ActivityProjectionError(
            "run manifest has no runSequencesByCategory object"
        )
    return _require_string(sequences, "manifests")


def _validate_activity_ownership(
    project_root: Path,
    manifest: Mapping[str, Any],
    details: Mapping[str, Any],
) -> None:
    executions = manifest.get("roleExecutions")
    assignments = manifest.get("workerAssignments")
    if isinstance(assignments, list):
        assigned = {
            row["workerId"] for row in assignments
            if isinstance(row, Mapping) and isinstance(row.get("workerId"), str)
        }
    elif isinstance(executions, list):
        assigned = {
            f"{row['provider']}-worker"
            for row in executions
            if isinstance(row, Mapping) and isinstance(row.get("provider"), str)
        }
    else:
        assigned = set()
    if isinstance(assignments, list) or isinstance(executions, list):
        assigned.update({"okstra-lead", f"{manifest.get('leadRuntime', '')}-lead"})
        agent = _require_string(details, "agent")
        if agent not in assigned:
            raise ActivityProjectionError(
                f"agent `{agent}` is not assigned to this run"
            )
    state_value = manifest.get("planBodyVerificationPath")
    if not isinstance(state_value, str) or not details.get("planItemIds"):
        return
    state = _read_json_object(_resolve_project_path(project_root, state_value))
    verification = state.get("planBodyVerification")
    items = verification.get("planItems") if isinstance(verification, Mapping) else []
    known = {
        row.get("id") for row in items
        if isinstance(row, Mapping) and isinstance(row.get("id"), str)
    }
    unknown = sorted(set(details["planItemIds"]) - known)
    if unknown:
        raise ActivityProjectionError(
            "plan item(s) not in the current plan state: " + ", ".join(unknown)
        )


def _utc_now() -> str:
    return datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")


def _activity_row(event: LeadEvent) -> dict[str, Any]:
    details = dict(event.details)
    missing = [key for key in ACTIVITY_FIELDS if key not in details]
    if missing:
        raise ActivityProjectionError(
            "activity event is missing " + ", ".join(missing)
        )
    if details["kind"] not in ACTIVITY_KINDS:
        raise ActivityProjectionError(
            f"unsupported activity kind: {details['kind']}"
        )
    if details["outcome"] not in ACTIVITY_OUTCOMES:
        raise ActivityProjectionError(
            f"unsupported activity outcome: {details['outcome']}"
        )
    activity_id = details["activityId"]
    if (
        not isinstance(activity_id, str)
        or _ACTIVITY_ID_RE.fullmatch(activity_id) is None
    ):
        raise ActivityProjectionError(f"invalid activityId: {activity_id}")
    from okstra_ctl.execution_identity import stored_identity

    row = {key: details[key] for key in ACTIVITY_FIELDS}
    if "clarificationRefs" in details:
        refs = details["clarificationRefs"]
        if not isinstance(refs, list) or any(
            not isinstance(ref, str) or re.fullmatch(r"C-\d{3,}", ref) is None
            for ref in refs
        ):
            raise ActivityProjectionError("invalid clarificationRefs")
        row["clarificationRefs"] = list(refs)
    row.update(stored_identity(details))
    return row


def _validate_activity_order(rows: Sequence[Mapping[str, Any]]) -> None:
    ids = [str(row["activityId"]) for row in rows]
    if len(ids) != len(set(ids)):
        raise ActivityProjectionError("activityId values must be unique")
    numbers = [int(_ACTIVITY_ID_RE.fullmatch(value).group(1)) for value in ids]
    if any(current <= previous for previous, current in zip(numbers, numbers[1:])):
        raise ActivityProjectionError(
            "activity events must be in strict activityId order"
        )


def record_activity(
    project_root: Path,
    run_manifest_path: Path,
    details: Mapping[str, Any],
    timestamp: str | None = None,
) -> LeadEvent:
    """Append one activity event using identity from its run manifest."""
    manifest = _read_json_object(run_manifest_path)
    if manifest.get("activityContractVersion") != 1:
        raise ActivityProjectionError(
            "run manifest does not declare activityContractVersion 1"
        )
    _validate_activity_ownership(project_root, manifest, details)
    event = LeadEvent(
        event_type="activity",
        lead_runtime=_require_string(manifest, "leadRuntime"),
        task_key=_require_string(manifest, "taskKey"),
        task_type=_require_string(manifest, "taskType"),
        run_seq=_manifest_run_seq(manifest),
        timestamp=timestamp or _utc_now(),
        details=dict(details),
    )
    events_path = _resolve_project_path(
        project_root, _require_string(manifest, "leadEventsPath")
    )
    try:
        return append_activity_event(events_path, event)
    except ValueError as exc:
        raise ActivityProjectionError(str(exc)) from exc


def agent_activity_rows(
    project_root: Path,
    run_manifest_path: Path,
) -> tuple[dict[str, Any], ...]:
    """현재 실행의 정본 활동을 파일 변경 없이 반환한다."""
    manifest = _read_json_object(run_manifest_path)
    if manifest.get("activityContractVersion") != 1:
        return ()
    events_path = _resolve_project_path(
        project_root, _require_string(manifest, "leadEventsPath")
    )
    run_seq = _manifest_run_seq(manifest)
    task_key = _require_string(manifest, "taskKey")
    task_type = _require_string(manifest, "taskType")
    events = (
        event
        for event in read_lead_events(events_path)
        if event.event_type == "activity"
        and event.task_key == task_key
        and event.task_type == task_type
        and event.run_seq == run_seq
    )
    rows = tuple(_activity_row(event) for event in events)
    _validate_activity_order(rows)
    return rows


def project_agent_activity(
    project_root: Path,
    run_manifest_path: Path,
    data_path: Path,
) -> tuple[dict[str, Any], ...]:
    """2.0 호환 경로에서 ``agentActivity``만 교체한다."""
    rows = agent_activity_rows(project_root, run_manifest_path)
    manifest = _read_json_object(run_manifest_path)
    if manifest.get("activityContractVersion") != 1:
        return ()
    try:
        mutate_owned_object_atomic(
            data_path,
            lambda data: {**data, "agentActivity": list(rows)},
            artifact="final report record",
        )
    except JsonBoundaryError as exc:
        raise ActivityProjectionError(str(exc)) from exc
    return rows


def _parse_command_records(raw_records: Sequence[str]) -> tuple[EvidenceCommand, ...]:
    content = "\n".join(
        f"- Evidence command: {record}" for record in raw_records
    )
    commands, failures = parse_evidence_commands(content)
    if failures:
        raise ActivityProjectionError("; ".join(failures))
    return commands


def _activity_commands(args: argparse.Namespace) -> tuple[EvidenceCommand, ...]:
    commands = list(_parse_command_records(args.command_record))
    typed = _typed_command(args)
    if typed is not None:
        commands.append(typed)
    if args.audit_sidecar is not None:
        audit_commands, failures = read_evidence_commands(args.audit_sidecar)
        if failures:
            raise ActivityProjectionError("; ".join(failures))
        commands.extend(audit_commands)
    return tuple(commands)


def _typed_command(args: argparse.Namespace) -> EvidenceCommand | None:
    values = (
        args.evidence_command,
        args.command_cwd,
        args.command_exit_code,
        args.command_output_file,
    )
    if not any(value is not None for value in values):
        return None
    if any(value is None for value in values):
        raise ActivityProjectionError(
            "--command requires --command-cwd, --command-exit-code, and "
            "--command-output-file"
        )
    try:
        output_summary = args.command_output_file.read_text(encoding="utf-8")
    except (OSError, UnicodeError) as exc:
        raise ActivityProjectionError(
            f"cannot read command output {args.command_output_file}: {exc}"
        ) from exc
    return EvidenceCommand(
        command=args.evidence_command,
        cwd=args.command_cwd,
        exit_code=args.command_exit_code,
        output_summary=output_summary,
    )


def _activity_summary(args: argparse.Namespace) -> str:
    if args.summary is not None:
        return args.summary
    try:
        return args.summary_file.read_text(encoding="utf-8").strip()
    except (OSError, UnicodeError) as exc:
        raise ActivityProjectionError(
            f"cannot read activity summary {args.summary_file}: {exc}"
        ) from exc


def _conversation_activity_line(details: Mapping[str, Any]) -> str:
    plan_items = ",".join(details["planItemIds"]) or "<none>"
    result_path = str(details["resultPath"] or "<none>")
    summary = json.dumps(details["summary"], ensure_ascii=False)
    return (
        f"ACTIVITY: id={details['activityId']} agent={details['agent']} "
        f"summary={summary} items={plan_items} result={result_path} "
        f"outcome={details['outcome']}"
    )


def _append(args: argparse.Namespace) -> int:
    commands = _activity_commands(args)
    details = {
        "kind": args.kind,
        "agent": args.agent,
        "summary": _activity_summary(args),
        "planItemIds": args.plan_item_id,
        "resultPath": args.result_path,
        "commands": [command.to_record() for command in commands],
        "evidenceRefs": args.evidence_ref,
        "outcome": args.outcome,
    }
    clarification_refs = [
        ref for ref in args.evidence_ref
        if isinstance(ref, str) and re.fullmatch(r"C-\d{3,}", ref)
    ]
    if clarification_refs:
        details["clarificationRefs"] = clarification_refs
    if args.request_ref is not None:
        details["activityRequestRef"] = args.request_ref
    event = record_activity(args.project_root, args.run_manifest, details)
    payload = dict(event.details)
    payload["ok"] = True
    payload["activityLine"] = _conversation_activity_line(payload)
    print(json.dumps(payload, ensure_ascii=False, indent=2))
    return 0


def _project(args: argparse.Namespace) -> int:
    rows = project_agent_activity(args.project_root, args.run_manifest, args.data)
    print(json.dumps(
        {"ok": True, "count": len(rows), "agentActivity": rows},
        ensure_ascii=False,
        indent=2,
    ))
    return 0


def _parser() -> argparse.ArgumentParser:
    parser = argparse.ArgumentParser(prog="okstra agent-activity")
    subparsers = parser.add_subparsers(dest="command", required=True)
    append_parser = subparsers.add_parser("append")
    append_parser.add_argument("--project-root", type=Path, required=True)
    append_parser.add_argument("--run-manifest", type=Path, required=True)
    append_parser.add_argument(
        "--kind", choices=sorted(ACTIVITY_KINDS), required=True
    )
    append_parser.add_argument("--agent", required=True)
    summary = append_parser.add_mutually_exclusive_group(required=True)
    summary.add_argument("--summary")
    summary.add_argument("--summary-file", type=Path)
    append_parser.add_argument(
        "--outcome", choices=sorted(ACTIVITY_OUTCOMES), required=True
    )
    append_parser.add_argument("--plan-item-id", action="append", default=[])
    append_parser.add_argument("--evidence-ref", action="append", default=[])
    append_parser.add_argument("--command-record", action="append", default=[])
    append_parser.add_argument("--command", dest="evidence_command")
    append_parser.add_argument("--command-cwd")
    append_parser.add_argument("--command-exit-code", type=int)
    append_parser.add_argument("--command-output-file", type=Path)
    append_parser.add_argument("--result-path", default="")
    append_parser.add_argument("--audit-sidecar", type=Path)
    append_parser.add_argument("--request-ref")
    project_parser = subparsers.add_parser("project")
    project_parser.add_argument("--project-root", type=Path, required=True)
    project_parser.add_argument("--run-manifest", type=Path, required=True)
    project_parser.add_argument("--data", type=Path, required=True)
    return parser


def main(argv: list[str] | None = None) -> int:
    args = _parser().parse_args(argv)
    try:
        return _append(args) if args.command == "append" else _project(args)
    except ActivityProjectionError as exc:
        print(f"okstra agent-activity: {exc}", file=sys.stderr)
        return 1


if __name__ == "__main__":
    raise SystemExit(main(sys.argv[1:]))
