"""Cross-field validation for structured read-only analysis reports."""
from __future__ import annotations

import json
import re
import sys
from dataclasses import dataclass
from pathlib import Path, PurePosixPath

# scripts/ (repo) and python/ (installed under ~/.okstra/lib) are sibling
# source roots, so standalone validator execution must add the one that exists.
_VALIDATORS_DIR = Path(__file__).resolve().parent
for _ssot_dir in (_VALIDATORS_DIR.parent / "scripts", _VALIDATORS_DIR.parent / "python"):
    if _ssot_dir.is_dir() and str(_ssot_dir) not in sys.path:
        sys.path.insert(0, str(_ssot_dir))

from okstra_ctl.analysis_inputs import ANALYSIS_TASK_TYPES
from okstra_ctl.final_report_paths import final_report_data_path
from okstra_ctl.paths import RunRef
from okstra_ctl.report_views import analysis_review_context
from okstra_ctl.user_response import UserResponseError, parse_analysis_review


_ANALYSIS_BLOCKS = {
    "projectAnalysis",
    "featureAnalysis",
    "changeImpactAnalysis",
}
_EVIDENCE_COLLECTIONS = {
    "project-analysis": (
        "components",
        "dependencies",
        "entryPoints",
        "dataStores",
        "externalSystems",
        "featureIndex",
    ),
    "feature-analysis": (
        "flows",
        "domainRules",
        "stateChanges",
        "externalInteractions",
    ),
    "change-impact-analysis": (
        "preservedBehaviors",
        "impactItems",
        "dependencyBlastRadius",
        "testImpact",
        "operationalImpact",
    ),
}
_ANALYSIS_PARENT_KEYS = {
    "project-analysis": "projectAnalysis",
    "feature-analysis": "featureAnalysis",
    "change-impact-analysis": "changeImpactAnalysis",
}


def _is_report_writer_role(role: str) -> bool:
    return "reportwriter" in re.sub(r"[^a-z0-9]", "", role.lower())


_FINAL_ANALYSIS_REPORT_RE = re.compile(
    r"^final-report-(?P<task_type>project-analysis|feature-analysis|"
    r"change-impact-analysis)-(?P<seq>\d{3})\.md$"
)


@dataclass(frozen=True)
class AnalysisValidationResult:
    errors: tuple[str, ...]

    @property
    def ok(self) -> bool:
        return not self.errors


@dataclass(frozen=True)
class AnalysisTaskIdentity:
    task_key: str
    project_id: str
    task_group: str
    task_id: str


@dataclass(frozen=True)
class SourceAnalysisAuthority:
    task_root: Path
    source_report: Path
    source_seq: str
    current_identity: AnalysisTaskIdentity


def validate_analysis_snapshot(
    data: dict, run_manifest: dict, errors: list[str]
) -> None:
    common = data.get("analysisCommon") or {}
    if common.get("sourceCommit") != run_manifest.get("analysisSourceCommit"):
        errors.append(
            "analysisCommon.sourceCommit must equal run-manifest.analysisSourceCommit"
        )
    if common.get("evidenceInputs") != run_manifest.get("evidenceInputs"):
        errors.append(
            "analysisCommon.evidenceInputs must equal run-manifest.evidenceInputs "
            "in the same order and with the same values"
        )
    scope = common.get("scope") or {}
    if scope.get("resolvedTarget") != run_manifest.get("analysisTarget"):
        errors.append(
            "analysisCommon.scope.resolvedTarget must equal run-manifest.analysisTarget"
        )


def _normalized_project_path(path: object) -> PurePosixPath | None:
    if not isinstance(path, str):
        return None
    raw = path.strip().rstrip("/")
    if not raw or raw == "." or raw.startswith("/"):
        return None
    if any(part in {"", ".", ".."} for part in raw.split("/")):
        return None
    normalized = PurePosixPath(raw)
    if normalized.is_absolute():
        return None
    return normalized


def _path_is_included(path: object, included_paths: list[object]) -> bool:
    candidate = _normalized_project_path(path)
    if candidate is None:
        return False
    for raw in included_paths:
        included = _normalized_project_path(raw)
        if included is not None and (
            candidate == included or included in candidate.parents
        ):
            return True
    return False


