from __future__ import annotations

import fnmatch
import re
import unicodedata
from dataclasses import dataclass
from typing import Any, Mapping


class DesignSurfaceError(ValueError):
    """The planning structure cannot be mapped to deterministic design surfaces."""


@dataclass(frozen=True)
class SurfaceRule:
    kind: str
    path_tokens: tuple[str, ...]
    path_suffixes: tuple[str, ...]
    path_patterns: tuple[str, ...]
    action_phrases: tuple[str, ...]


@dataclass(frozen=True)
class TriggerEvidence:
    step: int | None
    field: str
    match: str


@dataclass(frozen=True)
class DesignSurfaceTrigger:
    stage: int
    kind: str
    evidence: tuple[TriggerEvidence, ...]


RULES = (
    SurfaceRule(
        "domain-contract",
        ("/domain/",),
        (),
        (),
        ("entity", "value object", "aggregate", "domain error"),
    ),
    SurfaceRule(
        "persistence-schema",
        ("/migrations/", "/orm/", "/repositories/"),
        (".sql",),
        (),
        (
            "create table",
            "alter table",
            "column",
            "index",
            "foreign key",
            "unique constraint",
        ),
    ),
    SurfaceRule(
        "external-interface",
        ("/ports/", "/adapters/", "/clients/", "/gateways/", "/infrastructure/http/"),
        (),
        (),
        ("port", "adapter", "client", "gateway", "external api"),
    ),
    SurfaceRule(
        "transaction-consistency",
        (),
        (),
        (),
        ("transaction", "outbox", "unit of work", "atomic write"),
    ),
    SurfaceRule(
        "transformation-mapping",
        ("/mappers/", "/parsers/", "/normalizers/"),
        (),
        (),
        ("parse", "map", "canonical", "normalize", "transform"),
    ),
    SurfaceRule(
        "lifecycle-state-machine",
        (),
        (),
        (),
        (
            "claim",
            "lease",
            "retry",
            "dead",
            "dead-letter",
            "state transition",
            "state machine",
        ),
    ),
    SurfaceRule(
        "rollout-observability",
        ("/monitoring/", "/deploy/", "/helm/"),
        ("/values.yaml",),
        ("*/values-*.yaml",),
        ("rollout", "liveness", "alert", "feature flag"),
    ),
)


def _normalise(value: object) -> str:
    return unicodedata.normalize("NFC", str(value or "")).replace("\\", "/").lower()


def _phrase_matches(text: str, phrase: str) -> bool:
    return re.search(rf"(?<![\w-]){re.escape(phrase)}(?![\w-])", text) is not None


def _selected_candidate(planning: Mapping[str, Any]) -> Mapping[str, Any]:
    recommended = _normalise((planning.get("recommendedOption") or {}).get("name"))
    candidates = [
        row
        for row in planning.get("optionCandidates") or []
        if isinstance(row, Mapping)
    ]
    exact = [row for row in candidates if _normalise(row.get("name")) == recommended]
    if len(exact) > 1:
        raise DesignSurfaceError(
            f"recommended option {recommended!r} is missing or ambiguous"
        )
    if len(exact) == 1:
        return exact[0]
    prefixed = [
        row
        for row in candidates
        if _normalise(row.get("name")).startswith(
            (recommended + ":", recommended + " —")
        )
    ]
    if len(prefixed) != 1:
        raise DesignSurfaceError(
            f"recommended option {recommended!r} is missing or ambiguous"
        )
    return prefixed[0]


def expected_prep_plan_item_id(stage: int, kind: str) -> str:
    return f"P-Prep-S{stage}-{kind}"


def _path_matches(rule: SurfaceRule, raw_path: object) -> bool:
    path = "/" + _normalise(raw_path).lstrip("/")
    return (
        any(token in path for token in rule.path_tokens)
        or any(path.endswith(suffix) for suffix in rule.path_suffixes)
        or any(fnmatch.fnmatch(path, pattern) for pattern in rule.path_patterns)
    )


def _matching_rule_evidence(
    *,
    step: int | None,
    field: str,
    value: object,
) -> list[tuple[str, TriggerEvidence]]:
    text = _normalise(value)
    matches = []
    for rule in RULES:
        path_hit = field == "files" and _path_matches(rule, text)
        action_hit = field == "action" and any(
            _phrase_matches(text, phrase) for phrase in rule.action_phrases
        )
        if path_hit or action_hit:
            matches.append((rule.kind, TriggerEvidence(step, field, text)))
    return matches


def _stage_rows(planning: Mapping[str, Any]) -> dict[int, Mapping[str, Any]]:
    rows = {
        int(stage["stage"]): stage
        for stage in planning.get("stages") or []
        if isinstance(stage, Mapping) and isinstance(stage.get("stage"), int)
    }
    if not rows:
        raise DesignSurfaceError("implementationPlanning.stages is empty")
    return rows


_LINE_RANGE_SUFFIX = re.compile(r":\d+(?:-\d+)?$")
_PAREN_SUFFIX = re.compile(r"\s*\([^)]*\)\s*$")


