"""Implementation-option deduplication, ranking, and report semantics."""

from __future__ import annotations

import hashlib
import json
import re
from collections import Counter
from collections.abc import Mapping, Sequence
from typing import Any

from .exact_coverage import ExactCoverageError, calculate_exact_coverage


EVALUATION_CRITERIA = (
    "requirement-fit",
    "architecture-fit",
    "change-locality",
    "implementation-complexity",
    "correctness-risk",
    "reversibility",
    "verification-cost",
    "rollout-cost",
)

_FINGERPRINT_FIELDS = (
    "goal",
    "coreMechanism",
    "architectureBoundaries",
    "expectedChangeAreas",
)
_CITATION_RE = re.compile(r"(?:[\w./-]+\.\w+:\d+|§\s*\d+)")


def _normalized_text(value: object) -> str:
    return " ".join(str(value).split()).casefold()


def _normalized_fingerprint_value(value: object) -> object:
    if isinstance(value, str):
        return _normalized_text(value)
    if isinstance(value, Sequence) and not isinstance(value, (str, bytes)):
        normalized = [_normalized_fingerprint_value(item) for item in value]
        return sorted(normalized, key=lambda item: json.dumps(item, sort_keys=True))
    if isinstance(value, Mapping):
        return {
            str(key): _normalized_fingerprint_value(item)
            for key, item in sorted(value.items(), key=lambda pair: str(pair[0]))
        }
    return value


def candidate_fingerprint(candidate: Mapping[str, object]) -> str:
    """Hash the direction-level fields that distinguish a candidate."""
    payload = {
        field: _normalized_fingerprint_value(candidate.get(field))
        for field in _FINGERPRINT_FIELDS
    }
    encoded = json.dumps(payload, sort_keys=True, separators=(",", ":")).encode()
    return hashlib.sha256(encoded).hexdigest()


def weighted_score(
    scores: Mapping[str, int | float],
    weights: Mapping[str, int | float],
) -> float:
    """Return the four-decimal weighted mean for one candidate."""
    if not scores or set(scores) != set(weights):
        raise ValueError("scores and weights must name the same non-empty criteria")
    total_weight = sum(weights.values())
    if total_weight <= 0:
        raise ValueError("criterion weights must have a positive sum")
    total = sum(scores[criterion] * weights[criterion] for criterion in scores)
    return round(total / total_weight, 4)


def _criterion_values(rows: object, value_field: str) -> dict[str, Any]:
    if not isinstance(rows, Sequence) or isinstance(rows, (str, bytes)):
        return {}
    return {
        str(row.get("criterion")): row.get(value_field)
        for row in rows
        if isinstance(row, Mapping)
    }


def rank_valid_options(
    options: Sequence[Mapping[str, object]],
    criteria: Sequence[Mapping[str, object]],
) -> tuple[str, ...]:
    """Rank candidates by the fixed descending tie-break contract."""
    weights = _criterion_values(criteria, "weight")

    def sort_key(option: Mapping[str, object]) -> tuple[object, ...]:
        scores = _criterion_values(option.get("criterionScores"), "score")
        total = weighted_score(scores, weights)
        return (
            -scores["requirement-fit"],
            -scores["correctness-risk"],
            -scores["architecture-fit"],
            -total,
            str(option.get("id") or ""),
        )

    return tuple(str(option.get("id")) for option in sorted(options, key=sort_key))


def _validate_criteria(data: Mapping[str, object], errors: list[str]) -> dict[str, Any]:
    criteria = data.get("evaluationCriteria")
    rows = criteria if isinstance(criteria, Sequence) else ()
    names = tuple(
        str(row.get("criterion")) for row in rows if isinstance(row, Mapping)
    )
    if names != EVALUATION_CRITERIA:
        errors.append("evaluationCriteria must contain the fixed eight criteria in order")
    weights = _criterion_values(rows, "weight")
    if any(
        not isinstance(weight, int) or isinstance(weight, bool) or not 1 <= weight <= 5
        for weight in weights.values()
    ):
        errors.append("evaluationCriteria weights must be integers in 1..5")
    return weights


