"""Data types, enums, and constants for the eval harness.

Extracted from ``evals.run_eval`` to keep the main orchestrator lean.
Re-exported by ``evals.run_eval`` for backward compatibility.
"""

from __future__ import annotations

import os
import re
import threading
from dataclasses import dataclass, field
from enum import Enum
from pathlib import Path
from typing import TYPE_CHECKING, Any, Callable, NamedTuple, cast

from evals.adaptive_budget_online_policy import (
    BUDGET_POLICY_ALGOS,
    BUDGET_POLICY_MODES,
)
from evals.adaptive_online_policy import (
    ACTION_COMPACT_THEN_PATCH,
    ACTION_FILE_ONLY,
    ACTION_FRAME_ONLY,
    ACTION_INVOKE_HELPER,
    ACTION_RETRY_HELPER_STRICT_JSON,
    ACTION_SEARCH_ONLY,
    ACTION_SNAPSHOT_LITE,
    ONLINE_POLICY_ALGOS,
    ONLINE_POLICY_MODES,
    PolicyDecision,
)
from evals.policy_feature_scaling import POLICY_FEATURE_SCALING_PROFILES

if TYPE_CHECKING:
    from evals.patchers.payload_types import ContextRequestEntry, ContextRequestPayload
else:
    ContextRequestEntry = dict[str, Any]  # type: ignore[misc,assignment]
    ContextRequestPayload = dict[str, Any]  # type: ignore[misc,assignment]


class Condition(str, Enum):
    """Experimental conditions for the A/B eval."""

    TRACEBACK_ONLY = "traceback_only"
    WITH_SNAPSHOT = "with_snapshot"
    WITH_SNAPSHOT_STRUCTURED = "with_snapshot_structured"
    ADAPTIVE_GATED = "adaptive_gated"
    ADAPTIVE_LLM_DISCRETION = "adaptive_llm_discretion"