def _split_declared_paths(raw: str) -> list[str]:
    """쉼표·줄바꿈으로 이어진 경로를 파일 단위로 나눈다. `{a,b}` 안 쉼표는 유지.

    `fileStructure.path` 는 원래 열 수 있는 경로 하나인데, 계획이 여러 파일을
    한 칸에 이어 쓰면 추출기가 그 문자열 전체를 한 경로로 보고 스테이지 매핑에
    실패한다.
    """
    parts: list[str] = []
    buf: list[str] = []
    depth = 0
    for char in raw:
        if char == "{":
            depth += 1
            buf.append(char)
        elif char == "}":
            depth = max(0, depth - 1)
            buf.append(char)
        elif char in ",\n" and depth == 0:
            token = "".join(buf).strip()
            if token:
                parts.append(token)
            buf = []
        else:
            buf.append(char)
    token = "".join(buf).strip()
    if token:
        parts.append(token)
    return parts


def _step_files_cells(step: Mapping[str, Any]) -> list[str]:
    raw = step.get("files")
    if isinstance(raw, list):
        return [_normalise(item) for item in raw if item]
    text = _normalise(raw)
    return [text] if text else []


def _files_cell_tokens(cell: str) -> list[str]:
    tokens: list[str] = []
    for part in _split_declared_paths(cell) or [cell]:
        token = _LINE_RANGE_SUFFIX.sub("", part)
        token = _PAREN_SUFFIX.sub("", token).strip()
        if token:
            tokens.append(token)
    return tokens


def _cell_covers_path(cell: str, path: str) -> bool:
    path_pattern = rf"(?<![\w./@-]){re.escape(path)}(?![\w./@-])"
    if re.search(path_pattern, cell) is not None:
        return True
    for token in _files_cell_tokens(cell):
        if token == path:
            return True
        if "*" in token and fnmatch.fnmatch(path, token):
            return True
    return False


def _stages_touching_path(
    path: str,
    stages: Mapping[int, Mapping[str, Any]],
) -> list[int]:
    hits: list[int] = []
    for stage_number, stage in stages.items():
        cells = [
            cell
            for step in stage.get("stepwiseExecution") or []
            if isinstance(step, Mapping)
            for cell in _step_files_cells(step)
        ]
        if any(_cell_covers_path(cell, path) for cell in cells):
            hits.append(stage_number)
    return hits


def _stages_for_selected_path(
    path: str,
    stages: Mapping[int, Mapping[str, Any]],
) -> list[int]:
    """Every stage whose stepwise ``files`` cells touch *path*.

    A file legitimately edited by more than one stage — the task-level
    ``qa/conformance-manifest.json`` is the standard case, since each
    conformance-declaring stage registers its own entry — yields one design
    surface per stage, which is what the consumer keys on. Requiring a single
    owner instead would force the plan to hide an edit, collapse stage
    boundaries, or re-add the line-range suffixes that used to disambiguate
    stages by accident.

    Zero stages is still an error: the recommended option declares a file that
    no step creates or edits.
    """
    declared = [
        _LINE_RANGE_SUFFIX.sub("", _normalise(part))
        for part in (_split_declared_paths(path) or [path])
    ]
    declared = [part for part in declared if part]
    if not declared:
        raise DesignSurfaceError(
            f"selected option path {path!r} is not mapped to a stage"
        )
    matched: list[int] = []
    seen: set[int] = set()
    for part in declared:
        hits = _stages_touching_path(part, stages)
        if not hits:
            raise DesignSurfaceError(
                f"selected option path {part!r} is not mapped to a stage"
            )
        for stage_number in hits:
            if stage_number not in seen:
                seen.add(stage_number)
                matched.append(stage_number)
    return matched


def detect_design_surfaces(
    planning: Mapping[str, Any],
) -> list[DesignSurfaceTrigger]:
    stages = _stage_rows(planning)
    grouped: dict[tuple[int, str], list[TriggerEvidence]] = {}
    for stage_number, stage in stages.items():
        for step in stage.get("stepwiseExecution") or []:
            if not isinstance(step, Mapping):
                continue
            step_number = (
                int(step.get("step")) if isinstance(step.get("step"), int) else None
            )
            for field in ("files", "action"):
                for kind, evidence in _matching_rule_evidence(
                    step=step_number,
                    field=field,
                    value=step.get(field),
                ):
                    grouped.setdefault((stage_number, kind), []).append(evidence)
    selected = _selected_candidate(planning)
    for row in selected.get("fileStructure") or []:
        if not isinstance(row, Mapping):
            continue
        path = str(row.get("path") or "")
        for stage_number in _stages_for_selected_path(path, stages):
            for declared in _split_declared_paths(path) or [path]:
                for kind, evidence in _matching_rule_evidence(
                    step=None,
                    field="files",
                    value=declared,
                ):
                    grouped.setdefault((stage_number, kind), []).append(evidence)
    rule_order = {rule.kind: index for index, rule in enumerate(RULES)}
    return [
        DesignSurfaceTrigger(stage, kind, tuple(grouped[(stage, kind)]))
        for stage, kind in sorted(
            grouped,
            key=lambda key: (key[0], rule_order[key[1]]),
        )
    ]
