"""Shared evidence resolution for read-only analysis sidetracks."""
from __future__ import annotations

import json
import re
import subprocess
from dataclasses import dataclass
from datetime import datetime, timezone
from pathlib import Path
from typing import Mapping, Sequence

from .final_report_paths import final_report_data_path
from .paths import task_manifest_file
from .json_boundary import JsonBoundaryError, load_owned_object
from .user_response import (
    UserResponseError,
    load_authoritative_analysis_review,
)


ANALYSIS_TASK_TYPES = (
    "project-analysis",
    "feature-analysis",
    "change-impact-analysis",
)


class AnalysisInputError(ValueError):
    """Raised when a report cannot safely serve as analysis evidence."""


@dataclass(frozen=True)
class AnalysisReportCandidate:
    report_path: Path
    task_key: str
    task_type: str
    created_at: str
    run_seq: str
    source_commit: str
    review_status: str
    feature_index: tuple[object, ...]


@dataclass(frozen=True)
class ResolvedEvidenceInput:
    report_path: Path
    task_key: str
    task_type: str
    run_seq: str
    source_commit: str
    review_status: str
    relation: str
    freshness: str

    def to_dict(self) -> dict[str, str]:
        return {
            "taskKey": self.task_key,
            "taskType": self.task_type,
            "reportPath": str(self.report_path),
            "runSeq": self.run_seq,
            "sourceCommit": self.source_commit,
            "relation": self.relation,
            "freshness": self.freshness,
            "reviewStatus": self.review_status,
        }


_EVIDENCE_RELATION_BY_TYPES = {
    ("feature-analysis", "project-analysis"): "project-context",
    ("change-impact-analysis", "project-analysis"): "project-context",
    ("change-impact-analysis", "feature-analysis"): "feature-baseline",
}
_REPORT_NAME_RE = re.compile(
    r"^final-report-.+-(?P<run_seq>[^-]+)\.data\.json$"
)
_FEATURE_ID_RE = re.compile(r"PF-\d{3}")
_FULL_COMMIT_RE = re.compile(r"[0-9a-f]{40}")
_RUN_SEQ_RE = re.compile(r"[0-9]{3}")


def parse_evidence_paths(
    raw_paths: str, project_root: Path | None = None,
) -> tuple[Path, ...]:
    """Parse a comma-separated evidence selection in its supplied order."""
    root = project_root.resolve() if project_root else None
    paths: list[Path] = []
    for raw_path in raw_paths.split(","):
        value = raw_path.strip()
        if not value:
            continue
        path = Path(value).expanduser()
        if root and not path.is_absolute():
            path = root / path
        paths.append(path.resolve())
    return tuple(paths)


def load_candidate_map(
    project_root: Path, report_paths: Sequence[Path],
) -> dict[Path, AnalysisReportCandidate]:
    """Load canonical metadata for every explicitly selected report."""
    return {
        candidate.report_path: candidate
        for candidate in (
            load_analysis_report_candidate(project_root, report_path)
            for report_path in report_paths
        )
    }


def resolve_analysis_head(analysis_root: Path, task_type: str = "") -> str:
    """Return an analysis worktree's immutable HEAD, or reject an invalid one."""
    if task_type and task_type not in ANALYSIS_TASK_TYPES:
        return ""
    try:
        result = subprocess.run(
            ["git", "-C", str(analysis_root), "rev-parse", "HEAD"],
            check=True,
            capture_output=True,
            text=True,
        )
    except (OSError, subprocess.CalledProcessError) as exc:
        raise AnalysisInputError(
            f"cannot resolve analysis source commit: {analysis_root}"
        ) from exc
    return _validated_commit(result.stdout.strip(), "analysis source commit")


def _resolved_within(path: Path, root: Path, description: str) -> Path:
    resolved = path.resolve()
    try:
        resolved.relative_to(root)
    except ValueError as exc:
        raise AnalysisInputError(f"{description} must be under {root}") from exc
    return resolved


def _required_string(data: Mapping[str, object], key: str, context: str) -> str:
    value = data.get(key)
    if not isinstance(value, str) or not value:
        raise AnalysisInputError(f"data.json {context}.{key} must be a non-empty string")
    return value


def _validated_commit(value: str, field: str) -> str:
    if not _FULL_COMMIT_RE.fullmatch(value):
        raise AnalysisInputError(
            f"{field} must be a 40-character lowercase hexadecimal commit"
        )
    return value