VALID_CATEGORIES = {
    "hidden_state",
    "mutability_aliasing",
    "async_temporal",
    "cross_file_contract",
    "data_shape_runtime",
    "parse_value",
    "path_import_env",
    "traceback_controls",
    # Extended categories for imported datasets.
    "syntax_error",
    "reference_error",
    "logic_error",
    "type_coercion",
}
VALID_DIFFICULTIES = {"easy", "medium", "hard"}
VALID_SNAPSHOT_DEPENDENCIES = {"required", "helpful", "none"}
PATCH_APPLY_TIMEOUT_SEC = 10
PYTEST_TIMEOUT_RETURNCODE = 124
EVAL_RECORD_SCHEMA_VERSION = 5
RECORD_MODES = {"full", "hash", "none"}
CONTEXT_PROTOCOLS = {"monolithic", "staged"}
RETRY_PROMPT_MODES = {"full", "delta", "auto"}
RETRY_CONTEXT_MODES = {"fresh", "history"}
CONTEXT_COMPACTION_MODES = {"off", "auto_overflow", "always"}
CONTEXT_COMPACTION_SCOPE_TOKENS = {"snapshot", "pytest", "files"}
ADAPTIVE_GATE_POLICIES = {"aggressive", "balanced", "conservative"}
ADAPTIVE_HELPER_BUDGET_PROFILES = {"default", "compact"}
ADAPTIVE_OPENAI_HELPER_RESPONSE_MODES = {"inherit", "edits", "diff"}
ADAPTIVE_FEATURE_SCALING_PROFILES = set(POLICY_FEATURE_SCALING_PROFILES)
OPENAI_RESPONSE_MODE_AUTOSWITCH_MODES = {"off", "retry_to_diff"}
ADAPTIVE_ONLINE_POLICY_MODES = set(ONLINE_POLICY_MODES)
ADAPTIVE_ONLINE_POLICY_ALGOS = set(ONLINE_POLICY_ALGOS)
ADAPTIVE_BUDGET_POLICY_MODES = set(BUDGET_POLICY_MODES)
ADAPTIVE_BUDGET_POLICY_ALGOS = set(BUDGET_POLICY_ALGOS)
OPENAI_CONTEXT_WINDOW_CHARS_PER_TOKEN = 4
OPENAI_CONTEXT_WINDOW_RESERVE_TOKENS = 1024
ADAPTIVE_REWARD_LAMBDA_TOKENS_DEFAULT = 0.08
ADAPTIVE_REWARD_MU_LATENCY_DEFAULT = 0.05
ADAPTIVE_REWARD_TOKEN_SCALE_DEFAULT = 1000.0
ADAPTIVE_REWARD_LATENCY_SCALE_DEFAULT = 10.0
ADAPTIVE_BUDGET_REWARD_RHO_BUDGET_EXCEEDED_DEFAULT = 0.5
ADAPTIVE_REWARD_ETA_PROGRESS_DEFAULT = 0.15
ADAPTIVE_REWARD_KAPPA_STAGNATION_DEFAULT = 0.10
PATCH_VERIFIER_SCOPE_DISABLED = "disabled"
PATCH_VERIFIER_SCOPE_ADAPTIVE_ONLY = "adaptive_only"
PATCH_VERIFIER_MIN_SCORE = 2
DEFAULT_SYNTAX_PREFLIGHT_MAX_CYCLES = 2
DEFAULT_ADAPTIVE_BUDGET_TOPUP_MAX_CALLS = 1
DEFAULT_ADAPTIVE_NO_PROGRESS_TOKEN_CAP = 150_000
DEFAULT_TRANSPORT_FAIL_FAST_STREAK = 5
DEFAULT_PROGRESS_HEARTBEAT_SEC = 30.0
DEFAULT_PYTEST_OUTPUT_TAIL_LINES = 4_000
PERSISTED_PYTEST_OUTPUT_HARD_CHAR_CAP = 250_000
DEFAULT_SNAPSHOT_ARTIFACT_MAX_STRING_CHARS = 16_000
ADAPTIVE_HELPER_OUTCOME_NOT_INVOKED = "not_invoked"
ADAPTIVE_HELPER_OUTCOME_MATERIALIZED = "materialized"
ADAPTIVE_HELPER_OUTCOME_SEMANTIC_NO_CONTEXT = "semantic_no_context"
ADAPTIVE_HELPER_OUTCOME_INVALID_PAYLOAD = "invalid_payload"
ADAPTIVE_HELPER_OUTCOME_TRANSPORT_FAILED = "transport_failed"
ADAPTIVE_HELPER_OUTCOME_UNAVAILABLE_BACKEND = "unavailable_backend"
ADAPTIVE_HELPER_OUTCOME_REQUEST_BUDGET_EXCEEDED = "request_budget_exceeded"
ADAPTIVE_HELPER_OUTCOME_SKIPPED_POLICY = "skipped_policy"
ADAPTIVE_HELPER_OUTCOME_SKIPPED_GUARDRAIL = "skipped_guardrail"
ADAPTIVE_HELPER_OUTCOME_SKIPPED_UNSUPPORTED = "skipped_unsupported"
HELPER_STAGE_A_ACTIONS = frozenset(
    {
        ACTION_INVOKE_HELPER,
        ACTION_FRAME_ONLY,
        ACTION_FILE_ONLY,
        ACTION_SEARCH_ONLY,
        ACTION_RETRY_HELPER_STRICT_JSON,
    }
)
HELPER_RESTRICTED_STAGE_A_ACTIONS = frozenset(
    {
        ACTION_FRAME_ONLY,
        ACTION_FILE_ONLY,
        ACTION_SEARCH_ONLY,
    }
)
HELPER_CONTEXT_MATERIALIZING_ACTIONS = frozenset(
    {
        ACTION_INVOKE_HELPER,
        ACTION_FRAME_ONLY,
        ACTION_FILE_ONLY,
        ACTION_SEARCH_ONLY,
        ACTION_RETRY_HELPER_STRICT_JSON,
        ACTION_SNAPSHOT_LITE,
        ACTION_COMPACT_THEN_PATCH,
    }
)
PROGRESS_VERBOSITIES = {"basic", "detailed", "trace"}
PATCHER_CONTEXT_LIMIT_ERROR_MARKERS = (
    "exceeds the available context size",
    "context size",
    "greater than the context length",
    "tokens to keep from the initial prompt is greater than the context length",
    "maximum context length",
    "prompt is too long",
)
CONTEXT_RECOVERY_FAILURE_KINDS = {"context_limit", "transport"}
RETRYABLE_HTTP_STATUS_CODES = {408, 409, 425, 429}
CONTEXT_RECOVERY_PROMPT_BUDGET = {
    "total_chars": 24_000,
    "retry_delta_chars": 4_000,
    "stdout_chars": 4_000,
    "snapshot_chars": 6_000,
    "evidence_chars": 10_000,
    "retry_history_chars": 2_000,
}
OPENAI_API_KEY_ENV_FALLBACKS = ("Z_API", "OPENAI_API_KEY", "LLMDEBUG_EVAL_OPENAI_API_KEY")
VALID_EXECUTORS = {"local", "testcontainers"}
CONTAINER_NETWORK_MODES = {"none", "bridge"}
DEFAULT_EXECUTOR = "testcontainers"
DEFAULT_CONTAINER_IMAGE = os.getenv("LLMDEBUG_EVAL_CONTAINER_IMAGE", "llmdebug-eval-runner:latest")
PATCH_VERIFIER_RECOVERY_RESULT_NOT_ELIGIBLE = "not_eligible"
PATCH_VERIFIER_RECOVERY_RESULT_SKIPPED_DEADLINE_EXHAUSTED = "skipped_deadline_exhausted"
PATCH_VERIFIER_RECOVERY_RESULT_NO_DIFF = "no_diff"
PATCH_VERIFIER_RECOVERY_RESULT_REQUEST_FAILED = "request_failed"
PATCH_VERIFIER_RECOVERY_RESULT_STILL_REJECTED = "still_rejected"
PATCH_VERIFIER_RECOVERY_RESULT_PASSED_VERIFIER = "passed_verifier"
DEFAULT_CONTAINER_NETWORK = "none"
DEFAULT_CONTAINER_CPUS = 1.0
DEFAULT_CONTAINER_MEMORY = "1g"
DEFAULT_CONTAINER_PIDS_LIMIT = 256
DEFAULT_CONTAINER_TMPFS_SIZE_MB = 128
DEFAULT_CONTAINER_CLEANUP_STALE_GRACE_SEC = 300
EVAL_CONTAINER_LABEL_KEY = "io.llmdebug.eval.managed"
EVAL_CONTAINER_LABEL_VALUE = "1"
_CONTAINER_ACTIVE_STATES = {"running", "restarting", "paused"}
RUN_EVAL_CONFIG_SECTION = "run_eval"
RUN_EVAL_CONFIG_LOCK_SCHEMA_VERSION = 1
RUN_EVAL_LOCKFILE_REDACTED = "***REDACTED***"
RUN_EVAL_SECRET_CONFIG_LITERAL_KEYS = {"openai_api_key"}
RUN_EVAL_SECRET_CONFIG_ENV_KEYS = {"openai_api_key_env"}
RUN_EVAL_LOCKFILE_REDACTED_KEYS = {"openai_api_key"}