def _validate_included_paths(included_paths: list[object], errors: list[str]) -> None:
    for index, path in enumerate(included_paths):
        if _normalized_project_path(path) is None:
            errors.append(
                f"analysisCommon.scope.includedPaths[{index}] must be a non-empty "
                "project-relative path without `.` or `..` segments"
            )


def _validate_evidence_rows(
    parent: dict,
    collection: str,
    prefix: str,
    included_paths: list[object],
    errors: list[str],
) -> None:
    for row_index, row in enumerate(parent.get(collection) or []):
        evidence = row.get("currentCodeEvidence") if isinstance(row, dict) else None
        if not evidence:
            errors.append(
                f"{prefix}.{collection}[{row_index}].currentCodeEvidence must "
                "contain current-code path and line evidence; sourceReportRefs "
                "alone are insufficient"
            )
            continue
        for evidence_index, item in enumerate(evidence):
            path = item.get("path") if isinstance(item, dict) else None
            if not _path_is_included(path, included_paths):
                errors.append(
                    f"{prefix}.{collection}[{row_index}].currentCodeEvidence"
                    f"[{evidence_index}].path must be a normalized path inside "
                    "analysisCommon.scope.includedPaths"
                )


def _validate_all_current_code_evidence(
    task_type: str, data: dict, errors: list[str]
) -> None:
    common = data.get("analysisCommon") or {}
    included_paths = ((common.get("scope") or {}).get("includedPaths") or [])
    _validate_included_paths(included_paths, errors)
    _validate_evidence_rows(
        common, "confirmedFacts", "analysisCommon", included_paths, errors
    )
    parent_key = _ANALYSIS_PARENT_KEYS[task_type]
    parent = data.get(parent_key) or {}
    for collection in _EVIDENCE_COLLECTIONS[task_type]:
        _validate_evidence_rows(
            parent, collection, parent_key, included_paths, errors
        )
    feature = (((common.get("scope") or {}).get("resolvedTarget") or {}).get(
        "feature"
    ) or {})
    if feature:
        _validate_evidence_rows(
            {"feature": [feature]},
            "feature",
            "analysisCommon.scope.resolvedTarget",
            included_paths,
            errors,
        )


def _validate_project_semantics(data: dict, errors: list[str]) -> None:
    common = data.get("analysisCommon") or {}
    included = ((common.get("scope") or {}).get("includedPaths") or [])
    analysis = data.get("projectAnalysis") or {}
    for row in analysis.get("featureIndex") or []:
        if not isinstance(row, dict):
            continue
        entry = row.get("representativeEntryPoint") or {}
        if not _path_is_included(entry.get("path"), included):
            errors.append(
                f"projectAnalysis.featureIndex `{row.get('id') or '?'}` representative "
                "entry point must be inside analysisCommon.scope.includedPaths"
            )
    _validate_component_references(analysis, errors)


def _validate_component_references(analysis: dict, errors: list[str]) -> None:
    """Every componentId a row names has to be a component this report declares.

    The reader clicks these — they render as links to the component's row — so
    a reference to a component that was never listed is a dead anchor, and one
    to a component that exists under a different id is worse: it reads as a
    relationship the analysis never found.
    """
    declared = {
        row.get("id")
        for row in analysis.get("components") or []
        if isinstance(row, dict) and row.get("id")
    }
    if not declared:
        return

    def check(component_id: object, where: str) -> None:
        if isinstance(component_id, str) and component_id and component_id not in declared:
            errors.append(f"{where} names component `{component_id}`, which is not declared")

    for row in analysis.get("internalInterfaces") or []:
        if not isinstance(row, dict):
            continue
        where = f"projectAnalysis.internalInterfaces `{row.get('id') or '?'}`"
        check(row.get("ownerComponentId"), where)
        for consumer in row.get("consumers") or []:
            check(consumer, where)
    for row in analysis.get("workflows") or []:
        if not isinstance(row, dict):
            continue
        for step in row.get("steps") or []:
            if isinstance(step, dict):
                check(step.get("componentId"), f"projectAnalysis.workflows `{row.get('id') or '?'}`")