def _coverage_input(option: Mapping[str, object]) -> tuple[dict[str, str], dict[str, tuple[str, ...]]]:
    coverage_rows = option.get("requirementCoverage")
    commitment_rows = option.get("scopeCommitments")
    statuses = {
        str(row.get("requirementId")): str(row.get("status"))
        for row in coverage_rows or ()
        if isinstance(row, Mapping)
    }
    commitments = {
        str(row.get("id")): tuple(str(item) for item in row.get("requirementIds") or ())
        for row in commitment_rows or ()
        if isinstance(row, Mapping)
    }
    return statuses, commitments


def _validate_option_coverage(
    option: Mapping[str, object],
    original_ids: Sequence[str],
    errors: list[str],
    *,
    require_exact: bool,
) -> bool:
    option_id = str(option.get("id") or "?")
    statuses, commitments = _coverage_input(option)
    try:
        result = calculate_exact_coverage(original_ids, statuses, commitments)
    except ExactCoverageError as exc:
        errors.append(f"{option_id} exact coverage cannot be calculated: {exc}")
        return False
    if require_exact and result.verdict != "exact":
        errors.append(
            f"{option_id} recalculated coverage verdict is {result.verdict}, not exact"
        )
    if option.get("coverageSummary") != result.as_report_summary():
        errors.append(f"{option_id} coverageSummary does not match recalculated coverage")
        return False
    return result.verdict == "exact"


def _validate_option_scores(
    option: Mapping[str, object],
    weights: Mapping[str, Any],
    errors: list[str],
) -> bool:
    option_id = str(option.get("id") or "?")
    rows = option.get("criterionScores")
    score_rows = rows if isinstance(rows, Sequence) else ()
    names = tuple(
        str(row.get("criterion")) for row in score_rows if isinstance(row, Mapping)
    )
    if names != EVALUATION_CRITERIA:
        errors.append(f"{option_id} criterionScores must contain the fixed eight criteria")
    scores = _criterion_values(score_rows, "score")
    if any(
        not isinstance(score, int) or isinstance(score, bool) or not 1 <= score <= 5
        for score in scores.values()
    ):
        errors.append(f"{option_id} criterion score values must be integers in 1..5")
    values_valid = all(
        isinstance(score, int)
        and not isinstance(score, bool)
        and 1 <= score <= 5
        for score in scores.values()
    )
    if (
        set(scores) != set(EVALUATION_CRITERIA)
        or set(weights) != set(EVALUATION_CRITERIA)
    ):
        return False
    recalculated = weighted_score(scores, weights)
    weighted_score_valid = option.get("weightedScore") == recalculated
    if not weighted_score_valid:
        errors.append(
            f"{option_id} weightedScore must equal recalculated value {recalculated}"
        )
    return values_valid and weighted_score_valid


def _validate_option_feasibility(
    option: Mapping[str, object],
    participating_analysers: Sequence[str],
    errors: list[str],
    *,
    require_valid: bool,
) -> bool:
    option_id = str(option.get("id") or "?")
    votes = option.get("feasibilityVotes") or ()
    workers = [
        str(vote.get("worker")) for vote in votes if isinstance(vote, Mapping)
    ]
    all_participated = (
        len(workers) == len(set(workers))
        and set(workers) == set(participating_analysers)
    )
    if require_valid and not all_participated:
        errors.append(f"{option_id} must be evaluated by every participating analyser")
    feasible = sum(
        isinstance(vote, Mapping) and vote.get("verdict") == "feasible"
        for vote in votes
    )
    if require_valid and feasible < 2:
        errors.append(f"{option_id} must have at least two feasible votes")
    if require_valid and option.get("safetyBlockers"):
        errors.append(f"{option_id} safetyBlockers must be empty")
    if require_valid and option.get("unresolvedFeasibilityFacts"):
        errors.append(f"{option_id} unresolvedFeasibilityFacts must be empty")
    return (
        all_participated
        and feasible >= 2
        and not option.get("safetyBlockers")
        and not option.get("unresolvedFeasibilityFacts")
    )


