"""Deterministic integrity and warning boundary for Coach V2 output."""

from __future__ import annotations

import hashlib
import json
import re
from dataclasses import dataclass
from types import MappingProxyType
from typing import Final, Iterable, Mapping, TypeGuard, cast

from .nutrition_coaching_proposal import (
    COACH_V2_RESPONSE_KEYS,
    NutritionTargets,
    ValidatedCoachProposal,
    _energy_consistent,
    _targets,
    targets_from_plan,
)
from .nutrition_coaching_copy_integrity import recommendation_claims_match
from .nutrition_coaching_safety import (
    contains_prohibited_nutrition_guidance,
    contains_unsupported_sensitive_fact,
)

_NUMBER: Final = re.compile(r"(?<![\w.])[+-]?\d+(?:[.,]\d+)?")
_DIGEST: Final = re.compile(r"^[a-f0-9]{64}$")
_VALIDATION_CODES: Final = frozenset(
    {
        "invalid_json",
        "invalid_shape",
        "response_contract_mismatch",
        "semantic_validation_failed",
        "accepted",
    }
)
_RULE_PATHS: Final = {
    "response.invalid_json": "$",
    "response.invalid_shape": "$",
    "contract.schema_version_exact": "$.schema_version",
    "contract.customer_key_bounded": "$.customer_key",
    "contract.customer_key_exact": "$.customer_key",
    "contract.revision_binding_digest_exact": "$.revision_binding_digest",
    "contract.recommendation_unit_system_exact": "$.recommendation_unit_system",
    "contract.decision_allowed": "$.decision",
    "contract.confidence_allowed": "$.confidence",
    "content.interpretation_bounded": "$.interpretation",
    "content.customer_draft_bounded": "$.customer_draft",
    "safety.interpretation_prohibited_guidance": "$.interpretation",
    "safety.customer_draft_prohibited_guidance": "$.customer_draft",
    "safety.safety_hold_required": "$.decision",
    "ids.evidence_ids_strict": "$.evidence_ids",
    "ids.next_checkin_focus_ids_strict": "$.next_checkin_focus_ids",
    "ids.evidence_ids_offered": "$.evidence_ids",
    "ids.next_checkin_focus_ids_offered": "$.next_checkin_focus_ids",
    "recommendation.targets_valid": "$.recommendation",
    "recommendation.energy_consistent": "$.recommendation.calories",
    "decision.adjust_changes_targets": "$.recommendation",
    "decision.non_adjust_keeps_targets": "$.recommendation",
    "copy.interpretation_numbers_grounded": "$.interpretation",
    "copy.customer_draft_numbers_grounded": "$.customer_draft",
    "copy.interpretation_recommendation_claims_match": "$.interpretation",
    "copy.customer_draft_recommendation_claims_match": "$.customer_draft",
    "grounding.interpretation_sensitive_fact_supported": "$.interpretation",
    "grounding.customer_draft_sensitive_fact_supported": "$.customer_draft",
}
COACH_PROPOSAL_VALIDATION_RULE_PATHS: Final[Mapping[str, str]] = MappingProxyType(
    _RULE_PATHS
)
_ATTEMPT_DIAGNOSTIC_KEYS: Final = frozenset(
    {
        "phase",
        "rule_code",
        "json_path",
        "response_sha256",
        "audit_sha256",
    }
)


@dataclass(frozen=True, slots=True)
class CoachProposalValidationDiagnostic:
    """A privacy-safe validation result that never retains model content."""

    code: str
    rule_code: str | None
    json_path: str | None
    response_sha256: str
    audit_sha256: str

    def __post_init__(self) -> None:
        accepted = self.code == "accepted"
        if (
            self.code not in _VALIDATION_CODES
            or (accepted and (self.rule_code is not None or self.json_path is not None))
            or (
                not accepted
                and (
                    self.rule_code not in COACH_PROPOSAL_VALIDATION_RULE_PATHS
                    or self.json_path
                    != COACH_PROPOSAL_VALIDATION_RULE_PATHS[self.rule_code]
                )
            )
            or _DIGEST.fullmatch(self.response_sha256) is None
            or _DIGEST.fullmatch(self.audit_sha256) is None
        ):
            raise ValueError("coach proposal validation diagnostic is invalid")

    def as_dict(self) -> dict[str, str | None]:
        return {
            "code": self.code,
            "rule_code": self.rule_code,
            "json_path": self.json_path,
            "response_sha256": self.response_sha256,
            "audit_sha256": self.audit_sha256,
        }