def _validate_feature_semantics(data: dict, errors: list[str]) -> None:
    analysis = data.get("featureAnalysis") or {}
    common_target = (((data.get("analysisCommon") or {}).get("scope") or {}).get(
        "resolvedTarget"
    ) or {})
    feature_target = analysis.get("target") or {}
    for field in ("inputMode", "requestedValue"):
        if feature_target.get(field) != common_target.get(field):
            errors.append(
                f"featureAnalysis.target.{field} must match "
                f"analysisCommon.scope.resolvedTarget.{field}"
            )
    resolved_feature = common_target.get("feature") or {}
    if common_target.get("inputMode") == "feature-index" and (
        feature_target.get("featureId") != resolved_feature.get("id")
    ):
        errors.append(
            "featureAnalysis.target.featureId must match the resolved feature id"
        )


def _validate_change_impact_semantics(data: dict, errors: list[str]) -> None:
    analysis = data.get("changeImpactAnalysis") or {}
    allowed = {"constraint", "unknown"}
    for index, row in enumerate(analysis.get("planningInputs") or []):
        if not isinstance(row, dict):
            continue
        for field in sorted(set(row) - allowed):
            errors.append(
                f"changeImpactAnalysis.planningInputs[{index}] forbids `{field}`; "
                "planning inputs may contain constraints and unknowns only"
            )


def _required_worker_roles(
    run_manifest: dict, errors: list[str]
) -> tuple[str, ...]:
    contract = run_manifest.get("teamContract")
    entries = (
        contract.get("requiredWorkerRoles")
        if isinstance(contract, dict)
        else None
    )
    if not isinstance(entries, list) or not entries:
        errors.append(
            "run-manifest.teamContract.requiredWorkerRoles must be a non-empty "
            "array of worker contract objects"
        )
        return ()
    roles: list[str] = []
    for index, entry in enumerate(entries):
        role = entry.get("role") if isinstance(entry, dict) else None
        if (
            not isinstance(role, str)
            or not role.strip()
            or role != role.strip()
        ):
            errors.append(
                "run-manifest.teamContract.requiredWorkerRoles"
                f"[{index}].role must be an exact non-empty string"
            )
            return ()
        if role in roles:
            errors.append(
                "run-manifest.teamContract.requiredWorkerRoles must not contain "
                f"duplicate role `{role}`"
            )
            return ()
        roles.append(role)
    return tuple(roles)


def _required_worker_rows(data: dict, required_roles: tuple[str, ...]) -> list[dict]:
    required = set(required_roles)
    return [
        row
        for row in data.get("executionStatus") or []
        if isinstance(row, dict) and row.get("role") in required
    ]


def _validate_execution_status_roles(
    data: dict, required_roles: tuple[str, ...], errors: list[str]
) -> bool:
    """Validate exact labels and report whether verdict matching is unambiguous."""
    actual_roles = {
        str(row.get("role") or "")
        for row in data.get("executionStatus") or []
        if isinstance(row, dict)
    }
    expected_by_fold = {role.casefold(): role for role in required_roles}
    verdict_roles_unambiguous = True
    for actual_role in sorted(actual_roles):
        expected_role = expected_by_fold.get(actual_role.casefold())
        if expected_role is None or actual_role == expected_role:
            continue
        errors.append(
            f"unknown executionStatus analysis role `{actual_role}`; "
            f"expected exact role `{expected_role}`"
        )
        verdict_roles_unambiguous = False
    for required_role in required_roles:
        if required_role in actual_roles:
            continue
        errors.append(f"missing required analysis role `{required_role}`")
    return verdict_roles_unambiguous


def _scope_confirmation_status(
    run_manifest: dict, errors: list[str]
) -> str | None:
    snapshot = run_manifest.get("analysisScopeConfirmation")
    if not isinstance(snapshot, dict):
        errors.append(
            "run-manifest.analysisScopeConfirmation must be a pre-dispatch "
            "brief confirmation snapshot"
        )
        return None
    task_brief_path = snapshot.get("taskBriefPath")
    status = snapshot.get("status")
    brief_sha256 = snapshot.get("briefSha256")
    valid = True
    if (
        not isinstance(task_brief_path, str)
        or not task_brief_path
        or task_brief_path != run_manifest.get("taskBriefPath")
    ):
        errors.append(
            "run-manifest.analysisScopeConfirmation.taskBriefPath must equal "
            "run-manifest.taskBriefPath"
        )
        valid = False
    if status not in {"complete", "partial", "pending", "skipped"}:
        errors.append(
            "run-manifest.analysisScopeConfirmation.status must be one of "
            "complete, partial, pending, skipped"
        )
        valid = False
    if not isinstance(brief_sha256, str) or re.fullmatch(
        r"[0-9a-f]{64}", brief_sha256
    ) is None:
        errors.append(
            "run-manifest.analysisScopeConfirmation.briefSha256 must be a "
            "lowercase SHA-256 hex digest"
        )
        valid = False
    return status if valid else None


