"""OutcomeVector — multi-criteria utility for a run.

Utility formula (weighted geometric mean with hard zero-effect gate):

    utility = mask × exp(Σ w_i × log(max(x_i, floor)))

where ``x_i`` are the seven inputs in ``components`` (correctness, effect,
safety, feedback, cost_score, latency_score, reversibility), ``w_i`` are
the weights from ``_meta/evolution/config/evolution.yaml`` (arithmetic-sum =
1.0), and ``mask`` is 0.02 when ``zero_effect or blocked``, else 1.0.

Why geometric mean, not arithmetic? Arithmetic lets one strong subscore mask
a catastrophic failure in another dimension. A run scoring 0.99 correctness
× 0.0 safety should have utility ≈ 0, not ≈ 0.7. Geometric mean gives each
subscore a veto: any near-zero dimension drags the aggregate toward 0.
That's the right property for multi-criteria operational judgment.

Why not logistic? All inputs are already in [0, 1] — logistic normalization
would be redundant and less interpretable.

``input_floor`` (0.01) is the minimum we consider a subscore to be. Without
it, ``log(0)`` blows up; with it, a truly zero dimension contributes
``w_i × log(0.01) = -4.6 × w_i``, which is enough to push utility very low
while still ranking correctly against other zero-dimension runs.

Every subscore + the p50 baselines used for cost/latency normalization is
persisted in ``components_json`` so utility can be recomputed retroactively
when weights change — or debugged if one value looks off.
"""

from __future__ import annotations

import logging
import math
from datetime import UTC, datetime
from typing import Any

from agntspace.evolution.schemas import OutcomeVector

log = logging.getLogger(__name__)


# ── Default weights (sum to 1.0) ──
# YAML in ``_meta/evolution/config/evolution.yaml`` overrides these; see
# EvolutionService.load_config().
_DEFAULT_WEIGHTS: dict[str, float] = {  # arch:deterministic — fallback weights
    "correctness":   0.30,
    "effect":        0.25,
    "safety":        0.20,
    "feedback":      0.10,
    "cost":          0.08,
    "latency":       0.05,
    "reversibility": 0.02,
}
_ZERO_EFFECT_MASK: float = 0.02
_INPUT_FLOOR: float = 0.01
_FEEDBACK_NEUTRAL_PRIOR: float = 0.5


# ── Reversibility map: from contract action → risk score in [0, 1] ──
# Used as a proxy for blast radius when no explicit risk was recorded.
_REVERSIBILITY_RISK_MAP: dict[str, float] = {  # arch:deterministic — contract→risk proxy
    "pure_read":        0.0,
    "vault_write":      0.3,
    "write_vault":      0.3,
    "modify_wiki":      0.3,
    "create_task":      0.2,
    "modify_project":   0.3,
    "canvas_push":      0.1,
    "send_email":       0.7,
    "send_message":     0.7,
    "delete_vault_file": 0.9,
    "reset_wiki":       0.9,
    "manage_channels":  0.7,
}


# ── Safety-flagged error_type values ──
# arch:deterministic — engine-emitted error_type classifiers (Guardian,
# contract enforcer, PII redactor). These are stable technical identifiers
# tied 1:1 to the modules that emit them; moving to YAML would make the
# safety-score mapping silently skip emitters when a string is edited.
# Users tune safety via Guardian / contract rules, not via this set.
_SAFETY_FLAGGED_ERRORS: frozenset[str] = frozenset({  # arch:deterministic
    "safety_block", "pii_redaction_triggered",
    "guardian_rejected", "contract_violated",
})


