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

from __future__ import annotations

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

from .clarification_items.dispositions import (
    USER_INPUT_BLOCKS,
    clarification_disposition,
    incorporated_clarification_ids,
    row_blocks_progress,
)
from .exact_coverage import ExactCoverageError, calculate_exact_coverage
from .technical_verification import TechnicalVerificationError, technical_verification_facts


EVALUATION_CRITERIA = (
    "requirement-fit",
    "architecture-fit",
    "change-locality",
    "implementation-complexity",
    "correctness-risk",
    "reversibility",
    "verification-cost",
    "rollout-cost",
)
MIN_CRITERION_VALUE = 1
MAX_CRITERION_VALUE = 5
MIN_FEASIBLE_VOTES = 2
MAX_RANKED_OPTIONS = 3
# 분석자 한 명이 낼 수 있는 원시 후보(`rankedOptions` + `candidateAudit`) 수.
# 저작 계약(`report_synthesis_packet`)이 같은 값을 작성자에게 말한다 — 리터럴로
# 두던 동안 계약은 `proposedBy` 를 `string` 으로만 적었고, 리드가 세 이름을
# 쉼표로 이어 쓰라고 지시해 한 라운드를 버렸다(2026-09-03 실측).
MAX_RAW_CANDIDATES_PER_ANALYSER = 3
CANDIDATE_COMPARISON_ROUTING = "pending-direction-selection"
NO_VALID_OPTIONS_ROUTING = "blocked"

_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 MIN_CRITERION_VALUE <= weight <= MAX_CRITERION_VALUE
        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 MIN_CRITERION_VALUE <= score <= MAX_CRITERION_VALUE
        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 MIN_CRITERION_VALUE <= score <= MAX_CRITERION_VALUE
        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 < MIN_FEASIBLE_VOTES:
        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")
    copied = _copied_votes(votes)
    for first, second in copied:
        errors.append(
            f"{option_id} feasibilityVotes for {first} and {second} are identical; "
            "each vote carries that analyser's own rationale and counterevidence"
        )
    return (
        all_participated
        and feasible >= MIN_FEASIBLE_VOTES
        and not option.get("safetyBlockers")
        and not option.get("unresolvedFeasibilityFacts")
        and not copied
    )


