"""Shared helpers for ModalityRunner implementations.

Single source of truth for runner failure-metadata shapes and the
``RunResult.id`` generator.  Each runner imports the shape it needs with
an ``as _failure_metadata`` alias so call sites in the runner body stay
neutral about which shape they're using.

Failure-metadata shapes
-----------------------
Four distinct shapes exist in production; their key sets differ and tests
assert exact-equality on three of them, so each shape is its own function:

* :func:`_failure_metadata_production` — image_runner + video_runner.
  Six keys: ``final_state, cost_usd, gate_verdict, take_index, model,
  pipeline``.

* :func:`_failure_metadata_audio` — audio_runner. Six keys:
  ``final_state, cost_usd, model, voice_id, request_id, char_count``.

* :func:`_failure_metadata_lipsync` — lipsync_post. Seven keys:
  ``final_state, cost_usd, model, job_id, duration_s, video_path,
  audio_path``.

* :func:`_failure_metadata_eval` — eval_image_runner + eval_video_runner
  + eval_audio_runner. Six keys: ``final_state, eval_score,
  eval_reasoning, judge_id, model_used, eval_cost_usd``.

ID generation
-------------
:func:`make_run_result_id` builds ``"{shot_id}_{modality}_{time_ns}"``.
image_runner and video_runner still use ``int(time.time())`` (seconds)
inline; switching them to nanoseconds would change the observable ID
format and is intentionally out of scope here.
"""

from __future__ import annotations

import time
from typing import Any


def _failure_metadata_production() -> dict[str, Any]:
    """6-key failure metadata for image_runner + video_runner.

    Used by validation-failure and exception-failure paths so CP-5+
    callers can do ``result.metadata["cost_usd"]`` without checking
    ``result.success`` first.  Mirrors the shape that the
    success-path adapter ``_step_result_to_run_result`` populates from
    ``StepResult``.

    Sentinel values: ``final_state == "failed"`` and ``cost_usd == 0.0``
    so callers can sum costs across receipts without filtering or
    NaN-checking (post-CP-4 Bug 6.1 absorption, CP-5 Phase 2).
    """
    return {
        "final_state": "failed",
        "cost_usd": 0.0,
        "gate_verdict": None,
        "take_index": None,
        "model": None,
        "pipeline": None,
    }


def _failure_metadata_audio() -> dict[str, Any]:
    """6-key failure metadata for audio_runner.

    Mirrors AudioRunner's success-path metadata key set (model, voice_id,
    request_id, char_count) so callers see a stable key surface across
    success/failure paths.  Exact-equality asserted by
    test_audio_runner.py::test_empty_payload_mentions_all_missing_keys.
    """
    return {
        "final_state": "failed",
        "cost_usd": 0.0,
        "model": None,
        "voice_id": None,
        "request_id": None,
        "char_count": 0,
    }


def _failure_metadata_lipsync() -> dict[str, Any]:
    """7-key failure metadata for lipsync_post.

    Mirrors LipSyncPostProcessor's success-path metadata key set
    (model, job_id, duration_s, video_path, audio_path) so callers
    see a stable key surface across success/failure paths.
    """
    return {
        "final_state": "failed",
        "cost_usd": 0.0,
        "model": None,
        "job_id": None,
        "duration_s": None,
        "video_path": None,
        "audio_path": None,
    }


def _failure_metadata_eval() -> dict[str, Any]:
    """6-key failure metadata for eval_image_runner / eval_video_runner /
    eval_audio_runner.

    Per CP-9 Phase 4 § 12c R2: every key is present on every failure
    path so callers can do ``result.metadata["eval_score"]`` without
    branching on ``result.success``.  Exact-equality asserted by all
    three eval-runner test files.
    """
    return {
        "final_state": "failed",
        "eval_score": None,
        "eval_reasoning": None,
        "judge_id": None,
        "model_used": None,
        "eval_cost_usd": 0.0,
    }


def make_run_result_id(shot_id: str | None, modality: str) -> str:
    """Generate a unique RunResult.id that won't collide across re-runs.

    Format: ``{shot_id}_{modality}_{time_ns}``.  Falls back to
    ``"unknown"`` when ``shot_id`` is None or empty (matches the
    pre-consolidation ``f"{shot_id or 'unknown'}_..."`` pattern).

    Replaces 25+ inline copies of this pattern across audio_runner,
    lipsync_post, and the three eval_*_runner files.  Uses
    ``time.time_ns()`` (nanoseconds) to ensure uniqueness even when
    two runs of the same shot kick off in the same wall-clock second.
    """
    safe_shot = shot_id or "unknown"
    return f"{safe_shot}_{modality}_{time.time_ns()}"


def _require_aspect_ratio(payload: dict[str, Any], shot_id: str) -> str:
    """Return aspect_ratio from payload, raising MissingAspectRatioError if absent or empty.

    Normalizes internal underscore format ("9_16") to the API colon format ("9:16").
    Internal project config and AspectRatio enum use underscores; every provider API
    expects colons — normalize once here so callers never see the internal form.
    """
    val = payload.get("aspect_ratio")
    if val is None or val == "":
        from recoil.pipeline.core.exceptions import MissingAspectRatioError
        raise MissingAspectRatioError(
            f"shot_id={shot_id}: payload missing aspect_ratio. "
            "Use dispatch() with a project context, or pass explicitly."
        )
    return val.replace("_", ":")


__all__ = [
    "_failure_metadata_production",
    "_failure_metadata_audio",
    "_failure_metadata_lipsync",
    "_failure_metadata_eval",
    "make_run_result_id",
    "_require_aspect_ratio",
]
