"""Structured JSONL events emitted by artifact-accounted lead runtimes."""
from __future__ import annotations

import json
import hashlib
import re
import uuid
from contextlib import contextmanager
from dataclasses import dataclass, field, replace
from pathlib import Path
from typing import Any, Iterator, Mapping

from okstra_ctl.run_context import dir_flock


REQUIRED_FIELDS = (
    "eventType",
    "leadRuntime",
    "taskKey",
    "taskType",
    "runSeq",
    "timestamp",
    "details",
)


class LeadEventParseError(ValueError):
    """Raised when a lead-events JSONL file contains an invalid row."""

    def __init__(self, path: Path, line_number: int, message: str) -> None:
        super().__init__(f"{path}:{line_number}: {message}")
        self.path = path
        self.line_number = line_number


@dataclass(frozen=True)
class LeadEvent:
    event_type: str
    lead_runtime: str
    task_key: str
    task_type: str
    run_seq: str
    timestamp: str
    details: Mapping[str, Any] = field(default_factory=dict)

    def to_record(self) -> dict[str, Any]:
        return {
            "eventType": self.event_type,
            "leadRuntime": self.lead_runtime,
            "taskKey": self.task_key,
            "taskType": self.task_type,
            "runSeq": self.run_seq,
            "timestamp": self.timestamp,
            "details": _stable_json_value(dict(self.details)),
        }

    @classmethod
    def from_record(cls, record: Mapping[str, Any]) -> "LeadEvent":
        missing = [field_name for field_name in REQUIRED_FIELDS
                   if field_name not in record]
        if missing:
            raise ValueError(
                "missing required lead event field(s): " + ", ".join(missing)
            )
        details = record["details"]
        if not isinstance(details, Mapping):
            raise ValueError("lead event details must be a JSON object")
        return cls(
            event_type=_require_string(record, "eventType"),
            lead_runtime=_require_string(record, "leadRuntime"),
            task_key=_require_string(record, "taskKey"),
            task_type=_require_string(record, "taskType"),
            run_seq=_require_string(record, "runSeq"),
            timestamp=_require_string(record, "timestamp"),
            details=dict(details),
        )


def append_lead_event(path: Path, event: LeadEvent) -> None:
    """Append one lead event as compact JSONL, creating parent directories."""
    with _event_log_lock(path):
        _append_unlocked(path, event)


@contextmanager
def _event_log_lock(path: Path) -> Iterator[None]:
    with dir_flock(path.parent, f".{path.name}.lock"):
        yield


def _append_unlocked(path: Path, event: LeadEvent) -> None:
    path.parent.mkdir(parents=True, exist_ok=True)
    with path.open("a", encoding="utf-8") as handle:
        handle.write(_event_json(event) + "\n")


def _next_activity_id(events: list[LeadEvent]) -> str:
    numbers = [
        int(match.group(1))
        for event in events
        if event.event_type == "activity"
        for match in [
            re.fullmatch(
                r"A-(\d{3,})", str(event.details.get("activityId", ""))
            )
        ]
        if match is not None
    ]
    return f"A-{max(numbers, default=0) + 1:03d}"


def _activity_request_digest(details: Mapping[str, Any]) -> str:
    payload = {
        key: value for key, value in details.items()
        if key not in {"activityId", "activityRequestDigest", "activityRequestRef", "activityVersion"}
    }
    encoded = json.dumps(_stable_json_value(payload), ensure_ascii=False, separators=(",", ":"))
    return hashlib.sha256(encoded.encode("utf-8")).hexdigest()


def append_activity_event(path: Path, event: LeadEvent) -> LeadEvent:
    """Assign the next activity ID and append the event under one lock."""
    if event.event_type != "activity":
        raise ValueError("activity append requires eventType=activity")
    with _event_log_lock(path):
        details = dict(event.details)
        request_ref = details.get("activityRequestRef")
        events = read_lead_events(path)
        digest = _activity_request_digest(details)
        if request_ref is not None:
            if not isinstance(request_ref, str) or not request_ref:
                raise ValueError("activityRequestRef must be a non-empty string")
            for previous in events:
                if (
                    previous.event_type == event.event_type
                    and previous.lead_runtime == event.lead_runtime
                    and previous.task_key == event.task_key
                    and previous.task_type == event.task_type
                    and previous.run_seq == event.run_seq
                    and previous.details.get("activityRequestRef") == request_ref
                ):
                    if previous.details.get("activityRequestDigest") != digest:
                        raise ValueError("activity request reference has different payload")
                    return previous
            raise ValueError("unknown activity request reference")
        details["activityRequestRef"] = f"activity-{uuid.uuid4().hex}"
        details["activityRequestDigest"] = digest
        details["activityVersion"] = 1
        details["activityId"] = _next_activity_id(events)
        stored = replace(event, details=details)
        _append_unlocked(path, stored)
    return stored


def read_lead_events(path: Path) -> list[LeadEvent]:
    """Parse a lead-events JSONL file.

    Missing files read as an empty log. Blank lines are ignored. Malformed JSON
    and malformed event objects fail loudly with path and line number context.
    """
    if not path.is_file():
        return []
    events: list[LeadEvent] = []
    for line_number, line in enumerate(
        path.read_text(encoding="utf-8").splitlines(), start=1
    ):
        stripped = line.strip()
        if not stripped:
            continue
        try:
            record = json.loads(stripped)
        except json.JSONDecodeError as exc:
            raise LeadEventParseError(path, line_number, "invalid JSON") from exc
        if not isinstance(record, Mapping):
            raise LeadEventParseError(
                path, line_number, "lead event row must be a JSON object"
            )
        try:
            events.append(LeadEvent.from_record(record))
        except ValueError as exc:
            raise LeadEventParseError(path, line_number, str(exc)) from exc
    return events


def _event_json(event: LeadEvent) -> str:
    return json.dumps(event.to_record(), ensure_ascii=False, separators=(",", ":"))


def _require_string(record: Mapping[str, Any], field_name: str) -> str:
    value = record[field_name]
    if not isinstance(value, str) or not value:
        raise ValueError(f"lead event {field_name} must be a non-empty string")
    return value


def _stable_json_value(value: Any) -> Any:
    if isinstance(value, Mapping):
        return {
            key: _stable_json_value(value[key])
            for key in sorted(value)
        }
    if isinstance(value, list):
        return [_stable_json_value(item) for item in value]
    return value