def _copied_votes(votes: Sequence[object]) -> list[tuple[str, str]]:
    """Pairs of analysers whose votes share one rationale and counterevidence.

    A vote is that analyser's own finding. The writer synthesizes the row from
    each result, and a run (2026-09-04, dev-10626) shipped three votes whose
    sentences matched to the letter — one worker's text copied under the other
    two names — so the votes said nothing a single vote did not. `uncertain`
    votes are exempt: two analysers that never evaluated a candidate say so in
    the same words legitimately.
    """
    seen: dict[tuple[str, str], str] = {}
    copied: list[tuple[str, str]] = []
    for vote in votes:
        if not isinstance(vote, Mapping) or vote.get("verdict") == "uncertain":
            continue
        key = (
            str(vote.get("rationale") or "").strip(),
            str(vote.get("counterevidence") or "").strip(),
        )
        if not any(key):
            continue
        worker = str(vote.get("worker") or "?")
        first = seen.setdefault(key, worker)
        if first != worker:
            copied.append((first, worker))
    return copied


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) > MAX_RANKED_OPTIONS:
        errors.append(
            f"rankedOptions must display at most {MAX_RANKED_OPTIONS} 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 not in {NO_VALID_OPTIONS_ROUTING, "technical-verification"}:
            errors.append("routing must be blocked only when no valid options exist")
        return
    if routing in {NO_VALID_OPTIONS_ROUTING, "technical-verification"}:
        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")


@dataclass(frozen=True)
class VoteGap:
    """전원 투표만 모자란 후보 하나와, 표를 받아야 할 분석자."""

    option_id: str
    missing: tuple[str, ...]
    feasible_votes: int


def vote_gaps(
    selection: Mapping[str, object],
    participating_analysers: Sequence[str],
) -> list[VoteGap]:
    """표만 채우면 살아날 후보 — 유효성 규칙을 거꾸로 읽는다.

    1라운드의 설계자들은 병렬로 돌아 서로의 후보를 보지 못한다. 그래서 자기가
    낸 후보에만 표를 남기고, 병합된 집합에는 분석자마다 다른 구멍이 생긴다.
    `_validate_option_feasibility` 의 전원 투표 조항은 그 상태를 조립 시점에
    거절할 뿐 메우지 못한다 — 실측(2026-09-10, dev-10629-4): 설계자 3명 로스터에서
    IO-001·IO-002·IO-003 이 각각 `feasible` 2표를 받고도 빠진 분석자가 하나씩
    달라 전부 탈락했고, 그 run 은 후보 0건으로 차단됐다.

    여기서 세는 것은 **표만 모자란** 후보다. `safetyBlockers` 나
    `unresolvedFeasibilityFacts` 가 있거나 베낀 표가 있으면 표를 더 받아도
    유효해지지 않으므로 제외한다. 남은 표를 다 받아도 `feasible` 이
    `MIN_FEASIBLE_VOTES` 에 못 미치는 후보도 제외한다 — 부쳐 봐야 결과가
    같다.
    """
    roster = list(dict.fromkeys(str(name) for name in participating_analysers))
    gaps: list[VoteGap] = []
    for candidate in (
        row
        for key in ("rankedOptions", "candidateAudit")
        for row in (selection.get(key) or ())
        if isinstance(row, Mapping)
    ):
        if candidate.get("safetyBlockers") or candidate.get(
            "unresolvedFeasibilityFacts"
        ):
            continue
        votes = candidate.get("feasibilityVotes") or ()
        if _copied_votes(votes):
            continue
        voted = [str(vote.get("worker")) for vote in votes if isinstance(vote, Mapping)]
        if len(voted) != len(set(voted)):
            continue
        missing = tuple(name for name in roster if name not in set(voted))
        if not missing or set(voted) - set(roster):
            continue
        feasible = sum(
            isinstance(vote, Mapping) and vote.get("verdict") == "feasible"
            for vote in votes
        )
        if feasible + len(missing) < MIN_FEASIBLE_VOTES:
            continue
        gaps.append(
            VoteGap(
                option_id=str(candidate.get("id") or "?"),
                missing=missing,
                feasible_votes=feasible,
            )
        )
    return gaps


def validate_blocked_answer_channel(
    report_data: Mapping[str, object],
) -> list[str]:
    """사실별 해결 방식과 사용자 질문 연결을 검사하며 차단 상태는 보존한다.

    구형 보고서의 분류 없는 사실은 사용자 결정으로 추정하지 않는다. 읽기
    스키마는 수용하지만 재발행 전에 작성자가 분류를 보완해야 한다.
    답변의 진행 차단 판정은 공통 처분 규칙을 그대로 사용한다.
    """
    selection = report_data.get("implementationOptionSelection")
    if not isinstance(selection, Mapping):
        return []
    if selection.get("routing") not in {NO_VALID_OPTIONS_ROUTING, "technical-verification"}:
        return []
    if selection.get("routing") == "technical-verification":
        try:
            technical_verification_facts(report_data)
        except TechnicalVerificationError as exc:
            return [str(exc)]
    incorporated = incorporated_clarification_ids(report_data)
    answer_channels = {
        row.get("id")
        for row in (report_data.get("clarificationItems") or ())
        if isinstance(row, Mapping)
        and str(row.get("status") or "").strip().lower() != "obsolete"
        and (
            row.get("blocks") in USER_INPUT_BLOCKS
            or not row_blocks_progress(
                str(row.get("status") or ""), clarification_disposition(row),
                incorporated=row.get("id") in incorporated,
            )
        )
    }
    errors: list[str] = []
    for candidate in (
        row for key in ("rankedOptions", "candidateAudit")
        for row in (selection.get(key) or ()) if isinstance(row, Mapping)
    ):
        for index, fact in enumerate(candidate.get("unresolvedFeasibilityFacts") or ()):
            label = f"{candidate.get('id')} unresolvedFeasibilityFacts[{index}]"
            if not isinstance(fact, Mapping):
                errors.append(f"{label}: expected a fact object with resolutionKind")
                continue
            label += f" ({fact.get('fact')})"
            kind = fact.get("resolutionKind")
            if kind == "technical-verification":
                continue
            if kind != "user-decision":
                errors.append(f"{label}: classification required — set resolutionKind to "
                              "user-decision or technical-verification; preserve the fact "
                              "and evidence, then reassemble; do not infer a user question")
                continue
            refs = fact.get("clarificationRefs")
            if not isinstance(refs, list) or not refs or any(
                not isinstance(ref, str) or ref not in answer_channels for ref in refs
            ):
                errors.append(f"{label}: clarificationRefs {refs!r} must link each required "
                              "user decision to an existing blocking or answered clarification; "
                              "use `okstra approval-decision open --ledger <approvalDecisionsPath>` "
                              "only for a missing question, preserve recorded answers, then reassemble")
    return errors


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 > MAX_RAW_CANDIDATES_PER_ANALYSER for count in counts.values()):
        errors.append("each analyser may submit at most three raw candidates")
    if len(candidate_ids) > len(analyser_set) * MAX_RAW_CANDIDATES_PER_ANALYSER:
        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 ()
    )[:MAX_RANKED_OPTIONS]
    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") != CANDIDATE_COMPARISON_ROUTING:
            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