def _validate_analysis_verdict(
    data: dict,
    required_roles: tuple[str, ...],
    reporter_confirmation: str | None,
    errors: list[str],
) -> None:
    common = data.get("analysisCommon") or {}
    scope = common.get("scope") or {}
    worker_rows = _required_worker_rows(data, required_roles)
    worker_blocked = not any(row.get("status") == "completed" for row in worker_rows)
    scope_blocked = reporter_confirmation != "complete"
    unresolved_review = any(
        isinstance(row, dict) and row.get("outcome") == "still-unresolved"
        for row in common.get("analysisReviewResolution") or []
    )
    partial = unresolved_review or bool(scope.get("unscannedPaths")) or any(
        isinstance(row, dict) and row.get("affectsVerdict") is True
        for row in common.get("unknowns") or []
    )
    expected = "blocked" if worker_blocked or scope_blocked else (
        "analysis-partial" if partial else "analysis-complete"
    )
    actual = str((data.get("finalVerdict") or {}).get("verdictToken") or "")
    if actual == expected:
        return
    reasons = []
    if worker_blocked:
        reasons.append("required analysis workers produced zero completed results")
    if scope_blocked:
        reasons.append("brief frontmatter reporter-confirmations is not complete")
    if partial:
        reasons.append(
            "a still-unresolved review resolution, unscannedPaths, or an "
            "affectsVerdict unknown remains"
        )
    detail = "; ".join(reasons) or "the analysis scope is fully scanned"
    errors.append(f"verdict must be `{expected}` because {detail}")


def _validate_scope_before_dispatch(
    data: dict,
    required_roles: tuple[str, ...],
    reporter_confirmation: str | None,
    errors: list[str],
) -> None:
    if reporter_confirmation == "complete":
        return
    dispatched = any(
        row.get("status") != "not-run"
        for row in _required_worker_rows(data, required_roles)
    )
    if dispatched:
        errors.append(
            "required analysis workers were dispatched before "
            "reporter-confirmations: complete was recorded"
        )


def validate_analysis_semantics(
    task_type: str,
    data: dict,
    run_manifest: dict,
    reporter_confirmation: str | None,
    errors: list[str],
) -> None:
    required_roles = _required_worker_roles(run_manifest, errors)
    analysis_worker_roles = tuple(
        role for role in required_roles if not _is_report_writer_role(role)
    )
    verdict_roles_unambiguous = _validate_execution_status_roles(
        data, analysis_worker_roles, errors
    )
    _validate_all_current_code_evidence(task_type, data, errors)
    if task_type == "project-analysis":
        _validate_project_semantics(data, errors)
    elif task_type == "feature-analysis":
        _validate_feature_semantics(data, errors)
    elif task_type == "change-impact-analysis":
        _validate_change_impact_semantics(data, errors)
    _validate_scope_before_dispatch(
        data, analysis_worker_roles, reporter_confirmation, errors
    )
    if verdict_roles_unambiguous:
        _validate_analysis_verdict(
            data, analysis_worker_roles, reporter_confirmation, errors
        )


def _actual_analysis_task_type(
    data: dict, run_manifest: dict, errors: list[str]
) -> str:
    header = data.get("header")
    reported = str(header.get("taskType") or "") if isinstance(header, dict) else ""
    actual = str(run_manifest.get("taskType") or "")
    if actual and reported != actual:
        errors.append(
            "header.taskType must equal run-manifest.taskType "
            f"(`{reported}` != `{actual}`)"
        )
    present_blocks = sorted(_ANALYSIS_BLOCKS.intersection(data))
    if actual not in ANALYSIS_TASK_TYPES and present_blocks:
        errors.append(
            "non-analysis run must not contain analysis-only blocks: "
            + ", ".join(present_blocks)
        )
    return actual