@dataclass(frozen=True, slots=True)
class CoachProposalAttemptDiagnostic:
    """One ordered, content-free initial or correction diagnostic."""

    phase: str
    rule_code: str
    json_path: str
    response_sha256: str
    audit_sha256: str

    def __post_init__(self) -> None:
        if (
            self.phase not in {"initial", "correction"}
            or self.rule_code not in COACH_PROPOSAL_VALIDATION_RULE_PATHS
            or self.json_path != COACH_PROPOSAL_VALIDATION_RULE_PATHS[self.rule_code]
            or _DIGEST.fullmatch(self.response_sha256) is None
            or _DIGEST.fullmatch(self.audit_sha256) is None
        ):
            raise ValueError("coach proposal attempt diagnostic is invalid")

    @classmethod
    def from_validation(
        cls,
        phase: str,
        diagnostic: CoachProposalValidationDiagnostic,
    ) -> CoachProposalAttemptDiagnostic:
        if diagnostic.rule_code is None or diagnostic.json_path is None:
            raise ValueError("accepted validation cannot be retained as a failed attempt")
        return cls(
            phase,
            diagnostic.rule_code,
            diagnostic.json_path,
            diagnostic.response_sha256,
            diagnostic.audit_sha256,
        )

    @classmethod
    def from_dict(cls, value: object) -> CoachProposalAttemptDiagnostic | None:
        if not isinstance(value, dict) or set(value) != _ATTEMPT_DIAGNOSTIC_KEYS:
            return None
        mapping = cast(dict[str, object], value)
        values = tuple(mapping[key] for key in _ATTEMPT_DIAGNOSTIC_KEYS)
        if not all(isinstance(item, str) for item in values):
            return None
        try:
            return cls(
                cast(str, mapping["phase"]),
                cast(str, mapping["rule_code"]),
                cast(str, mapping["json_path"]),
                cast(str, mapping["response_sha256"]),
                cast(str, mapping["audit_sha256"]),
            )
        except ValueError:
            return None

    def as_dict(self) -> dict[str, str]:
        return {
            "phase": self.phase,
            "rule_code": self.rule_code,
            "json_path": self.json_path,
            "response_sha256": self.response_sha256,
            "audit_sha256": self.audit_sha256,
        }


def diagnose_coach_proposal(
    raw_response: str,
    *,
    customer_key: str,
    revision_binding_digest: str,
    evidence_ids: Iterable[str],
    focus_ids: Iterable[str],
    available_text: Iterable[str],
    current_targets: NutritionTargets | Mapping[str, object] | None,
    valid_sample_count: int,
    plan_adherence: float | None,
    average_sleep_hours: float | None,
    deterministic_baseline: str,
    safety_held: bool = False,
) -> tuple[ValidatedCoachProposal | None, CoachProposalValidationDiagnostic]:
    """Validate Coach V2 output and return only allowlisted, hash-based diagnostics."""
    response_bytes = (
        raw_response.encode("utf-8")
        if type(raw_response) is str
        else b"<non-string-model-response>"
    )
    response_sha256 = hashlib.sha256(response_bytes).hexdigest()
    proposal, rule_code = _evaluate_coach_proposal(
        raw_response,
        customer_key=customer_key,
        revision_binding_digest=revision_binding_digest,
        evidence_ids=evidence_ids,
        focus_ids=focus_ids,
        available_text=available_text,
        current_targets=current_targets,
        valid_sample_count=valid_sample_count,
        plan_adherence=plan_adherence,
        average_sleep_hours=average_sleep_hours,
        deterministic_baseline=deterministic_baseline,
        safety_held=safety_held,
    )
    if proposal is not None:
        code = "accepted"
        json_path = None
    else:
        if rule_code is None:
            raise RuntimeError("rejected coach proposal has no validation rule")
        if rule_code == "response.invalid_json":
            code = "invalid_json"
        elif rule_code == "response.invalid_shape":
            code = "invalid_shape"
        elif rule_code == "contract.schema_version_exact":
            code = "response_contract_mismatch"
        else:
            code = "semantic_validation_failed"
        json_path = COACH_PROPOSAL_VALIDATION_RULE_PATHS[rule_code]
    audit_sha256 = hashlib.sha256(
        json.dumps(
            {
                "schema_version": "nutrition-coach-validation-diagnostic-v2",
                "code": code,
                "rule_code": rule_code,
                "json_path": json_path,
                "response_sha256": response_sha256,
            },
            sort_keys=True,
            separators=(",", ":"),
        ).encode("utf-8")
    ).hexdigest()
    return proposal, CoachProposalValidationDiagnostic(
        code,
        rule_code,
        json_path,
        response_sha256,
        audit_sha256,
    )