def _default_helper_action() -> str:
    return ACTION_INVOKE_HELPER


def _helper_action_allowed_request_types(action: str) -> tuple[str, ...]:
    if action == ACTION_FRAME_ONLY:
        return ("frame",)
    if action == ACTION_FILE_ONLY:
        return ("file",)
    if action == ACTION_SEARCH_ONLY:
        return ("search",)
    return ("file", "search", "frame")


def _helper_action_fallback_reason(
    *,
    action: str,
    supports_context_request: bool,
    snapshot_available: bool,
    context_compaction_mode: str,
    supports_context_compaction: bool,
    context_compaction_calls_used: int,
    context_compaction_max_calls: int,
    is_openai_patcher: bool,
    last_helper_invalid_response: bool,
) -> str:
    """Determine why a helper action cannot execute.

    Returns an empty string when the action is eligible, or a short
    reason token (e.g. ``"snapshot_unavailable"``) when it must fall back.
    """
    if (
        action in (HELPER_RESTRICTED_STAGE_A_ACTIONS | {ACTION_RETRY_HELPER_STRICT_JSON})
        and not supports_context_request
    ):
        return "request_context_unsupported"
    if action == ACTION_SNAPSHOT_LITE and not snapshot_available:
        return "snapshot_unavailable"
    if action == ACTION_COMPACT_THEN_PATCH:
        if str(context_compaction_mode or "off") == "off":
            return "context_compaction_disabled"
        if not supports_context_compaction:
            return "context_compaction_unsupported"
        if max(0, int(context_compaction_max_calls)) <= 0:
            return "context_compaction_budget_exhausted"
        if max(0, int(context_compaction_calls_used)) >= max(0, int(context_compaction_max_calls)):
            return "context_compaction_budget_exhausted"
    if action == ACTION_RETRY_HELPER_STRICT_JSON:
        if not is_openai_patcher:
            return "strict_json_requires_openai_compatible_patcher"
        if not last_helper_invalid_response:
            return "strict_json_requires_prior_invalid_payload"
    return ""


