"""Immutable Stage Q finding-driven validation contracts."""

from __future__ import annotations

from dataclasses import dataclass
from enum import Enum
import re
from types import MappingProxyType
from typing import Mapping, TypeAlias

from agent.models import sanitise_value
from architecture.models import (
    EvidenceReference,
    ExecutionStatus,
    FindingOutcome,
    NormalizedFinding,
)
from validator.capabilities.models import ToolMetadata


VALIDATION_PROTOCOL_VERSION = 1
VALIDATION_RESULT_SCHEMA_VERSION = 1


def _freeze(value: object) -> object:
    if isinstance(value, Mapping):
        return MappingProxyType({str(key): _freeze(item) for key, item in value.items()})
    if isinstance(value, (tuple, list)):
        return tuple(_freeze(item) for item in value)
    return value


def _plain(value: object) -> object:
    if isinstance(value, Mapping):
        return {str(key): _plain(item) for key, item in value.items()}
    if isinstance(value, (tuple, list)):
        return [_plain(item) for item in value]
    return value


_CREDENTIAL_ASSIGNMENT = re.compile(
    r"(?i)\b(api[_-]?key|access[_-]?token|password|secret)\s*[:=]\s*([^\s,;]+)"
)
_BEARER = re.compile(r"(?i)\bBearer\s+[A-Za-z0-9._~+/=-]{8,}")
_OPENAI_KEY = re.compile(r"\bsk-[A-Za-z0-9_-]{8,}\b")


def sanitize_validation_value(value: object) -> object:
    """Return a portable JSON value with host paths and credentials redacted."""

    normalized = sanitise_value(_plain(value))

    def redact(item: object) -> object:
        if isinstance(item, str):
            item = _CREDENTIAL_ASSIGNMENT.sub(r"\1=<redacted-credential>", item)
            item = _BEARER.sub("Bearer <redacted-credential>", item)
            return _OPENAI_KEY.sub("<redacted-credential>", item)
        if isinstance(item, dict):
            return {str(key): redact(child) for key, child in item.items()}
        if isinstance(item, list):
            return [redact(child) for child in item]
        return item

    return redact(normalized)


class ValidationMode(str, Enum):
    LIVE = "LIVE"
    REPLAY = "REPLAY"


class ValidationAssessment(str, Enum):
    SUPPORTED = "SUPPORTED"
    CONTRADICTED = "CONTRADICTED"
    INSUFFICIENT_EVIDENCE = "INSUFFICIENT_EVIDENCE"


class ValidationFeasibility(str, Enum):
    STATIC_SUFFICIENT = "STATIC_SUFFICIENT"
    DYNAMIC_SUPPORTED = "DYNAMIC_SUPPORTED"
    DYNAMIC_UNAVAILABLE = "DYNAMIC_UNAVAILABLE"
    REQUIRED_CAPABILITY_UNAVAILABLE = "REQUIRED_CAPABILITY_UNAVAILABLE"
    UNSUPPORTED_CAPABILITY = "UNSUPPORTED_CAPABILITY"


class ValidationTerminationReason(str, Enum):
    FINISHED = "FINISHED"
    TURN_BUDGET_EXHAUSTED = "TURN_BUDGET_EXHAUSTED"
    TOOL_BUDGET_EXHAUSTED = "TOOL_BUDGET_EXHAUSTED"
    MUTATION_BUDGET_EXHAUSTED = "MUTATION_BUDGET_EXHAUSTED"
    UI_BUDGET_EXHAUSTED = "UI_BUDGET_EXHAUSTED"
    PROVIDER_FAILURE = "PROVIDER_FAILURE"
    AMBIGUOUS_PROVIDER_OUTPUT = "AMBIGUOUS_PROVIDER_OUTPUT"
    POLICY_REJECTED = "POLICY_REJECTED"
    MALFORMED_ACTION_LIMIT = "MALFORMED_ACTION_LIMIT"
    REPEATED_ACTION_LIMIT = "REPEATED_ACTION_LIMIT"
    CAPABILITY_UNAVAILABLE = "CAPABILITY_UNAVAILABLE"
    PRECONDITION_FAILED = "PRECONDITION_FAILED"
    DEVICE_UNAVAILABLE = "DEVICE_UNAVAILABLE"
    TOOL_TIMEOUT = "TOOL_TIMEOUT"
    TOOL_FAILURE = "TOOL_FAILURE"
    EVIDENCE_INSUFFICIENT = "EVIDENCE_INSUFFICIENT"