def validate_coach_proposal(
    raw_response: str,
    *,
    customer_key: str,
    revision_binding_digest: str,
    evidence_ids: Iterable[str],
    focus_ids: Iterable[str],
    available_text: Iterable[str],
    current_targets: NutritionTargets | Mapping[str, object] | None,
    valid_sample_count: int,
    plan_adherence: float | None,
    average_sleep_hours: float | None,
    deterministic_baseline: str,
    safety_held: bool = False,
) -> ValidatedCoachProposal | None:
    """Preserve the public proposal-or-None validation contract."""
    proposal, _rule_code = _evaluate_coach_proposal(
        raw_response,
        customer_key=customer_key,
        revision_binding_digest=revision_binding_digest,
        evidence_ids=evidence_ids,
        focus_ids=focus_ids,
        available_text=available_text,
        current_targets=current_targets,
        valid_sample_count=valid_sample_count,
        plan_adherence=plan_adherence,
        average_sleep_hours=average_sleep_hours,
        deterministic_baseline=deterministic_baseline,
        safety_held=safety_held,
    )
    return proposal


def _evaluate_coach_proposal(
    raw_response: str,
    *,
    customer_key: str,
    revision_binding_digest: str,
    evidence_ids: Iterable[str],
    focus_ids: Iterable[str],
    available_text: Iterable[str],
    current_targets: NutritionTargets | Mapping[str, object] | None,
    valid_sample_count: int,
    plan_adherence: float | None,
    average_sleep_hours: float | None,
    deterministic_baseline: str,
    safety_held: bool = False,
) -> tuple[ValidatedCoachProposal | None, str | None]:
    available_text = tuple(available_text)
    try:
        payload = json.loads(raw_response)
    except (TypeError, json.JSONDecodeError):
        return None, "response.invalid_json"
    if type(payload) is not dict or set(payload) != COACH_V2_RESPONSE_KEYS:
        return None, "response.invalid_shape"
    if payload.get("schema_version") != "nutrition-coach-response-v2":
        return None, "contract.schema_version_exact"
    if not _bounded_text(payload.get("customer_key"), 128):
        return None, "contract.customer_key_bounded"
    if payload.get("customer_key") != customer_key:
        return None, "contract.customer_key_exact"
    if payload.get("revision_binding_digest") != revision_binding_digest:
        return None, "contract.revision_binding_digest_exact"
    if payload.get("recommendation_unit_system") != "kcal_and_grams":
        return None, "contract.recommendation_unit_system_exact"

    decision = payload.get("decision")
    confidence = payload.get("confidence")
    interpretation = payload.get("interpretation")
    customer_draft = payload.get("customer_draft")
    if decision not in {"maintain", "adjust", "observe", "safety_hold"}:
        return None, "contract.decision_allowed"
    if confidence not in {"low", "medium", "high"}:
        return None, "contract.confidence_allowed"
    if not _bounded_text(interpretation, 1200):
        return None, "content.interpretation_bounded"
    if not _bounded_text(customer_draft, 2000):
        return None, "content.customer_draft_bounded"
    if _unsafe(interpretation):
        return None, "safety.interpretation_prohibited_guidance"
    if _unsafe(customer_draft):
        return None, "safety.customer_draft_prohibited_guidance"
    if safety_held and decision != "safety_hold":
        return None, "safety.safety_hold_required"

    used_evidence = _strict_ids(payload.get("evidence_ids"))
    if used_evidence is None:
        return None, "ids.evidence_ids_strict"
    next_focus = _strict_ids(payload.get("next_checkin_focus_ids"))
    if next_focus is None:
        return None, "ids.next_checkin_focus_ids_strict"
    if not set(used_evidence).issubset(evidence_ids):
        return None, "ids.evidence_ids_offered"
    if not set(next_focus).issubset(focus_ids):
        return None, "ids.next_checkin_focus_ids_offered"

    recommendation = _targets(payload.get("recommendation"))
    if recommendation is None:
        return None, "recommendation.targets_valid"
    if not _energy_consistent(recommendation):
        return None, "recommendation.energy_consistent"
    current = (
        current_targets
        if isinstance(current_targets, NutritionTargets)
        else targets_from_plan(current_targets)
    )
    if current is not None:
        changes_targets = recommendation != current
        if decision == "adjust" and not changes_targets:
            return None, "decision.adjust_changes_targets"
        if decision != "adjust" and changes_targets:
            return None, "decision.non_adjust_keeps_targets"

    allowed_numbers = _numbers(available_text)
    allowed_numbers.update(float(value) for value in recommendation.as_dict().values())
    if current is not None:
        allowed_numbers.update(float(value) for value in current.as_dict().values())
    if not _numbers((interpretation,)).issubset(allowed_numbers):
        return None, "copy.interpretation_numbers_grounded"
    if not _numbers((customer_draft,)).issubset(allowed_numbers):
        return None, "copy.customer_draft_numbers_grounded"
    if not recommendation_claims_match(interpretation, recommendation.as_dict()):
        return None, "copy.interpretation_recommendation_claims_match"
    if not recommendation_claims_match(customer_draft, recommendation.as_dict()):
        return None, "copy.customer_draft_recommendation_claims_match"
    if contains_unsupported_sensitive_fact(interpretation, available_text):
        return None, "grounding.interpretation_sensitive_fact_supported"
    if contains_unsupported_sensitive_fact(customer_draft, available_text):
        return None, "grounding.customer_draft_sensitive_fact_supported"

    warnings: list[str] = []
    if confidence == "low":
        warnings.append("low_confidence")
    if valid_sample_count < 3:
        warnings.append("limited_samples")
    if current is not None and abs(recommendation.calories - current.calories) >= 300:
        warnings.append("large_calorie_delta")
    if current is not None and (
        abs(recommendation.protein_g - current.protein_g) >= 25
        or abs(recommendation.carbs_g - current.carbs_g) >= 50
        or abs(recommendation.fat_g - current.fat_g) >= 15
    ):
        warnings.append("large_macro_delta")
    if decision == "adjust" and plan_adherence is not None and plan_adherence < 0.8:
        warnings.append("low_adherence_adjustment")
    if (
        decision == "adjust"
        and average_sleep_hours is not None
        and average_sleep_hours < 6
    ):
        warnings.append("poor_recovery_adjustment")
    if deterministic_baseline and decision != deterministic_baseline:
        warnings.append("baseline_divergence")
    return ValidatedCoachProposal(
        decision,
        confidence,
        used_evidence,
        interpretation,
        recommendation,
        next_focus,
        customer_draft,
        tuple(warnings),
        _owner_review_notes(
            average_sleep_hours,
            plan_adherence,
            safety_hold=decision == "safety_hold",
        ),
    ), None