def _analysis_task_root(
    report_path: Path, project_root: Path, errors: list[str]
) -> Path | None:
    try:
        run_ref = RunRef.from_report_path(report_path)
    except ValueError:
        errors.append(
            "current analysis report path cannot resolve its task root"
        )
        return None
    if run_ref.stage is not None:
        errors.append(
            "current analysis report path cannot resolve its task root"
        )
        return None
    if report_path.parent.resolve() != run_ref.reports_dir.resolve():
        errors.append(
            "current analysis report path cannot resolve its task root"
        )
        return None
    task_root = run_ref.task_root.resolve()
    try:
        task_root.relative_to(project_root.resolve())
    except ValueError:
        errors.append("current analysis report task root escapes the project root")
        return None
    return task_root


def _contained_source_report(
    source_report: str,
    task_root: Path,
    errors: list[str],
) -> Path | None:
    if not source_report:
        errors.append("ANALYSIS REVIEW source-report is required")
        return None
    relative = Path(source_report)
    if relative.is_absolute():
        errors.append("ANALYSIS REVIEW source-report must be task-relative")
        return None
    if ".." in PurePosixPath(source_report).parts:
        errors.append("ANALYSIS REVIEW source-report must not contain `..`")
        return None
    resolved = (task_root / relative).resolve()
    try:
        resolved.relative_to(task_root)
    except ValueError:
        errors.append("ANALYSIS REVIEW source-report escapes the task root")
        return None
    return resolved


def _current_analysis_task_identity(
    data: dict,
    task_root: Path,
    run_manifest_task_key: str,
    errors: list[str],
) -> AnalysisTaskIdentity | None:
    header = data.get("header")
    frontmatter = data.get("frontmatter")
    if not isinstance(header, dict) or not isinstance(frontmatter, dict):
        errors.append("current analysis data must define its task identity")
        return None
    task_key = header.get("taskKey")
    project_id = frontmatter.get("projectId")
    task_group = frontmatter.get("taskGroup")
    task_id = frontmatter.get("taskId")
    values = (task_key, project_id, task_group, task_id, run_manifest_task_key)
    if any(not isinstance(value, str) or not value for value in values):
        errors.append("current analysis task identity must be complete")
        return None
    identity = AnalysisTaskIdentity(
        task_key=task_key,
        project_id=project_id,
        task_group=task_group,
        task_id=task_id,
    )
    if identity.task_key != run_manifest_task_key:
        errors.append("current header.taskKey must equal run-manifest.taskKey")
    if identity.task_key != (
        f"{identity.project_id}:{identity.task_group}:{identity.task_id}"
    ):
        errors.append("current report task identity fields must equal header.taskKey")
    if (identity.task_group, identity.task_id) != (
        task_root.parent.name,
        task_root.name,
    ):
        errors.append("current report task identity must match its task root")
    return identity


def _validate_source_analysis_task_identity(
    source_header: dict,
    source_frontmatter: dict,
    current: AnalysisTaskIdentity,
    errors: list[str],
) -> None:
    expected_fields = (
        (source_header, "taskKey", current.task_key, "header.taskKey"),
        (
            source_frontmatter,
            "projectId",
            current.project_id,
            "frontmatter.projectId",
        ),
        (
            source_frontmatter,
            "taskGroup",
            current.task_group,
            "frontmatter.taskGroup",
        ),
        (source_frontmatter, "taskId", current.task_id, "frontmatter.taskId"),
    )
    for block, key, expected, label in expected_fields:
        if block.get(key) != expected:
            errors.append(
                f"ANALYSIS REVIEW source {label} must match current task identity"
            )


def _validate_source_analysis_lineage(
    task_root: Path,
    source: Path,
    current_match: re.Match[str],
    source_match: re.Match[str],
    data: dict,
    current_task_type: str,
    review_seq: str,
    report_path: Path,
    errors: list[str],
) -> None:
    current_seq = current_match.group("seq")
    source_seq = source_match.group("seq")
    current_common = data.get("analysisCommon")
    if current_match.group("task_type") != current_task_type:
        errors.append(
            "current analysis report filename task type must equal the current task type"
        )
    if not isinstance(current_common, dict) or current_common.get("runSeq") != current_seq:
        errors.append(
            "current analysisCommon.runSeq must match the current report filename"
        )
    expected_parent = RunRef.from_task_root(
        task_root, current_task_type
    ).reports_dir.resolve()
    if source.parent != expected_parent:
        errors.append(
            "ANALYSIS REVIEW source report must belong to the current task and analysis type"
        )
    if source_match.group("task_type") != current_task_type:
        errors.append(
            "ANALYSIS REVIEW source analysis task type must match the current analysis task type"
        )
    if review_seq != source_seq:
        errors.append("ANALYSIS REVIEW review seq must match the source report seq")
    if source == report_path.resolve() or int(source_seq) >= int(current_seq):
        errors.append("ANALYSIS REVIEW source report must predate the current report")