class ValidationProviderFailure(str, Enum):
    API_ERROR = "API_ERROR"
    AMBIGUOUS_PROVIDER_OUTPUT = "AMBIGUOUS_PROVIDER_OUTPUT"
    BAD_REQUEST = "BAD_REQUEST"
    AUTHENTICATION = "AUTHENTICATION"
    PERMISSION = "PERMISSION"
    MODEL_NOT_FOUND = "MODEL_NOT_FOUND"
    RATE_LIMITED = "RATE_LIMITED"
    CONNECTION = "CONNECTION"
    TIMEOUT = "TIMEOUT"
    SERVER_ERROR = "SERVER_ERROR"
    REFUSAL = "REFUSAL"
    MALFORMED_OUTPUT = "MALFORMED_OUTPUT"
    INVALID_RESPONSE = "INVALID_RESPONSE"


class ValidationPolicyStatus(str, Enum):
    APPROVED = "APPROVED"
    REJECTED = "REJECTED"


class EvidenceAssertionKind(str, Enum):
    MATCH_PRESENT = "MATCH_PRESENT"
    EXPLICIT_DENIAL = "EXPLICIT_DENIAL"
    VALUE_EQUALS = "VALUE_EQUALS"
    STATE_CHANGED = "STATE_CHANGED"
    STATE_UNCHANGED = "STATE_UNCHANGED"
    TEXT_CONTAINS_LITERAL = "TEXT_CONTAINS_LITERAL"
    COLLECTION_CONTAINS_VALUE = "COLLECTION_CONTAINS_VALUE"


class EvidencePolarity(str, Enum):
    SUPPORTS = "SUPPORTS"
    CONTRADICTS = "CONTRADICTS"
    NEUTRAL = "NEUTRAL"
    UNAVAILABLE = "UNAVAILABLE"


@dataclass(frozen=True, slots=True)
class ValidationBudgets:
    max_provider_turns: int = 12
    max_weighted_tool_cost: int = 16
    max_provider_retries: int = 1
    max_identical_consecutive_actions: int = 2
    max_malformed_actions: int = 2
    max_policy_rejections: int = 2
    max_device_mutations: int = 4
    max_ui_actions: int = 4
    max_context_bytes: int = 24_576
    max_action_bytes: int = 16_384

    def __post_init__(self) -> None:
        positive = (
            self.max_provider_turns,
            self.max_weighted_tool_cost,
            self.max_identical_consecutive_actions,
            self.max_malformed_actions,
            self.max_policy_rejections,
            self.max_device_mutations,
            self.max_ui_actions,
            self.max_context_bytes,
            self.max_action_bytes,
        )
        if any(type(value) is not int or value <= 0 for value in positive):
            raise ValueError("Validation budget limits must be positive integers.")
        if (
            type(self.max_provider_retries) is not int
            or not 0 <= self.max_provider_retries <= self.max_provider_turns
        ):
            raise ValueError(
                "Provider retries must be non-negative and no greater than turns."
            )

    def to_dict(self) -> dict[str, int]:
        return {
            "max_provider_turns": self.max_provider_turns,
            "max_weighted_tool_cost": self.max_weighted_tool_cost,
            "max_provider_retries": self.max_provider_retries,
            "max_identical_consecutive_actions": self.max_identical_consecutive_actions,
            "max_malformed_actions": self.max_malformed_actions,
            "max_policy_rejections": self.max_policy_rejections,
            "max_device_mutations": self.max_device_mutations,
            "max_ui_actions": self.max_ui_actions,
            "max_context_bytes": self.max_context_bytes,
            "max_action_bytes": self.max_action_bytes,
        }


@dataclass(frozen=True, slots=True)
class EvidenceAssertion:
    kind: EvidenceAssertionKind
    observation_id: str
    field: str | None = None
    expected: str | None = None
    comparison_observation_id: str | None = None

    def __post_init__(self) -> None:
        if not self.observation_id.strip():
            raise ValueError("Evidence assertion observation_id must not be empty.")
        if self.field is not None and len(self.field) > 200:
            raise ValueError("Evidence assertion field is too long.")
        if self.expected is not None and len(self.expected) > 1_024:
            raise ValueError("Evidence assertion expected value is too long.")

    def to_dict(self) -> dict[str, object]:
        return {
            "kind": self.kind.value,
            "observation_id": self.observation_id,
            "field": self.field,
            "expected": self.expected,
            "comparison_observation_id": self.comparison_observation_id,
        }