def compute_outcome_inputs(
    *,
    mutations: dict[str, int],
    tool_decisions: list[dict[str, Any]],
    cost: float,
    duration_ms: int,
    feedback_score: float | None,
    reversibility_risk: float | None,
    cost_p50: float,
    latency_p50_ms: float,
    zero_effect: bool,
    blocked: bool,
    error: str | None = None,
    rolled_back: bool = False,
) -> dict[str, float]:
    """Derive the seven subscores from joined decision/cost/mutation data.

    Every output is in [0, 1]. The caller persists this dict as
    ``components_json`` on the OutcomeVector so utility can be recomputed
    if weights change.

    See each inline comment for the derivation rule.
    """
    total_decisions = max(len(tool_decisions), 1)

    # correctness: fraction of tool_decisions with result_status='ok'.
    # Excludes 'blocked' from the denominator (contract is governance,
    # not correctness). Blocked-only runs → correctness=0 by construction
    # because all decisions were blocked so ok_count=0 and denom is small.
    # If there were zero decisions, we defer to the existence of an error.
    if len(tool_decisions) == 0:
        # No decisions recorded — use error existence as a proxy.
        correctness = 0.0 if (error or zero_effect or blocked) else 1.0
    else:
        # Denominator excludes rows that were never executed (blocked).
        considered = [
            d for d in tool_decisions
            if (d.get("result_status") or "") != "blocked"
        ]
        denom = max(len(considered), 1)
        ok_count = sum(
            1 for d in considered
            if (d.get("result_status") or "") == "ok"
        )
        correctness = ok_count / denom

    # effect: 1.0 if real mutations occurred AND nothing rolled back.
    # Writes are NOT inherently good — they only count as effect when
    # verified. A run whose vault writes were all reverted gets effect=0.
    has_mutations = any(int(mutations.get(k, 0)) > 0 for k in (
        "vault_writes", "tool_calls", "graph_triples",
    ))
    effect = 0.0 if (zero_effect or rolled_back or not has_mutations) else 1.0

    # safety: 1 - (safety-flagged decisions / total).
    safety_hits = sum(
        1 for d in tool_decisions
        if (d.get("error_type") or "") in _SAFETY_FLAGGED_ERRORS
    )
    safety = 1.0 - (safety_hits / total_decisions)
    safety = max(0.0, min(1.0, safety))

    # feedback: user approval/rejection signal. None → neutral prior 0.5.
    feedback = (
        _FEEDBACK_NEUTRAL_PRIOR if feedback_score is None
        else max(0.0, min(1.0, feedback_score))
    )

    # cost_score: shaped around p50. Below p50 → >0.5; at 2×p50 → 0.
    # Guards against p50=0 in fresh installations.
    if cost_p50 <= 0:
        cost_score = 1.0 if cost <= 0 else 0.5
    else:
        cost_score = max(0.0, min(1.0, 1.0 - (cost / (2.0 * cost_p50))))

    # latency_score: same shape over duration.
    if latency_p50_ms <= 0:
        latency_score = 1.0 if duration_ms <= 0 else 0.5
    else:
        latency_score = max(0.0, min(1.0, 1.0 - (duration_ms / (2.0 * latency_p50_ms))))

    # reversibility: 1 - risk (higher risk → lower score).
    rev = _FEEDBACK_NEUTRAL_PRIOR if reversibility_risk is None else reversibility_risk
    rev = max(0.0, min(1.0, rev))
    reversibility = 1.0 - rev

    return {
        "correctness":   round(correctness,   4),
        "effect":        round(effect,        4),
        "safety":        round(safety,        4),
        "feedback":      round(feedback,      4),
        "cost":          round(cost_score,    4),
        "latency":       round(latency_score, 4),
        "reversibility": round(reversibility, 4),
        # Persist baselines alongside the subscores for recomputation.
        "_cost_p50":        round(cost_p50, 6),
        "_latency_p50_ms":  round(latency_p50_ms, 2),
        "_cost_raw":        round(cost, 6),
        "_duration_ms":     int(duration_ms),
        "_feedback_raw":    feedback_score if feedback_score is not None else -1.0,
    }


def compute_utility(
    components: dict[str, float],
    *,
    weights: dict[str, float] | None = None,
    zero_effect: bool = False,
    blocked: bool = False,
) -> float:
    """Apply the weighted geometric mean formula + zero-effect gate.

    Returns a utility score in [0, 1]. Components keys that aren't in
    weights are ignored. Missing weight keys contribute 0 weight.

    Deterministic and total — never raises. Negative or NaN inputs are
    floored to ``_INPUT_FLOOR``.
    """
    w = weights or _DEFAULT_WEIGHTS
    mask = _ZERO_EFFECT_MASK if (zero_effect or blocked) else 1.0

    total_weight = sum(w.get(k, 0.0) for k in _DEFAULT_WEIGHTS)
    if total_weight <= 0:
        return 0.0

    log_sum = 0.0
    for key, weight in w.items():
        if weight <= 0 or not isinstance(key, str):
            continue
        x = components.get(key, 0.0)
        try:
            x = float(x)
        except (TypeError, ValueError):
            x = 0.0
        if not math.isfinite(x):
            x = 0.0
        x = max(x, _INPUT_FLOOR)
        x = min(x, 1.0)
        log_sum += weight * math.log(x)

    utility = mask * math.exp(log_sum)
    # Clamp to [0, 1] — rounding can drift one bit past 1.0
    return max(0.0, min(1.0, utility))


def build_outcome(
    run_id: str,
    *,
    mutations: dict[str, int],
    tool_decisions: list[dict[str, Any]],
    cost: float = 0.0,
    duration_ms: int = 0,
    feedback_score: float | None = None,
    reversibility_risk: float | None = None,
    cost_p50: float = 0.0,
    latency_p50_ms: float = 0.0,
    zero_effect: bool = False,
    blocked: bool = False,
    error: str | None = None,
    rolled_back: bool = False,
    weights: dict[str, float] | None = None,
) -> OutcomeVector:
    """One-shot builder: derive components + compute utility + return vector.

    This is what ``Agent.invoke_skill`` calls on exit to produce the
    OutcomeVector that gets persisted by the ledger.
    """
    components = compute_outcome_inputs(
        mutations=mutations,
        tool_decisions=tool_decisions,
        cost=cost,
        duration_ms=duration_ms,
        feedback_score=feedback_score,
        reversibility_risk=reversibility_risk,
        cost_p50=cost_p50,
        latency_p50_ms=latency_p50_ms,
        zero_effect=zero_effect,
        blocked=blocked,
        error=error,
        rolled_back=rolled_back,
    )
    utility = compute_utility(
        components,
        weights=weights,
        zero_effect=zero_effect,
        blocked=blocked,
    )
    return OutcomeVector(
        run_id=run_id,
        utility=round(utility, 6),
        zero_effect=bool(zero_effect),
        blocked=bool(blocked),
        components=components,
        computed_at=datetime.now(UTC).strftime("%Y-%m-%dT%H:%M:%S.%fZ"),
    )