def _validated_created_at(value: str) -> str:
    """The candidate's creation instant in canonical UTC ``...Z`` form.

    What this field must give the caller is one *comparable* value — candidates
    are ordered by it. It is not a spelling contract: the report schema requires
    only a non-empty string, nothing tells a report-writer to emit UTC, and
    `validate-run` accepts a local offset. Demanding `Z` here made this loader
    the only reader that rejected a report every other reader had accepted, and
    the run lost its carry-in candidates without saying why. So normalise.

    An instant with no offset is still refused: guessing its zone would order
    it wrongly against the others.
    """
    try:
        instant = datetime.fromisoformat(value.replace("Z", "+00:00"))
    except ValueError as exc:
        raise AnalysisInputError(
            "data.json header.createdAt must be an ISO-8601 instant "
            "(e.g. 2026-08-05T04:25:00Z or 2026-08-05T04:25:00+09:00)"
        ) from exc
    if instant.tzinfo is None:
        raise AnalysisInputError(
            "data.json header.createdAt carries no UTC offset, so it cannot be "
            "ordered against the other candidates"
        )
    return instant.astimezone(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")


def _report_path_metadata(project_root: Path, report_path: Path) -> tuple[Path, str, str]:
    okstra_root = project_root.resolve() / ".okstra"
    relative = report_path.relative_to(okstra_root)
    parts = relative.parts
    if (
        len(parts) != 7
        or parts[0] != "tasks"
        or parts[3] != "runs"
        or parts[5] != "reports"
    ):
        raise AnalysisInputError("report path is not an analysis run report")
    return report_path.parents[3], parts[4], parts[6]


def _task_key_from_manifest(task_root: Path, okstra_root: Path) -> str:
    manifest_path = _resolved_within(
        task_manifest_file(task_root), okstra_root, "task manifest path"
    )
    try:
        manifest = load_owned_object(manifest_path, artifact="task manifest")
    except JsonBoundaryError as exc:
        raise AnalysisInputError(f"cannot read task manifest: {manifest_path}") from exc
    if not isinstance(manifest, dict):
        raise AnalysisInputError("task manifest must contain an object")
    return _required_string(manifest, "taskKey", "task manifest")


def load_analysis_report_candidate(
    project_root: Path, report_path: Path
) -> AnalysisReportCandidate:
    """Load one report only after validating its path and canonical metadata."""
    root = project_root.resolve()
    okstra_root = root / ".okstra"
    report = _resolved_within(report_path, okstra_root, "report path")
    if not report.is_file():
        raise AnalysisInputError(f"report does not exist: {report}")
    task_root, path_task_type, filename = _report_path_metadata(root, report)
    filename_match = _REPORT_NAME_RE.fullmatch(filename)
    if not filename_match:
        raise AnalysisInputError("report filename is not a final report")
    data_path = _resolved_within(final_report_data_path(report), okstra_root, "data.json path")
    try:
        data = load_owned_object(data_path, artifact="analysis report record")
    except JsonBoundaryError as exc:
        raise AnalysisInputError(f"cannot read data.json: {data_path}") from exc
    if not isinstance(data, dict):
        raise AnalysisInputError("data.json must contain an object")
    header = data.get("header")
    common = data.get("analysisCommon")
    if not isinstance(header, dict) or not isinstance(common, dict):
        raise AnalysisInputError("data.json requires header and analysisCommon objects")
    task_key = _required_string(header, "taskKey", "header")
    task_type = _required_string(header, "taskType", "header")
    created_at = _validated_created_at(
        _required_string(header, "createdAt", "header")
    )
    run_seq = _required_string(common, "runSeq", "analysisCommon")
    if not _RUN_SEQ_RE.fullmatch(run_seq):
        raise AnalysisInputError("data.json analysisCommon.runSeq must be 3-digit")
    source_commit = _validated_commit(
        _required_string(common, "sourceCommit", "analysisCommon"),
        "sourceCommit",
    )
    if task_key != _task_key_from_manifest(task_root, okstra_root):
        raise AnalysisInputError("data.json header.taskKey does not match task manifest")
    if task_type != path_task_type:
        raise AnalysisInputError("data.json header.taskType does not match report path")
    if filename != f"final-report-{task_type}-{run_seq}.data.json":
        raise AnalysisInputError("data.json analysisCommon.runSeq does not match report filename")
    feature_index: Sequence[object] = ()
    if task_type == "project-analysis":
        project_analysis = data.get("projectAnalysis")
        if not isinstance(project_analysis, dict):
            raise AnalysisInputError("data.json projectAnalysis must be an object")
        feature_index = project_analysis.get("featureIndex")
        if not isinstance(feature_index, list):
            raise AnalysisInputError(
                "data.json projectAnalysis.featureIndex must be a list"
            )
    try:
        review = load_authoritative_analysis_review(
            report,
            expected_task_key=task_key,
            expected_task_type=task_type,
        )
    except UserResponseError as exc:
        raise AnalysisInputError(f"invalid review sidecar: {exc}") from exc
    review_status = review.status if review is not None else "unreviewed"
    return AnalysisReportCandidate(
        report_path=report,
        task_key=task_key,
        task_type=task_type,
        created_at=created_at,
        run_seq=run_seq,
        source_commit=source_commit,
        review_status=review_status,
        feature_index=tuple(feature_index),
    )


def list_evidence_candidates(
    project_root: Path, consumer_task_type: str, relation: str
) -> list[AnalysisReportCandidate]:
    """Return accepted, compatible reports in deterministic newest-first order."""
    root = project_root.resolve()
    compatible_types = [
        source_type
        for (consumer, source_type), allowed_relation in _EVIDENCE_RELATION_BY_TYPES.items()
        if consumer == consumer_task_type and allowed_relation == relation
    ]
    if not compatible_types:
        return []
    reports_root = root / ".okstra" / "tasks"
    candidates: list[AnalysisReportCandidate] = []
    for report in reports_root.glob("*/*/runs/*/reports/*.data.json"):
        try:
            candidate = load_analysis_report_candidate(root, report)
        except AnalysisInputError:
            continue
        if candidate.task_type in compatible_types and candidate.review_status == "accepted":
            candidates.append(candidate)
    candidates.sort(
        key=lambda candidate: (
            candidate.task_key,
            -int(candidate.run_seq),
            str(candidate.report_path),
        )
    )
    candidates.sort(key=lambda candidate: candidate.created_at, reverse=True)
    return candidates


def resolve_evidence_inputs(
    project_root: Path,
    consumer_task_type: str,
    report_paths: Sequence[Path],
    current_commit: str,
) -> tuple[ResolvedEvidenceInput, ...]:
    """Validate the explicit evidence selection that a consumer will use."""
    _validated_commit(current_commit, "current_commit")
    resolved: list[ResolvedEvidenceInput] = []
    seen: set[Path] = set()
    for report_path in report_paths:
        candidate = load_analysis_report_candidate(project_root, report_path)
        if candidate.report_path in seen:
            raise AnalysisInputError(f"duplicate evidence report: {candidate.report_path}")
        seen.add(candidate.report_path)
        relation = _EVIDENCE_RELATION_BY_TYPES.get(
            (consumer_task_type, candidate.task_type)
        )
        if relation is None:
            raise AnalysisInputError(
                f"evidence relation is not allowed: {consumer_task_type} <- {candidate.task_type}"
            )
        if candidate.review_status in {"revision-requested", "rejected"}:
            raise AnalysisInputError(
                f"evidence report has {candidate.review_status} review status"
            )
        resolved.append(ResolvedEvidenceInput(
            report_path=candidate.report_path,
            task_key=candidate.task_key,
            task_type=candidate.task_type,
            run_seq=candidate.run_seq,
            source_commit=candidate.source_commit,
            review_status=("accepted" if candidate.review_status == "accepted" else "user-unverified"),
            relation=relation,
            freshness="exact" if candidate.source_commit == current_commit else "stale",
        ))
    return tuple(resolved)


def resolve_analysis_target(
    raw_target: str,
    evidence_inputs: Sequence[ResolvedEvidenceInput],
    candidates: Mapping[Path, AnalysisReportCandidate],
) -> dict[str, object]:
    """Normalize a feature-index identifier or a non-empty free-text target."""
    target = raw_target.strip()
    if not target:
        raise AnalysisInputError("analysis target must not be empty")
    if not _FEATURE_ID_RE.fullmatch(target):
        return {"inputMode": "free-text", "requestedValue": target}
    matches: list[dict[object, object]] = []
    for evidence in evidence_inputs:
        if evidence.relation != "project-context":
            continue
        candidate = candidates.get(evidence.report_path)
        if candidate is None:
            candidate = next(
                (value for path, value in candidates.items() if path.resolve() == evidence.report_path),
                None,
            )
        if candidate is None:
            continue
        matches.extend(
            feature for feature in candidate.feature_index
            if isinstance(feature, dict) and feature.get("id") == target
        )
    if len(matches) != 1:
        raise AnalysisInputError(
            f"exactly one project-context feature must match {target}"
        )
    return {
        "inputMode": "feature-index",
        "requestedValue": target,
        "feature": dict(matches[0]),
    }