def _source_analysis_authority(
    source_report: str,
    review_seq: str,
    data: dict,
    current_task_type: str,
    current_task_key: str,
    report_path: Path,
    project_root: Path,
    errors: list[str],
) -> SourceAnalysisAuthority | None:
    initial_error_count = len(errors)
    task_root = _analysis_task_root(report_path, project_root, errors)
    if task_root is None:
        return None
    source = _contained_source_report(source_report, task_root, errors)
    if source is None:
        return None
    current_match = _FINAL_ANALYSIS_REPORT_RE.fullmatch(report_path.name)
    source_match = _FINAL_ANALYSIS_REPORT_RE.fullmatch(source.name)
    if current_match is None:
        errors.append("current analysis report filename is invalid")
        return None
    if source_match is None:
        errors.append("ANALYSIS REVIEW source report filename is invalid")
        return None
    current_identity = _current_analysis_task_identity(
        data, task_root, current_task_key, errors
    )
    if current_identity is None:
        return None
    _validate_source_analysis_lineage(
        task_root,
        source,
        current_match,
        source_match,
        data,
        current_task_type,
        review_seq,
        report_path,
        errors,
    )
    if len(errors) != initial_error_count:
        return None
    return SourceAnalysisAuthority(
        task_root=task_root,
        source_report=source,
        source_seq=source_match.group("seq"),
        current_identity=current_identity,
    )


def _load_source_analysis_payload(
    authority: SourceAnalysisAuthority,
    errors: list[str],
) -> dict | None:
    source_data = final_report_data_path(authority.source_report).resolve()
    try:
        source_data.relative_to(authority.task_root)
    except ValueError:
        errors.append("ANALYSIS REVIEW source data escapes the task root")
        return None
    if not authority.source_report.is_file() or not source_data.is_file():
        errors.append("ANALYSIS REVIEW source report has no analysis data")
        return None
    try:
        source_payload = json.loads(source_data.read_text(encoding="utf-8"))
    except (json.JSONDecodeError, OSError):
        errors.append("ANALYSIS REVIEW source report has no analysis data")
        return None
    if not isinstance(source_payload, dict):
        errors.append("ANALYSIS REVIEW source report has no analysis data")
        return None
    blocks = (
        source_payload.get("header"),
        source_payload.get("frontmatter"),
        source_payload.get("analysisCommon"),
    )
    if not all(
        isinstance(block, dict) for block in blocks
    ):
        errors.append("ANALYSIS REVIEW source report has no analysis data")
        return None
    return source_payload


def _source_analysis_payload_matches_authority(
    source_payload: dict,
    authority: SourceAnalysisAuthority,
    current_task_type: str,
    errors: list[str],
) -> bool:
    initial_error_count = len(errors)
    source_header = source_payload["header"]
    source_frontmatter = source_payload["frontmatter"]
    source_common = source_payload["analysisCommon"]
    source_types = (
        source_header.get("taskType"),
        source_frontmatter.get("taskType"),
    )
    _validate_source_analysis_task_identity(
        source_header,
        source_frontmatter,
        authority.current_identity,
        errors,
    )
    if any(source_type not in ANALYSIS_TASK_TYPES for source_type in source_types):
        errors.append(
            "ANALYSIS REVIEW source analysis task type must belong to ANALYSIS_TASK_TYPES"
        )
    if any(source_type != current_task_type for source_type in source_types):
        errors.append(
            "ANALYSIS REVIEW source analysis task type must match the current analysis task type"
        )
    if source_common.get("runSeq") != authority.source_seq:
        errors.append(
            "ANALYSIS REVIEW source analysisCommon.runSeq must match the source report seq"
        )
    return len(errors) == initial_error_count


