"""Deterministic materialization of per-shot spatial_data from a scene's SceneAxisPlan.

REC-180: the LLM authors a scene-level SceneAxisPlan (the 180-degree line of action plus
the few intentional jumps / re-establishments). This module walks each scene's shots in
order and DERIVES every shot's camera_side / screen_direction / axis_segment_id /
cut_relation / axis_transition_reason. Pure: mutates the plan in place, no I/O.

Segment semantics (axis_segment_id): a NEW editorial segment begins on re_establish (the
axis MOVED) and on neutral_pivot (an on-axis bridge that opens a fresh orientation).
intentional_jump flips camera_side WITHIN the same axis/segment (same line of action, camera
crossed) — so an UNLICENSED flip back within that segment is correctly caught by the critic.

Defensive: a missing OR structurally-invalid scene plan falls back to a neutral anchor for
that scene, so an invalid present plan can never crash or mis-materialize. Validity is decided
by the SAME predicate the validator/sanitizer use (axis_validation.scene_plan_errors).
"""
from __future__ import annotations

import logging

from recoil.pipeline._lib.render_schema import (
    AxisAnchor,
    AxisKind,
    AxisTransitionKind,
    CutRelation,
    EpisodePlan,
    SceneAxisPlan,
    ScreenDirection,
)
from recoil.pipeline.orchestrator.axis_validation import (
    noncontiguous_scenes,
    scene_plan_errors,
    shot_index_of,
)

logger = logging.getLogger(__name__)

_MIRROR = {
    ScreenDirection.LEFT_TO_RIGHT: ScreenDirection.RIGHT_TO_LEFT,
    ScreenDirection.RIGHT_TO_LEFT: ScreenDirection.LEFT_TO_RIGHT,
    ScreenDirection.TOWARD_CAMERA: ScreenDirection.AWAY_FROM_CAMERA,
    ScreenDirection.AWAY_FROM_CAMERA: ScreenDirection.TOWARD_CAMERA,
    ScreenDirection.CENTER: ScreenDirection.CENTER,
}


def mirror(d: ScreenDirection) -> ScreenDirection:
    return _MIRROR.get(d, ScreenDirection.CENTER)


def project_direction(anchor: AxisAnchor, side: str) -> ScreenDirection:
    """Per-shot screen_direction projected from the active anchor + camera side.
    side is only ever the literal "A" or "B"; any non-"B" value is treated as side A."""
    base = anchor.reference_direction
    if anchor.kind == AxisKind.NEUTRAL:
        if base in (ScreenDirection.TOWARD_CAMERA, ScreenDirection.AWAY_FROM_CAMERA):
            return base
        return ScreenDirection.CENTER
    return base if side != "B" else mirror(base)


def propagate_axis(plan: EpisodePlan) -> None:
    """Mutate plan in place: derive every shot's spatial_data from plan.axis_plans."""
    # Group by scene_index (preserving plan.shots order) so propagation validates against the
    # SAME full-scene shot set that validate_axis_plans/sanitize_axis_plans use — the scene's
    # axis_plan is the SSOT for the whole scene. v1 LIMITATION: assumes each scene is a
    # contiguous run (true by camera-test construction — scene_index is assigned monotonically);
    # shot-level intercuts (scene 1 → 2 → 1) are not modeled, so cut_relation across an intercut
    # boundary would read "consistent" rather than "scene_open". Deferred — does not occur in
    # current Stage-0 output.
    # Shared non-contiguity detection (same helper validate/sanitize use, so they agree).
    noncontig = noncontiguous_scenes(plan.shots)

    scenes: dict[int, list] = {}
    for shot in plan.shots:
        scenes.setdefault(shot.scene_index, []).append(shot)

    for scene_index, scene_shots in scenes.items():
        idx_list = [shot_index_of(s) for s in scene_shots]   # ORDERED (duplicates preserved)
        scene_plan = plan.axis_plans.get(scene_index)

        # Decide whether to neutral-fallback. scene_plan_errors (the SHARED predicate, called with
        # the ordered list) catches duplicate effective indices + every structural rule, so the
        # transitions dict is only built for a plan that passed. A PRESENT plan dropped here is
        # also removed from plan.axis_plans so persisted provenance matches the derived fields.
        reason = None
        if scene_index in noncontig:
            reason = "non-contiguous (intercut) — v1 unsupported"
        elif scene_plan is not None and scene_plan_errors(scene_plan, idx_list):
            reason = "invalid for these shots (failed scene_plan_errors)"

        if reason is not None:
            if scene_plan is not None:
                logger.warning("axis: scene %s neutral-fallback — %s", scene_index, reason)
                plan.axis_plans.pop(scene_index, None)   # reconcile provenance with materialization
            scene_plan = SceneAxisPlan(initial_anchor=AxisAnchor(kind=AxisKind.NEUTRAL))
        elif scene_plan is None:
            scene_plan = SceneAxisPlan(initial_anchor=AxisAnchor(kind=AxisKind.NEUTRAL))

        anchor = scene_plan.initial_anchor
        side = "A"
        segment_id = 0
        transitions_by_idx = {t.before_shot_index: t for t in scene_plan.transitions}

        for i, shot in enumerate(scene_shots):
            relation = CutRelation.SCENE_OPEN if i == 0 else CutRelation.CONSISTENT
            reason = None
            t = transitions_by_idx.get(shot_index_of(shot))
            if t is not None:
                relation = CutRelation(t.kind.value)
                reason = t.reason or None
                if t.kind == AxisTransitionKind.INTENTIONAL_JUMP:
                    # flip side WITHIN the same axis/segment (camera crosses the same line)
                    side = "B" if side == "A" else "A"
                elif t.kind == AxisTransitionKind.RE_ESTABLISH:
                    # the axis MOVED — new anchor, fresh segment, reset to side A
                    anchor = t.new_anchor or anchor
                    side = "A"
                    segment_id += 1
                elif t.kind == AxisTransitionKind.NEUTRAL_PIVOT:
                    # on-axis bridge shot: reset to side A and open a fresh segment so the
                    # scene can re-orient cleanly; the pivot shot itself is materialized on-axis.
                    # v1 LIMITATION: always emerges on side A (bridges B->A); a pivot cannot
                    # intentionally cross A->B in v1 (would need an emerge_side field). For an
                    # A->B cross, the LLM should author an intentional_jump instead.
                    side = "A"
                    segment_id += 1

            sd = shot.spatial_data
            sd.camera_side = side
            sd.axis_segment_id = segment_id
            sd.cut_relation = relation
            sd.axis_transition_reason = reason
            # The neutral_pivot shot is the on-axis bridge: project it CENTER regardless of anchor.
            if relation == CutRelation.NEUTRAL_PIVOT:
                sd.screen_direction = ScreenDirection.CENTER
            else:
                sd.screen_direction = project_direction(anchor, side)