def _strict_ids(value: object) -> tuple[str, ...] | None:
    if (
        type(value) is not list
        or not value
        or not all(
            type(item) is str
            and item.strip() == item
            and 1 <= len(item) <= 128
            for item in value
        )
        or len(set(value)) != len(value)
    ):
        return None
    return tuple(cast(list[str], value))


def _bounded_text(value: object, maximum: int) -> TypeGuard[str]:
    return type(value) is str and bool(value.strip()) and len(value) <= maximum


def _unsafe(value: object) -> bool:
    return contains_prohibited_nutrition_guidance(value)


def _numbers(texts: Iterable[str]) -> set[float]:
    result: set[float] = set()
    for text in texts:
        for match in _NUMBER.findall(text):
            try:
                result.add(float(match.replace(",", "")))
            except ValueError:
                continue
    return result


def _owner_review_notes(
    average_sleep_hours: float | None,
    plan_adherence: float | None,
    *,
    safety_hold: bool,
) -> tuple[str, ...]:
    lines: list[str] = []
    if average_sleep_hours is not None and average_sleep_hours <= 6:
        normalized_sleep = float(average_sleep_hours)
        sleep = (
            int(normalized_sleep)
            if normalized_sleep.is_integer()
            else normalized_sleep
        )
        lines.append(f"수면: {sleep}시간")
    if plan_adherence is not None and plan_adherence < 0.8:
        lines.append(f"식사 실행률: {round(plan_adherence * 100)}%")
    if safety_hold:
        lines.append("안전 보류: 소유자 검토 전 코칭 발송 보류")
    if lines:
        lines.append("소유자 검토 전 회복 상태 확인")
    return tuple(lines)