def _validate_candidate(
    option: Mapping[str, object],
    original_ids: Sequence[str],
    participating_analysers: Sequence[str],
    weights: Mapping[str, Any],
    errors: list[str],
    *,
    require_valid: bool,
) -> bool:
    coverage_valid = _validate_option_coverage(
        option, original_ids, errors, require_exact=require_valid
    )
    score_valid = _validate_option_scores(option, weights, errors)
    feasibility_valid = _validate_option_feasibility(
        option,
        participating_analysers,
        errors,
        require_valid=require_valid,
    )
    return coverage_valid and score_valid and feasibility_valid


def _validate_displayed_options(
    data: Mapping[str, object],
    original_ids: Sequence[str],
    participating_analysers: Sequence[str],
    weights: Mapping[str, Any],
    errors: list[str],
) -> tuple[list[Mapping[str, object]], dict[str, bool]]:
    raw_options = data.get("rankedOptions")
    options = [
        option for option in (raw_options or ()) if isinstance(option, Mapping)
    ]
    if len(options) > 3:
        errors.append("rankedOptions must display at most three valid options")
    validity = {
        str(option.get("id")): _validate_candidate(
            option,
            original_ids,
            participating_analysers,
            weights,
            errors,
            require_valid=True,
        )
        for option in options
    }
    return options, validity


def _validate_option_count_and_routing(
    data: Mapping[str, object],
    options: Sequence[Mapping[str, object]],
    valid_candidates: Sequence[Mapping[str, object]],
    errors: list[str],
) -> None:
    recommended = data.get("recommendedOptionId")
    routing = data.get("routing")
    if not valid_candidates:
        if recommended is not None:
            errors.append("recommendedOptionId must be null when no valid options exist")
        if routing != "blocked":
            errors.append("routing must be blocked only when no valid options exist")
        return
    if routing == "blocked":
        errors.append("routing may be blocked only when no valid options exist")
    if not options:
        errors.append("recommendedOptionId must name the first ranked option")
        return
    if recommended != options[0].get("id"):
        errors.append("recommendedOptionId must name the first ranked option")


def _validate_candidate_audit(
    data: Mapping[str, object],
    options: Sequence[Mapping[str, object]],
    original_ids: Sequence[str],
    participating_analysers: Sequence[str],
    weights: Mapping[str, Any],
    errors: list[str],
) -> tuple[list[Mapping[str, object]], dict[str, bool]]:
    audit = [
        row for row in (data.get("candidateAudit") or ()) if isinstance(row, Mapping)
    ]
    validity = {
        str(row.get("id")): _validate_candidate(
            row,
            original_ids,
            participating_analysers,
            weights,
            errors,
            require_valid=False,
        )
        for row in audit
    }
    _validate_candidate_ids_and_caps(options, audit, participating_analysers, errors)
    _validate_candidate_fingerprints(options, audit, errors)
    return audit, validity


def _validate_candidate_ids_and_caps(
    options: Sequence[Mapping[str, object]],
    audit: Sequence[Mapping[str, object]],
    participating_analysers: Sequence[str],
    errors: list[str],
) -> None:
    candidates = [*options, *audit]
    candidate_ids = [str(candidate.get("id")) for candidate in candidates]
    if len(candidate_ids) != len(set(candidate_ids)):
        errors.append("all IO-NNN candidate ids must be unique")
    counts = Counter(str(candidate.get("proposedBy")) for candidate in candidates)
    analyser_set = set(participating_analysers)
    if any(worker not in analyser_set for worker in counts):
        errors.append("every candidate proposedBy must name a participating analyser")
    if any(count > 3 for count in counts.values()):
        errors.append("each analyser may submit at most three raw candidates")
    if len(candidate_ids) > len(analyser_set) * 3:
        errors.append("total raw candidates exceed analyser count times three")