def _filter_context_request_payload_for_action(
    *,
    action: str,
    request_payload: ContextRequestPayload | None,
) -> ContextRequestPayload | None:
    """Restrict a Stage-A request payload to match a restricted action.

    For restricted actions (frame-only, file-only, search-only) the
    payload is filtered to include only the allowed request type.
    """
    if request_payload is None or action not in HELPER_RESTRICTED_STAGE_A_ACTIONS:
        return request_payload
    allowed_types = set(_helper_action_allowed_request_types(action))
    filtered_requests: list[ContextRequestEntry] = []
    requests_raw = request_payload.get("requests")
    if isinstance(requests_raw, list):
        for entry in requests_raw:
            if not isinstance(entry, dict):
                continue
            if str(entry.get("type") or "").strip() not in allowed_types:
                continue
            filtered_requests.append(cast(ContextRequestEntry, dict(entry)))
            if len(filtered_requests) >= 1:
                break
    filtered_payload = cast(ContextRequestPayload, dict(request_payload))
    filtered_payload["requests"] = cast(Any, filtered_requests)
    filtered_payload["need_snapshot"] = False
    filtered_payload["need_compact_context"] = False
    filtered_payload["snapshot_justification"] = ""
    filtered_payload["compact_context_reason"] = ""
    return filtered_payload


def _default_container_user() -> str:
    try:
        uid = getattr(os, "getuid", None)
        gid = getattr(os, "getgid", None)
        if callable(uid) and callable(gid):
            uid_fn = cast(Callable[[], int], uid)
            gid_fn = cast(Callable[[], int], gid)
            return f"{uid_fn()}:{gid_fn()}"
    except Exception:
        pass
    return "0:0"


DEFAULT_CONTAINER_USER = _default_container_user()


@dataclass(frozen=True)
class StageAContextData:
    """Immutable result of a Stage-A context-request round."""

    blocks: list[dict[str, Any]]
    text: str
    used_fallback: bool
    requests_count: int
    chars_returned: int
    request_success: bool | None
    request_payload: ContextRequestPayload | None
    request_raw: str | None
    reason: str
    evidence_items: list[dict[str, Any]] = field(default_factory=list)


def _empty_stage_a_context(*, reason: str = "not_used") -> StageAContextData:
    return StageAContextData(
        blocks=[],
        text="",
        used_fallback=False,
        requests_count=0,
        chars_returned=0,
        request_success=None,
        request_payload=None,
        request_raw=None,
        reason=reason,
        evidence_items=[],
    )