def _reason(value: str) -> str:
    if not isinstance(value, str) or not 1 <= len(value) <= 200:
        raise ValueError("reason_summary must contain 1-200 characters.")
    if value != value.strip() or len(value.splitlines()) != 1:
        raise ValueError("reason_summary must be a single trimmed line.")
    return value


@dataclass(frozen=True, slots=True)
class ValidationToolAction:
    tool_name: str
    arguments: Mapping[str, object]
    reason_summary: str

    def __post_init__(self) -> None:
        _reason(self.reason_summary)
        if not self.tool_name.strip():
            raise ValueError("Validation tool name must not be empty.")
        object.__setattr__(self, "arguments", _freeze(self.arguments))

    def to_dict(self) -> dict[str, object]:
        return {
            "action_type": "TOOL",
            "tool_name": self.tool_name,
            "arguments": _plain(self.arguments),
            "reason_summary": self.reason_summary,
        }


@dataclass(frozen=True, slots=True)
class ValidationAssessmentAction:
    assessment: ValidationAssessment
    evidence_observation_ids: tuple[str, ...]
    assertions: tuple[EvidenceAssertion, ...]
    summary: str
    reason_summary: str

    def __post_init__(self) -> None:
        _reason(self.reason_summary)
        if not 1 <= len(self.summary) <= 1_000:
            raise ValueError("Assessment summary must contain 1-1000 characters.")
        if len(self.evidence_observation_ids) > 24 or len(self.assertions) > 24:
            raise ValueError("Assessment cites too many observations or assertions.")
        if len(set(self.evidence_observation_ids)) != len(
            self.evidence_observation_ids
        ):
            raise ValueError("Assessment observation IDs must be unique.")

    def to_dict(self) -> dict[str, object]:
        return {
            "action_type": "ASSESS",
            "assessment": self.assessment.value,
            "evidence_observation_ids": list(self.evidence_observation_ids),
            "assertions": [item.to_dict() for item in self.assertions],
            "summary": self.summary,
            "reason_summary": self.reason_summary,
        }


@dataclass(frozen=True, slots=True)
class FinishValidationAction:
    summary: str
    reason_summary: str

    def __post_init__(self) -> None:
        _reason(self.reason_summary)
        if len(self.summary) > 500:
            raise ValueError("Finish summary must be at most 500 characters.")

    def to_dict(self) -> dict[str, object]:
        return {
            "action_type": "FINISH",
            "summary": self.summary,
            "reason_summary": self.reason_summary,
        }


ValidationAction: TypeAlias = (
    ValidationToolAction | ValidationAssessmentAction | FinishValidationAction
)


@dataclass(frozen=True, slots=True)
class ValidationObservation:
    observation_id: str
    source: str
    originating_tool: str | None
    success: bool
    execution_status: str
    summary: str
    data: Mapping[str, object]
    evidence_refs: tuple[EvidenceReference, ...] = ()
    truncated: bool = False
    limitations: tuple[str, ...] = ()
    failure_reason: str | None = None

    def __post_init__(self) -> None:
        if not self.observation_id or not self.source or not self.summary:
            raise ValueError("Validation observation identity and summary are required.")
        object.__setattr__(self, "data", _freeze(self.data))

    def to_dict(self) -> dict[str, object]:
        return sanitize_validation_value(
            {
                "observation_id": self.observation_id,
                "source": self.source,
                "originating_tool": self.originating_tool,
                "success": self.success,
                "execution_status": self.execution_status,
                "summary": self.summary,
                "data": _plain(self.data),
                "evidence_refs": [item.to_dict() for item in self.evidence_refs],
                "truncated": self.truncated,
                "limitations": list(self.limitations),
                "failure_reason": self.failure_reason,
            }
        )  # type: ignore[return-value]


