# -*- coding: utf-8 -*-
"""anu_v3.callback_4tuple_index — deterministic 4-tuple ownership index.

Standalone module for task-2553+29 (NO-CRON variant, 9-R.1/9-R.3).
Zero import/mutation of anu_v3.parallel_batch_coordinator and zero edit of
the frozen anu_v3.batch_join_policy anchor. File-level contract only.

A track's callback 4-tuple is:

    (task_id, dispatch_cron_id, normal_collector_cron_id, fallback_callback_cron_id)

This index lets the registry decide, when a normal-collector or fallback
event is observed, whether that event actually belongs to the claimed track.
Any cross-tuple ownership violation -> TRACK_MISMATCH (regression 7-10).
"""
from __future__ import annotations

from dataclasses import dataclass
from typing import Dict, List, Optional

TRACK_MISMATCH = "TRACK_MISMATCH"


@dataclass(frozen=True)
class Tuple4:
    task_id: str
    dispatch_cron_id: str
    normal_collector_cron_id: Optional[str]
    fallback_callback_cron_id: str


class Callback4TupleIndex:
    """Builds reverse maps cron_id -> owning task_id and validates events."""

    def __init__(self) -> None:
        self._by_task: Dict[str, Tuple4] = {}
        self._dispatch_owner: Dict[str, str] = {}
        self._normal_owner: Dict[str, str] = {}
        self._fallback_owner: Dict[str, str] = {}

    def register(self, t: Tuple4) -> None:
        self._by_task[t.task_id] = t
        if t.dispatch_cron_id:
            self._dispatch_owner[t.dispatch_cron_id] = t.task_id
        if t.normal_collector_cron_id:
            self._normal_owner[t.normal_collector_cron_id] = t.task_id
        if t.fallback_callback_cron_id:
            self._fallback_owner[t.fallback_callback_cron_id] = t.task_id

    def tuple_for(self, task_id: str) -> Optional[Tuple4]:
        return self._by_task.get(task_id)

    def validate_tuple(self, task_id: str) -> List[str]:
        """Internal consistency of a track's own 4-tuple."""
        reasons: List[str] = []
        t = self._by_task.get(task_id)
        if t is None:
            return [f"{task_id}: no 4-tuple registered"]
        if t.task_id != task_id:
            reasons.append(f"task_id mismatch: tuple={t.task_id} claimed={task_id}")
        if not t.dispatch_cron_id:
            reasons.append(f"{task_id}: dispatch_cron_id empty")
        if not t.fallback_callback_cron_id:
            reasons.append(f"{task_id}: fallback_callback_cron_id empty")
        return reasons

    def classify_event(
        self,
        *,
        claimed_task_id: str,
        event_kind: str,
        event_task_id: Optional[str] = None,
        event_cron_id: Optional[str] = None,
    ) -> List[str]:
        """Return TRACK_MISMATCH reasons (empty list == owned correctly).

        event_kind in {"normal_collector", "fallback", "dispatch"}.
        Checks both the carried task_id and the cron_id ownership.
        """
        reasons: List[str] = []
        t = self._by_task.get(claimed_task_id)
        if t is None:
            return [f"{claimed_task_id}: unregistered track for {event_kind} event"]

        if event_task_id is not None and event_task_id != claimed_task_id:
            reasons.append(
                f"{event_kind} task_id mismatch: event={event_task_id} "
                f"claimed={claimed_task_id}"
            )

        owner_map = {
            "dispatch": self._dispatch_owner,
            "normal_collector": self._normal_owner,
            "fallback": self._fallback_owner,
        }.get(event_kind, {})

        if event_cron_id is not None:
            owner = owner_map.get(event_cron_id)
            if owner is None:
                reasons.append(
                    f"{event_kind} cron_id {event_cron_id} owned by no registered track"
                )
            elif owner != claimed_task_id:
                reasons.append(
                    f"{event_kind} cron_id {event_cron_id} belongs to {owner}, "
                    f"not {claimed_task_id}"
                )
        return reasons