@dataclass(frozen=True)
class ContextCompactionBundle:
    """Immutable bundle of text assembled for context compaction."""

    text: str
    source_sections: list[str]
    source_chars: dict[str, int]
    replace_snapshot: bool
    replace_pytest_output: bool
    replace_stage_a_context: bool
    replace_project_files: bool


@dataclass
class _ActiveRunSettings:
    """Global mutable state for deterministic-seed propagation."""

    deterministic: bool = False
    seed: int | None = None


@dataclass(frozen=True)
class ConfigResolutionState:
    """Tracks which TOML config files were loaded and value provenance."""

    config_paths: list[str] = field(default_factory=list)
    value_sources: dict[str, str] = field(default_factory=dict)


@dataclass(frozen=True)
class CaseSpec:
    """Frozen specification of a single eval case loaded from disk."""

    case_id: str
    path: Path
    category: str
    difficulty: str
    snapshot_dependency: str
    expected_exception: str
    tags: list[str]
    python_args: list[str]
    timeout_sec: int
    description: str | None = None
    # Extended fields for imported cases (all optional, backward-compatible).
    bug_source: str | None = None
    contamination_risk: str | None = None
    expected_fix_lines: int | None = None
    source_id: str | None = None
    difficulty_rationale: str | None = None


@dataclass(frozen=True)
class RunResult:
    """Frozen result of a single pytest execution."""

    returncode: int
    duration_sec: float
    stdout: str
    stderr: str
    timed_out: bool = False
    timeout_sec: int | None = None


@dataclass(frozen=True)
class UnitExecutionOutcome:
    """Outcome of running one (case, condition) eval unit."""

    case: CaseSpec
    condition: Condition
    case_records: list[dict[str, Any]] | None
    unit_duration_sec: float
    runtime_error: str | None = None
    records_streamed: bool = False


@dataclass(frozen=True)
class _TrailingResultsTailIssue:
    """Describes a corrupt trailing record found during resume scan."""

    line_no: int
    truncate_offset_bytes: int
    raw_line_bytes: bytes
    parse_error: str


@dataclass(frozen=True)
class ExecutionConfig:
    """Configuration for the test-execution backend (local or container)."""

    executor: str = DEFAULT_EXECUTOR
    container_image: str = DEFAULT_CONTAINER_IMAGE
    container_network: str = DEFAULT_CONTAINER_NETWORK
    container_cpus: float = DEFAULT_CONTAINER_CPUS
    container_memory: str = DEFAULT_CONTAINER_MEMORY
    container_pids_limit: int = DEFAULT_CONTAINER_PIDS_LIMIT
    container_user: str = DEFAULT_CONTAINER_USER
    container_tmpfs_size_mb: int = DEFAULT_CONTAINER_TMPFS_SIZE_MB
    container_mount_repo_src: bool = True
    container_env: dict[str, str] = field(default_factory=dict)


_PROGRESS_LEVEL_ORDER = {"basic": 1, "detailed": 2, "trace": 3}
_PROGRESS_PRINT_LOCK = threading.Lock()

ADAPTIVE_ONLINE_REWARD_SCOPE_LEGACY = "helper_local_v1"
ADAPTIVE_BUDGET_REWARD_SCOPE_LEGACY = "budget_local_v1"
ADAPTIVE_ONLINE_REWARD_SCOPE = "helper_credit_v2"
ADAPTIVE_BUDGET_REWARD_SCOPE = "budget_credit_v2"
ADAPTIVE_LOCAL_REWARD_SUCCESS_HINT = 0.25
ADAPTIVE_LOCAL_REWARD_PROGRESS_HINT_SCALE = 0.5
ADAPTIVE_LOCAL_REWARD_STAGNATION_PENALTY_SCALE = 0.5
ADAPTIVE_LOCAL_REWARD_HELPER_MATERIALIZATION_BONUS_SCALE = 0.25
ADAPTIVE_DELAYED_RESCUE_HORIZON_ATTEMPTS = 2
ADAPTIVE_LOCAL_REWARD_DELAYED_RESCUE_BONUS_BASE = 0.5
ADAPTIVE_LOCAL_REWARD_DELAYED_RESCUE_HORIZON_TWO_FACTOR = 0.5
ADAPTIVE_LOCAL_REWARD_DELAYED_RESCUE_CONTEXT_MULTIPLIER = 1.25
ADAPTIVE_LOCAL_REWARD_DELAYED_RESCUE_BONUS_MAX = 0.75