def _source_analysis_selector_ids(
    source_report: str,
    review_seq: str,
    data: dict,
    current_task_type: str,
    current_task_key: str,
    report_path: Path,
    project_root: Path,
    errors: list[str],
) -> set[str] | None:
    authority = _source_analysis_authority(
        source_report,
        review_seq,
        data,
        current_task_type,
        current_task_key,
        report_path,
        project_root,
        errors,
    )
    if authority is None:
        return None
    source_payload = _load_source_analysis_payload(authority, errors)
    if source_payload is None:
        return None
    if not _source_analysis_payload_matches_authority(
        source_payload, authority, current_task_type, errors
    ):
        return None
    context = analysis_review_context(authority.source_report)
    if context is None:
        errors.append("ANALYSIS REVIEW source report has no analysis data")
        return None
    return set(context.selector_ids)


def validate_analysis_review_resolution(
    data: dict,
    clarification_text: str,
    errors: list[str],
    *,
    report_path: Path | None = None,
    project_root: Path | None = None,
    current_task_type: str = "",
    current_task_key: str = "",
) -> None:
    resolutions = (data.get("analysisCommon") or {}).get(
        "analysisReviewResolution"
    ) or []
    resolution_rows = [row for row in resolutions if isinstance(row, dict)]
    by_id = {str(row.get("affectedId") or ""): row for row in resolution_rows}
    confirmed_ids = {
        str(row.get("id") or "")
        for row in (data.get("analysisCommon") or {}).get("confirmedFacts") or []
        if isinstance(row, dict)
    }
    for affected_id, row in by_id.items():
        if row.get("outcome") == "still-unresolved" and affected_id in confirmed_ids:
            errors.append(
                f"`{affected_id}` remains refuted but is still present in confirmedFacts"
            )
    try:
        review = parse_analysis_review(clarification_text) if clarification_text else None
    except UserResponseError as exc:
        errors.append(f"analysis review input is invalid: {exc}")
        return
    if review is None:
        if resolutions:
            errors.append(
                "analysisReviewResolution must be empty when no prior analysis review exists"
            )
        return
    reviewed_ids = list(review.affected_ids)
    resolution_ids = [str(row.get("affectedId") or "") for row in resolution_rows]
    for affected_id in sorted(set(reviewed_ids)):
        if reviewed_ids.count(affected_id) > 1:
            errors.append(f"ANALYSIS REVIEW has duplicate Affected-IDs `{affected_id}`")
    for affected_id in sorted(set(resolution_ids)):
        if resolution_ids.count(affected_id) > 1:
            errors.append(
                f"analysisReviewResolution has duplicate reviewed id `{affected_id}`"
            )
    reviewed_set = set(reviewed_ids)
    resolution_set = set(resolution_ids)
    if report_path is not None and project_root is not None:
        source_ids = _source_analysis_selector_ids(
            review.source_report,
            review.seq,
            data,
            current_task_type,
            current_task_key,
            report_path,
            project_root,
            errors,
        )
        if source_ids is not None:
            for affected_id in sorted(reviewed_set - source_ids):
                errors.append(
                    f"ANALYSIS REVIEW Affected-ID `{affected_id}` is not present "
                    "in source analysis data"
                )
    for affected_id in sorted(reviewed_set - resolution_set):
        errors.append(
            f"analysisReviewResolution is missing reviewed id `{affected_id}`"
        )
    for affected_id in sorted(resolution_set - reviewed_set):
        errors.append(
            f"analysisReviewResolution has unknown reviewed id `{affected_id}`"
        )


def validate_analysis_report(
    *,
    data: dict,
    report_path: Path,
    project_root: Path,
    run_manifest: dict,
    clarification_text: str,
) -> AnalysisValidationResult:
    errors: list[str] = []
    task_type = _actual_analysis_task_type(data, run_manifest, errors)
    if task_type not in ANALYSIS_TASK_TYPES:
        return AnalysisValidationResult(errors=tuple(errors))
    reporter_confirmation = _scope_confirmation_status(run_manifest, errors)
    validate_analysis_snapshot(data, run_manifest, errors)
    validate_analysis_semantics(
        task_type, data, run_manifest, reporter_confirmation, errors
    )
    validate_analysis_review_resolution(
        data,
        clarification_text,
        errors,
        report_path=report_path,
        project_root=project_root,
        current_task_type=task_type,
        current_task_key=str(run_manifest.get("taskKey") or ""),
    )
    return AnalysisValidationResult(errors=tuple(errors))