@dataclass(frozen=True, slots=True)
class CapabilityView:
    available_tools: tuple[ToolMetadata, ...]
    unavailable: Mapping[str, str]
    feasibility: ValidationFeasibility

    def __post_init__(self) -> None:
        object.__setattr__(self, "unavailable", MappingProxyType(dict(self.unavailable)))

    @property
    def tool_names(self) -> tuple[str, ...]:
        return tuple(item.tool_id for item in self.available_tools)

    def to_dict(self) -> dict[str, object]:
        return {
            "available_tools": [item.to_dict() for item in self.available_tools],
            "unavailable": dict(self.unavailable),
            "feasibility": self.feasibility.value,
        }


@dataclass(frozen=True, slots=True)
class ValidationContext:
    threat_model: str
    application: Mapping[str, object]
    target_finding: Mapping[str, object]
    supporting_evidence: tuple[ValidationObservation, ...]
    observations: tuple[ValidationObservation, ...]
    assessments: tuple[Mapping[str, object], ...]
    capability_view: CapabilityView
    remaining_provider_turns: int
    remaining_weighted_tool_cost: int
    remaining_mutations: int
    remaining_ui_actions: int
    protocol_version: int = VALIDATION_PROTOCOL_VERSION
    workspace_guidance: Mapping[str, object] | None = None
    activity_experiment: Mapping[str, object] | None = None

    def __post_init__(self) -> None:
        object.__setattr__(self, "workspace_guidance", _freeze(self.workspace_guidance))
        object.__setattr__(self, "activity_experiment", _freeze(self.activity_experiment))
        object.__setattr__(self, "application", _freeze(self.application))
        object.__setattr__(self, "target_finding", _freeze(self.target_finding))
        object.__setattr__(
            self,
            "assessments",
            tuple(_freeze(item) for item in self.assessments),
        )

    def to_dict(self) -> dict[str, object]:
        return sanitize_validation_value(
            {
                **({"workspace_guidance": _plain(self.workspace_guidance)} if self.workspace_guidance is not None else {}),
                **({"activity_experiment": _plain(self.activity_experiment)} if self.activity_experiment is not None else {}),
                "protocol_version": self.protocol_version,
                "threat_model": self.threat_model,
                "application": _plain(self.application),
                "target_finding": _plain(self.target_finding),
                "supporting_evidence": [item.to_dict() for item in self.supporting_evidence],
                "observations": [item.to_dict() for item in self.observations],
                "assessments": [_plain(item) for item in self.assessments],
                "capability_view": self.capability_view.to_dict(),
                "remaining_provider_turns": self.remaining_provider_turns,
                "remaining_weighted_tool_cost": self.remaining_weighted_tool_cost,
                "remaining_mutations": self.remaining_mutations,
                "remaining_ui_actions": self.remaining_ui_actions,
            }
        )  # type: ignore[return-value]


@dataclass(frozen=True, slots=True)
class ValidationPolicyDecision:
    status: ValidationPolicyStatus
    message: str
    termination_reason: ValidationTerminationReason | None = None

    @property
    def approved(self) -> bool:
        return self.status is ValidationPolicyStatus.APPROVED

    def to_dict(self) -> dict[str, object]:
        return {
            "status": self.status.value,
            "message": self.message,
            "termination_reason": (
                None if self.termination_reason is None else self.termination_reason.value
            ),
        }


@dataclass(frozen=True, slots=True)
class ValidationBudgetSnapshot:
    provider_turns: int
    weighted_tool_cost: int
    device_mutations: int
    ui_actions: int

    def to_dict(self) -> dict[str, int]:
        return {
            "provider_turns": self.provider_turns,
            "weighted_tool_cost": self.weighted_tool_cost,
            "device_mutations": self.device_mutations,
            "ui_actions": self.ui_actions,
        }


@dataclass(frozen=True, slots=True)
class ValidationStep:
    turn_number: int
    action: Mapping[str, object] | None
    policy_decision: ValidationPolicyDecision | None
    observation_id: str | None
    message: str
    budget_before: ValidationBudgetSnapshot
    budget_after: ValidationBudgetSnapshot
    failure: str | None = None

    def __post_init__(self) -> None:
        if self.action is not None:
            object.__setattr__(self, "action", _freeze(self.action))

    def to_dict(self) -> dict[str, object]:
        return sanitize_validation_value(
            {
                "turn_number": self.turn_number,
                "action": None if self.action is None else _plain(self.action),
                "policy_decision": (
                    None if self.policy_decision is None else self.policy_decision.to_dict()
                ),
                "observation_id": self.observation_id,
                "message": self.message,
                "budget_before": self.budget_before.to_dict(),
                "budget_after": self.budget_after.to_dict(),
                "failure": self.failure,
            }
        )  # type: ignore[return-value]