@dataclass(frozen=True)
class AdaptivePolicyLocalAccounting:
    """Token and latency accounting for a single adaptive-policy attempt."""

    llm_call_count: int = 0
    tokens_prompt: int = 0
    tokens_completion: int = 0
    tokens_total: int = 0
    wall_sec: float = 0.0


EMPTY_ADAPTIVE_POLICY_LOCAL_ACCOUNTING = AdaptivePolicyLocalAccounting()

ADAPTIVE_BUDGET_CONTROL_CHANGE_KEYS = (
    "propose_budget",
    "max_tokens",
    "helper_allowance",
    "stage_a_requests",
    "stage_a_chars",
    "retry_history_budget",
    "compaction_allowance",
    "response_mode",
)


@dataclass(frozen=True)
class AdaptiveBudgetControlProfile:
    """Budget-control knobs produced by the budget-policy tier multiplier."""

    propose_budget_multiplier: float = 1.0
    propose_budget_sec: float = 0.0
    openai_max_tokens: int = 0
    helper_max_calls: int = 0
    stage_a_max_requests: int = 1
    stage_a_max_chars: int = 1
    retry_history_max_chars: int = 0
    context_compaction_max_calls: int = 0
    response_mode: str = ""


# ---------------------------------------------------------------------------
# P1.1 — Rule-based traceback informativeness pre-gate
# ---------------------------------------------------------------------------

_EXCEPTION_TYPE_SPECIFICITY: dict[str, float] = {
    # Tier A: Always diagnostic (score >= 0.8)
    "SyntaxError": 1.0,
    "IndentationError": 1.0,
    "TabError": 1.0,
    "NameError": 0.9,
    "UnboundLocalError": 0.9,
    "ModuleNotFoundError": 0.9,
    "FileNotFoundError": 0.9,
    "ImportError": 0.85,
    "ZeroDivisionError": 0.8,
    # Tier B: Usually diagnostic (score 0.6-0.8)
    "KeyError": 0.7,
    "IndexError": 0.7,
    "AttributeError": 0.7,
    "StopIteration": 0.6,
    "OverflowError": 0.5,
    # Tier C: Context-dependent (score 0.3-0.5)
    "TypeError": 0.4,
    "RecursionError": 0.35,  # Need to see what's recursing
    "ValueError": 0.3,
    # Tier D: Low informativeness (score <= 0.1)
    "AssertionError": 0.1,
    "TimeoutError": 0.05,
    "RuntimeError": 0.05,
    "CancelledError": 0.05,
    "InvalidStateError": 0.05,
}
_EXCEPTION_TYPE_SPECIFICITY_DEFAULT = 0.2

_DIAGNOSTIC_MESSAGE_PATTERNS: list[re.Pattern[str]] = [
    re.compile(r"name\s+'[^']+'\s+is\s+not\s+defined", re.IGNORECASE),
    re.compile(r"No\s+module\s+named", re.IGNORECASE),
    re.compile(r"invalid\s+syntax", re.IGNORECASE),
    re.compile(r"unexpected\s+indent", re.IGNORECASE),
    re.compile(r"unexpected\s+EOF", re.IGNORECASE),
    re.compile(r"list\s+index\s+out\s+of\s+range", re.IGNORECASE),
    re.compile(r"has\s+no\s+attribute\s+'[^']+'", re.IGNORECASE),
    re.compile(r"division\s+by\s+zero", re.IGNORECASE),
    re.compile(r"not\s+subscriptable", re.IGNORECASE),
    re.compile(r"object\s+is\s+not\s+callable", re.IGNORECASE),
    re.compile(r"cannot\s+import\s+name", re.IGNORECASE),
    re.compile(r"No\s+such\s+file\s+or\s+directory", re.IGNORECASE),
]