def reversibility_from_action(contract_action: str) -> float:
    """Return a risk score in [0, 1] for a contract action, or 0.5 if unknown."""
    return _REVERSIBILITY_RISK_MAP.get(contract_action, 0.5)


def default_weights() -> dict[str, float]:
    """Return a copy of the default weights dict."""
    return dict(_DEFAULT_WEIGHTS)


def load_weights_from_config(config: dict[str, Any] | None) -> dict[str, float]:
    """Read the GLOBAL weights from the ``_meta/evolution/config/evolution.yaml`` shape.

    Expected YAML fragment::

        utility:
          weights:
            correctness:   0.30
            effect:        0.25
            ...

    Missing keys fall back to defaults. Weights are NOT normalized — if
    they don't sum to 1.0 the utility formula still runs but the range
    is not strictly [0, 1]. That is a configuration error the caller
    should surface.

    For PER-SKILL weight overrides, see ``resolve_weights_for_skill``.
    """
    if not config:
        return default_weights()
    utility = config.get("utility") if isinstance(config, dict) else None
    if not isinstance(utility, dict):
        return default_weights()
    weights = utility.get("weights")
    if not isinstance(weights, dict):
        return default_weights()
    merged = default_weights()
    for k, v in weights.items():
        if not isinstance(k, str):
            continue
        try:
            merged[k] = max(0.0, float(v))
        except (TypeError, ValueError):
            continue
    return merged


def resolve_weights_for_skill(
    skill_name: str, config: dict[str, Any] | None,
) -> tuple[dict[str, float], str]:
    """Return the weight profile appropriate for this skill.

    Resolution order (first match wins):
      1. ``utility.per_skill_weights.<skill_name>`` in the config — an
         explicit override for this specific skill.
      2. ``utility.per_skill_weights.<prefix>`` where prefix matches a
         known skill family (e.g. ``memory-*``, ``kb-*``, ``channel-*``).
         Loose substring match on the skill name.
      3. ``utility.weights`` — the global default.

    Returns ``(weights, source)`` where ``source`` is one of:
      - ``"skill_override:<skill_name>"``
      - ``"family_override:<prefix>"``
      - ``"global"``

    Load-bearing property: the seven standard dimension keys always
    appear in the returned dict (missing keys fall through to the global
    defaults). That keeps ``compute_utility`` stable even when a user
    writes a partial per-skill override that only sets safety + effect.

    Rationale: different skills care about different utility dimensions.
    A ``delete_vault_file`` call should weight safety + reversibility far
    more than a ``search_vault`` call. Without per-skill weights, both
    runs get compared on the same yardstick and the selector gets biased
    evidence about safety-critical work.
    """
    global_weights = load_weights_from_config(config)
    if not config or not isinstance(config, dict) or not skill_name:
        return global_weights, "global"

    utility = config.get("utility")
    if not isinstance(utility, dict):
        return global_weights, "global"

    per_skill = utility.get("per_skill_weights")
    if not isinstance(per_skill, dict) or not per_skill:
        return global_weights, "global"

    # Exact match first
    if skill_name in per_skill and isinstance(per_skill[skill_name], dict):
        merged = dict(global_weights)
        for k, v in per_skill[skill_name].items():
            if not isinstance(k, str):
                continue
            try:
                merged[k] = max(0.0, float(v))
            except (TypeError, ValueError):
                continue
        return merged, f"skill_override:{skill_name}"

    # Prefix / substring match (for families like "memory-*", "kb-*").
    # Sort by descending prefix length so "memory-dream-light" matches
    # "memory-dream-" before it falls through to "memory-".
    candidates = sorted(
        (k for k in per_skill if isinstance(k, str) and k.endswith("-")),
        key=len, reverse=True,
    )
    for prefix in candidates:
        if skill_name.startswith(prefix) and isinstance(per_skill[prefix], dict):
            merged = dict(global_weights)
            for k, v in per_skill[prefix].items():
                if not isinstance(k, str):
                    continue
                try:
                    merged[k] = max(0.0, float(v))
                except (TypeError, ValueError):
                    continue
            return merged, f"family_override:{prefix}"

    return global_weights, "global"