@dataclass(frozen=True, slots=True)
class PredicateResult:
    predicate_id: str
    kind: str
    polarity: EvidencePolarity
    decisive: bool
    adequate: bool
    observation_ids: tuple[str, ...]
    description: str

    def to_dict(self) -> dict[str, object]:
        return {
            "predicate_id": self.predicate_id,
            "kind": self.kind,
            "polarity": self.polarity.value,
            "decisive": self.decisive,
            "adequate": self.adequate,
            "observation_ids": list(self.observation_ids),
            "description": self.description,
        }


@dataclass(frozen=True, slots=True)
class ValidationMetrics:
    provider_turns: int = 0
    provider_retries: int = 0
    provider_failures: int = 0
    tool_calls: int = 0
    weighted_tool_cost: int = 0
    read_only_tool_calls: int = 0
    read_only_tool_cost: int = 0
    mutating_tool_calls: int = 0
    mutating_tool_cost: int = 0
    ui_operations: int = 0
    policy_rejections: int = 0
    malformed_actions: int = 0
    timeouts: int = 0
    input_tokens: int = 0
    output_tokens: int = 0
    latency_ms: int = 0
    api_cost_usd: str | None = None

    def to_dict(self) -> dict[str, object]:
        return {
            field: getattr(self, field)
            for field in self.__dataclass_fields__
        }


@dataclass(frozen=True, slots=True)
class ValidationResult:
    finding: NormalizedFinding
    canonical_finding_id: str
    outcome: FindingOutcome
    feasibility: ValidationFeasibility
    termination_reason: ValidationTerminationReason
    termination_detail: str
    observations: tuple[ValidationObservation, ...]
    steps: tuple[ValidationStep, ...]
    assessment: ValidationAssessmentAction | None
    predicates: tuple[PredicateResult, ...]
    capability_view: CapabilityView
    budgets: ValidationBudgets
    metrics: ValidationMetrics
    provider_metadata: Mapping[str, object]
    limitations: tuple[str, ...] = ()
    alias_of: str | None = None
    final_context: ValidationContext | None = None
    schema_version: int = VALIDATION_RESULT_SCHEMA_VERSION

    def __post_init__(self) -> None:
        object.__setattr__(
            self, "provider_metadata", _freeze(self.provider_metadata)
        )
        if self.outcome.finding_id != self.finding.speculative_finding.finding_id:
            raise ValueError("Validation outcome finding ID does not match target.")

    def to_dict(self) -> dict[str, object]:
        return sanitize_validation_value(
            {
                "schema_version": self.schema_version,
                "finding": self.finding.to_dict(),
                "canonical_finding_id": self.canonical_finding_id,
                "alias_of": self.alias_of,
                "outcome": self.outcome.to_dict(),
                "feasibility": self.feasibility.value,
                "termination_reason": self.termination_reason.value,
                "termination_detail": self.termination_detail,
                "observations": [item.to_dict() for item in self.observations],
                "steps": [item.to_dict() for item in self.steps],
                "provider_assessment": (
                    None if self.assessment is None else self.assessment.to_dict()
                ),
                "predicates": [item.to_dict() for item in self.predicates],
                "capability_view": self.capability_view.to_dict(),
                "configured_budgets": self.budgets.to_dict(),
                "metrics": self.metrics.to_dict(),
                "provider_metadata": _plain(self.provider_metadata),
                "limitations": list(self.limitations),
            }
        )  # type: ignore[return-value]


@dataclass(frozen=True, slots=True)
class ValidationBatchResult:
    results: tuple[ValidationResult, ...]
    metrics: Mapping[str, object]
    canonical_runs: int
    alias_count: int

    def __post_init__(self) -> None:
        object.__setattr__(self, "metrics", MappingProxyType(dict(self.metrics)))

    def to_dict(self) -> dict[str, object]:
        return {
            "schema_version": VALIDATION_RESULT_SCHEMA_VERSION,
            "canonical_runs": self.canonical_runs,
            "alias_count": self.alias_count,
            "results": [item.to_dict() for item in self.results],
            "metrics": dict(self.metrics),
        }