_ASSERTION_VALUE_PATTERN = re.compile(
    r"(?:==|!=|>=|<=|>|<|assert|expected|got|actual)\s*\S", re.IGNORECASE
)

_STDOUT_PYTEST_ERROR_RE = re.compile(r"^E\s+(\w+(?:\.\w+)*):\s*(.*)", re.MULTILINE)
_STDOUT_TRACEBACK_ERROR_RE = re.compile(r"^(\w+(?:Error|Exception|Warning)):\s*(.*)", re.MULTILINE)
_PYTEST_SUMMARY_COUNT_RE = re.compile(r"(\d+)\s+(failed|error|errors)\b", re.IGNORECASE)
_EVAL_ONLY_CASE_ARTIFACTS = ("case.json", "meta.json", "oracle.patch")


class PreGateResult(NamedTuple):
    """Result of the traceback-informativeness pre-gate check."""

    should_skip: bool
    score: float
    exception_type: str
    reason: str
    f_type_specificity: float
    f_message_diagnostic: float
    f_crash_in_user_code: float
    f_assertion_quality: float
    f_frame_depth: float


@dataclass(frozen=True)
class AdaptiveProgressState:
    """Multi-signal progress assessment for adaptive conditions."""

    made: bool
    reason: str
    score: float
    component_signature: float
    component_new_evidence: float
    component_pytest_fail_reduction: float
    component_stage_advance: float
    component_verifier_score: float
    component_scope_alignment: float
    pytest_before_failed_total: int | None
    pytest_after_failed_total: int | None
    stage_before: str
    stage_after: str
    stage_before_ord: int
    stage_after_ord: int


@dataclass(frozen=True)
class AdaptiveOnlineRewardBreakdown:
    """Decomposed reward components for an online-policy update."""

    reward_scope: str
    reward: float
    reward_success_hint: float
    reward_progress_hint: float
    reward_progress_score: float
    reward_token_penalty: float
    reward_latency_penalty: float
    reward_stagnation_penalty: float
    reward_helper_materialization_bonus: float
    reward_delayed_rescue_bonus: float
    reward_credit_resolved_attempt: int
    reward_credit_horizon_attempts: int
    reward_credit_resolution_reason: str


@dataclass
class PendingAdaptiveOnlineCredit:
    """Deferred credit awaiting resolution for an online-policy decision."""

    decision: PolicyDecision
    run_id: str
    case_id: str
    condition_value: str
    attempt: int
    note: str
    success: bool
    attempt_metadata: dict[str, Any]
    patch_meta: dict[str, Any]
    record_ref: dict[str, Any] | None
    online_policy_mode_for_attempt: str
    online_policy_algo_for_attempt: str
    online_policy_eligible: bool
    online_policy_override_reason: str
    online_policy_feature_scaling_profile: str
    online_policy_features_raw: dict[str, float]
    online_policy_features_scaled: dict[str, float]
    helper_decision_reason: str
    normalized_adaptive_online_policy_mode: str
    adaptive_no_progress_streak_current: int
    reward_lambda_tokens: float
    reward_mu_latency: float
    reward_token_scale: float
    reward_latency_scale: float
    reward_eta_progress: float
    reward_kappa_stagnation: float
    local_accounting: AdaptivePolicyLocalAccounting
    override_reason: str
    behavior_action: str
    context_materialized: bool
    helper_outcome: str
    progress_state: AdaptiveProgressState
    failure_signature_repeat: bool
    case_difficulty: str
    case_snapshot_dependency: str


@dataclass(frozen=True)
class PromptBudgetResult:
    """Result of applying per-section and total-char prompt budgets."""

    prompt: str
    used_chars: int
    overflow_chars: int
    dropped_sections: list[str]
    section_status: dict[str, str] = field(default_factory=dict)