def _validate_candidate_fingerprints(
    options: Sequence[Mapping[str, object]],
    audit: Sequence[Mapping[str, object]],
    errors: list[str],
) -> None:
    displayed = {str(option.get("id")): option for option in options}
    groups: dict[str, list[Mapping[str, object]]] = {}
    for candidate in [*options, *audit]:
        groups.setdefault(candidate_fingerprint(candidate), []).append(candidate)
    for row in audit:
        target_id = row.get("mergedInto")
        target = displayed.get(str(target_id))
        if row.get("disposition") == "merged":
            if target is None:
                errors.append("candidateAudit mergedInto must target one displayed option")
            elif candidate_fingerprint(row) != candidate_fingerprint(target):
                errors.append("candidateAudit mergedInto target must share its fingerprint")
        elif target_id is not None:
            errors.append("rejected candidateAudit rows must not set mergedInto")
    for group in groups.values():
        if len(group) < 2:
            continue
        targets = [row for row in group if str(row.get("id")) in displayed]
        if len(targets) != 1:
            errors.append("duplicate candidate fingerprint must converge to one displayed option")
            continue
        target_id = targets[0].get("id")
        for row in group:
            if row is targets[0]:
                continue
            if row.get("disposition") != "merged" or row.get("mergedInto") != target_id:
                errors.append("duplicate candidate fingerprint must be merged into its displayed option")


def _validate_complete_ranking(
    data: Mapping[str, object],
    options: Sequence[Mapping[str, object]],
    displayed_validity: Mapping[str, bool],
    audit: Sequence[Mapping[str, object]],
    audit_validity: Mapping[str, bool],
    errors: list[str],
) -> list[Mapping[str, object]]:
    valid = [
        option for option in options if displayed_validity.get(str(option.get("id")))
    ]
    displayed_fingerprints = {candidate_fingerprint(option) for option in options}
    valid.extend(
        row
        for row in audit
        if row.get("disposition") == "rejected"
        and audit_validity.get(str(row.get("id")))
        and candidate_fingerprint(row) not in displayed_fingerprints
    )
    expected = rank_valid_options(valid, data.get("evaluationCriteria") or ())[:3]
    reported = tuple(str(option.get("id")) for option in options)
    if reported != expected:
        errors.append("rankedOptions must contain the top valid candidates in order")
    return valid


def _validate_mode(
    data: Mapping[str, object],
    options: Sequence[Mapping[str, object]],
    audit: Sequence[Mapping[str, object]],
    errors: list[str],
) -> None:
    mode = data.get("mode")
    direction = data.get("preselectedDirection")
    if mode == "candidate-comparison":
        if direction is not None:
            errors.append("candidate-comparison must not set preselectedDirection")
        if options and data.get("routing") != "pending-direction-selection":
            errors.append("candidate-comparison with options must await direction selection")
        return
    if len(options) != 1:
        errors.append("preselected-validation must contain exactly one validated option")
    if audit:
        errors.append("preselected-validation candidateAudit must be empty")
    if not isinstance(direction, Mapping):
        errors.append("preselected-validation requires preselectedDirection")
        return
    if options and direction.get("optionId") != options[0].get("id"):
        errors.append("preselectedDirection optionId must name the validated option")
    if not _CITATION_RE.search(str(direction.get("citation") or "")):
        errors.append("preselectedDirection citation must be citable")
    if data.get("routing") != "implementation-planning":
        errors.append("validated preselected direction must route to implementation-planning")


def validate_implementation_option_selection(
    data: Mapping[str, object],
    original_ids: Sequence[str],
    participating_analysers: Sequence[str],
) -> list[str]:
    """Recompute all selection semantics instead of trusting report summaries."""
    errors: list[str] = []
    declared_ids = tuple(
        (data.get("decisionContext") or {}).get("originalRequirementIds") or ()
    )
    if declared_ids != tuple(original_ids):
        errors.append("decisionContext originalRequirementIds do not match the source ids")
    if len(participating_analysers) != len(set(participating_analysers)):
        errors.append("participating analyser ids must be unique")
    weights = _validate_criteria(data, errors)
    options, displayed_validity = _validate_displayed_options(
        data, original_ids, participating_analysers, weights, errors
    )
    audit, audit_validity = _validate_candidate_audit(
        data,
        options,
        original_ids,
        participating_analysers,
        weights,
        errors,
    )
    valid_candidates = _validate_complete_ranking(
        data, options, displayed_validity, audit, audit_validity, errors
    )
    _validate_option_count_and_routing(data, options, valid_candidates, errors)
    _validate_mode(data, options, audit, errors)
    return errors
