"""런타임 계약 적재, 교차 참조 검증, 의존성 폐쇄 계산."""
from __future__ import annotations

from collections import Counter
from collections.abc import Mapping, Sequence
from dataclasses import dataclass
import hashlib
from pathlib import Path
from typing import Any

from .final_report_schema import validate as validate_schema
from .json_boundary import JsonBoundaryError, load_owned_object


_SCHEMA_FILES = {
    "common": "agent-common-v1.schema.json",
    "role": "agent-role-v1.schema.json",
    "duty": "agent-duty-v1.schema.json",
    "profile": "agent-profile-v1.schema.json",
    "operation": "agent-operation-v1.schema.json",
}
_KNOWN_ROLE_CAPABILITIES = frozenset(
    {
        "extended-artifact-authoring",
        "lead-session",
        "project-mutation",
        "source-readonly",
        "worker-artifact-io",
    }
)


class ContractGraphError(ValueError):
    """한 검증 단계에서 유효하지 않은 런타임 계약을 발견했다."""

    def __init__(self, errors: Sequence[str]) -> None:
        self.errors = tuple(errors)
        super().__init__("\n".join(self.errors))


@dataclass(frozen=True)
class ContractGraphRoot:
    common: Path
    roles: Path
    duties: Path
    profiles: Path
    operations: Path
    schemas: Path

    @classmethod
    def from_base(cls, base: Path) -> ContractGraphRoot:
        return cls(
            common=base / "agents" / "common.json",
            roles=base / "agents" / "roles",
            duties=base / "prompts" / "duties",
            profiles=base / "prompts" / "profiles",
            operations=base / "agents" / "operations",
            schemas=base / "schemas",
        )


@dataclass(frozen=True)
class ContractSelection:
    profile_id: str | None = None
    operation_id: str | None = None


@dataclass(frozen=True)
class ContractFile:
    relative_path: str
    schema_version: str
    sha256: str
    payload: Mapping[str, Any]

    @property
    def id(self) -> str:
        return str(self.payload["id"])

    @property
    def role_id(self) -> str:
        return str(self.payload["roleId"])


@dataclass(frozen=True)
class _LoadedContracts:
    common: ContractFile
    roles: tuple[ContractFile, ...]
    duties: tuple[ContractFile, ...]
    profiles: tuple[ContractFile, ...]
    operations: tuple[ContractFile, ...]
    profile_markdown: Mapping[str, ContractFile]


@dataclass(frozen=True)
class ContractGraph:
    common: ContractFile
    roles: Mapping[str, ContractFile]
    duties: Mapping[str, ContractFile]
    profiles: Mapping[str, ContractFile]
    operations: Mapping[str, ContractFile]
    profile_markdown: Mapping[str, ContractFile]

    @property
    def files(self) -> tuple[ContractFile, ...]:
        rows = (
            self.common,
            *self.roles.values(),
            *self.duties.values(),
            *self.profiles.values(),
            *self.profile_markdown.values(),
            *self.operations.values(),
        )
        return tuple(sorted(rows, key=lambda row: row.relative_path))

    def dependencies_for_profile(self, profile_id: str) -> Sequence[ContractFile]:
        profile = self._find(self.profiles, profile_id, "profile")
        dependencies = [self.common, profile]
        markdown = self.profile_markdown.get(profile_id)
        if markdown is not None:
            dependencies.append(markdown)
        for requirement in profile.payload["roles"]:
            dependencies.append(self.roles[str(requirement["roleId"])])
            dependencies.append(self.duties[str(requirement["dutyId"])])
            for source_role_id in requirement.get("sourceRoleIds", []):
                dependencies.append(self.roles[str(source_role_id)])
        return _unique_sorted(dependencies)

    def dependencies_for_operation(self, operation_id: str) -> Sequence[ContractFile]:
        operation = self._find(self.operations, operation_id, "operation")
        duty = self.duties[str(operation.payload["dutyId"])]
        return _unique_sorted([self.common, operation, duty, self.roles[duty.role_id]])

    @staticmethod
    def _find(
        contracts: Mapping[str, ContractFile], contract_id: str, kind: str
    ) -> ContractFile:
        try:
            return contracts[contract_id]
        except KeyError as exc:
            raise ContractGraphError(
                [f"<selection>: {kind}Id: unknown {kind} id {contract_id!r}"]
            ) from exc


class ContractGraphValidator:
    def validate(
        self,
        root: ContractGraphRoot,
        *,
        selected: ContractSelection | None = None,
        model_refs: Mapping[str, Sequence[str]] | None = None,
    ) -> ContractGraph:
        loaded = _load_contracts(root)
        schemas = _load_schemas(root, loaded)
        _validate_documents(loaded, schemas)
        _validate_identifiers(loaded)
        graph = _build_graph(loaded)
        _validate_duty_roles(graph)
        _validate_references(graph)
        _validate_dynamic_sources(graph)
        _validate_role_capabilities(graph)
        _validate_counts(graph, selected, model_refs)
        _validate_model_diversity(graph, model_refs)
        _validate_selection(graph, selected)
        return graph


def _load_schemas(
    root: ContractGraphRoot, loaded: _LoadedContracts
) -> dict[str, Mapping[str, Any]]:
    schemas: dict[str, Mapping[str, Any]] = {}
    errors: list[str] = []
    for kind in _required_schema_kinds(loaded):
        filename = _SCHEMA_FILES[kind]
        path = root.schemas / filename
        try:
            schemas[kind] = load_owned_object(path, artifact=f"schemas/{filename}")
        except JsonBoundaryError as exc:
            errors.append(f"schemas/{filename}: <root>: {exc.reason}")
    _fail(errors)
    return schemas


def _required_schema_kinds(loaded: _LoadedContracts) -> tuple[str, ...]:
    kinds = ["common"]
    for kind, rows in (
        ("role", loaded.roles),
        ("duty", loaded.duties),
        ("profile", loaded.profiles),
        ("operation", loaded.operations),
    ):
        if rows:
            kinds.append(kind)
    return tuple(kinds)


def _load_contracts(root: ContractGraphRoot) -> _LoadedContracts:
    try:
        common = _load_json_contract(root.common, "agents/common.json")
        roles = _load_directory(root.roles, "agents/roles")
        duties = _load_directory(root.duties, "prompts/duties")
        profiles = _load_directory(
            root.profiles,
            "prompts/profiles",
            excluded_names=frozenset({"forbidden-actions.json"}),
        )
        operations = _load_directory(root.operations, "agents/operations")
    except JsonBoundaryError as exc:
        relative = str(exc.artifact)
        raise ContractGraphError([f"{relative}: <root>: {exc.reason}"]) from exc
    markdown = _load_profile_markdown(root.profiles, profiles)
    return _LoadedContracts(common, roles, duties, profiles, operations, markdown)


def _load_directory(
    directory: Path,
    prefix: str,
    *,
    excluded_names: frozenset[str] = frozenset(),
) -> tuple[ContractFile, ...]:
    if not directory.is_dir():
        return ()
    return tuple(
        _load_json_contract(path, f"{prefix}/{path.name}")
        for path in sorted(directory.glob("*.json"))
        if path.name not in excluded_names
    )


def _load_json_contract(path: Path, relative_path: str) -> ContractFile:
    payload = load_owned_object(path, artifact=relative_path)
    version = payload.get("schemaVersion")
    schema_version = version if isinstance(version, str) else "<invalid>"
    return ContractFile(relative_path, schema_version, _digest(path), payload)


def _load_profile_markdown(
    directory: Path, profiles: Sequence[ContractFile]
) -> dict[str, ContractFile]:
    rows: dict[str, ContractFile] = {}
    for profile in profiles:
        profile_id = Path(profile.relative_path).stem
        path = directory / f"{profile_id}.md"
        if not path.is_file():
            continue
        relative_path = f"prompts/profiles/{path.name}"
        rows[profile_id] = ContractFile(
            relative_path,
            profile.schema_version,
            _digest(path),
            {"text": path.read_text(encoding="utf-8")},
        )
    return rows


def _validate_documents(
    loaded: _LoadedContracts, schemas: Mapping[str, Mapping[str, Any]]
) -> None:
    groups = (
        ("common", (loaded.common,)),
        ("role", loaded.roles),
        ("duty", loaded.duties),
        ("profile", loaded.profiles),
        ("operation", loaded.operations),
    )
    errors = [
        f"{row.relative_path}: {error}"
        for kind, rows in groups
        for row in rows
        for error in validate_schema(dict(row.payload), dict(schemas[kind]))
    ]
    _fail(errors)


def _validate_identifiers(loaded: _LoadedContracts) -> None:
    groups = (
        ("roles", loaded.roles),
        ("duties", loaded.duties),
        ("profiles", loaded.profiles),
        ("operations", loaded.operations),
    )
    errors: list[str] = []
    for namespace, rows in groups:
        counts = Counter(row.id for row in rows)
        for row in rows:
            filename_id = Path(row.relative_path).stem
            if counts[row.id] > 1:
                errors.append(
                    f"{row.relative_path}: id: duplicate id {row.id!r} in {namespace}"
                )
            if row.id != filename_id:
                errors.append(
                    f"{row.relative_path}: id: id {row.id!r} does not match "
                    f"filename {filename_id!r}"
                )
    _fail(errors)


def _build_graph(loaded: _LoadedContracts) -> ContractGraph:
    return ContractGraph(
        common=loaded.common,
        roles={row.id: row for row in loaded.roles},
        duties={row.id: row for row in loaded.duties},
        profiles={row.id: row for row in loaded.profiles},
        operations={row.id: row for row in loaded.operations},
        profile_markdown=loaded.profile_markdown,
    )


def _validate_duty_roles(graph: ContractGraph) -> None:
    errors = [
        f"{duty.relative_path}: roleId: unknown roleId {duty.role_id!r}"
        for duty in graph.duties.values()
        if duty.role_id not in graph.roles
    ]
    _fail(errors)


def _validate_references(graph: ContractGraph) -> None:
    errors: list[str] = []
    for profile in graph.profiles.values():
        for index, requirement in enumerate(profile.payload["roles"]):
            duty_id = str(requirement["dutyId"])
            role_id = str(requirement["roleId"])
            field = f"roles[{index}]"
            duty = graph.duties.get(duty_id)
            if duty is None:
                errors.append(
                    f"{profile.relative_path}: {field}.dutyId: "
                    f"unknown dutyId {duty_id!r}"
                )
            if role_id not in graph.roles:
                errors.append(
                    f"{profile.relative_path}: {field}.roleId: "
                    f"unknown roleId {role_id!r}"
                )
            if duty is not None and duty.role_id != role_id:
                errors.append(
                    f"{profile.relative_path}: {field}.roleId: "
                    f"roleId does not match duty {duty_id!r}"
                )
    errors.extend(_operation_reference_errors(graph))
    errors.extend(
        f"prompts/profiles/{profile_id}.md: <root>: missing profile instruction"
        for profile_id in graph.profiles
        if profile_id not in graph.profile_markdown
    )
    _fail(errors)


def _operation_reference_errors(graph: ContractGraph) -> list[str]:
    return [
        f"{operation.relative_path}: dutyId: unknown dutyId {duty_id!r}"
        for operation in graph.operations.values()
        if (duty_id := str(operation.payload["dutyId"])) not in graph.duties
    ]


def _validate_dynamic_sources(graph: ContractGraph) -> None:
    errors: list[str] = []
    for profile in graph.profiles.values():
        static_roles = {
            str(row["roleId"])
            for row in profile.payload["roles"]
            if row["mode"] == "static"
        }
        for index, requirement in enumerate(profile.payload["roles"]):
            if requirement["mode"] != "dynamic":
                continue
            unknown = sorted(set(requirement["sourceRoleIds"]) - static_roles)
            if unknown:
                errors.append(
                    f"{profile.relative_path}: roles[{index}].sourceRoleIds: "
                    f"unknown sourceRoleIds {unknown!r}"
                )
    _fail(errors)


def _validate_role_capabilities(graph: ContractGraph) -> None:
    errors: list[str] = []
    for role in graph.roles.values():
        capabilities = set(role.payload["requiredCapabilities"])
        unknown = sorted(capabilities - _KNOWN_ROLE_CAPABILITIES)
        if unknown:
            errors.append(
                f"{role.relative_path}: requiredCapabilities: "
                f"unknown requiredCapabilities {unknown!r}"
            )
    _fail(errors)


def _validate_counts(
    graph: ContractGraph,
    selected: ContractSelection | None,
    model_refs: Mapping[str, Sequence[str]] | None,
) -> None:
    errors: list[str] = []
    for profile in graph.profiles.values():
        for index, requirement in enumerate(profile.payload["roles"]):
            if requirement["mode"] != "static":
                continue
            counts = (
                requirement["min"],
                requirement["recommended"],
                requirement["max"],
            )
            if counts != tuple(sorted(counts)):
                errors.append(
                    f"{profile.relative_path}: roles[{index}]: "
                    "expected min <= recommended <= max"
                )
    if selected is not None and model_refs is not None:
        errors.extend(_selected_count_errors(graph, selected, model_refs))
    _fail(errors)


def _selected_count_errors(
    graph: ContractGraph,
    selected: ContractSelection,
    model_refs: Mapping[str, Sequence[str]],
) -> list[str]:
    if selected.profile_id is not None and selected.profile_id in graph.profiles:
        profile = graph.profiles[selected.profile_id]
        return _profile_model_count_errors(profile, model_refs)
    if selected.operation_id is not None and selected.operation_id in graph.operations:
        operation = graph.operations[selected.operation_id]
        duty = graph.duties.get(str(operation.payload["dutyId"]))
        if duty is None:
            return []
        actual = len(model_refs.get(duty.role_id, ()))
        expected = operation.payload["count"]
        if actual != expected:
            return [
                f"{operation.relative_path}: count: assigned model count {actual} "
                f"does not equal {expected}"
            ]
    return []


def _profile_model_count_errors(
    profile: ContractFile, model_refs: Mapping[str, Sequence[str]]
) -> list[str]:
    errors: list[str] = []
    for index, requirement in enumerate(profile.payload["roles"]):
        if requirement["mode"] != "static":
            continue
        actual = len(model_refs.get(str(requirement["roleId"]), ()))
        minimum = requirement["min"]
        maximum = requirement["max"]
        if not minimum <= actual <= maximum:
            errors.append(
                f"{profile.relative_path}: roles[{index}]: assigned model count "
                f"{actual} outside [{minimum}, {maximum}]"
            )
    return errors


def _validate_model_diversity(
    graph: ContractGraph, model_refs: Mapping[str, Sequence[str]] | None
) -> None:
    if model_refs is None:
        return
    errors = [
        f"agents/roles/{role_id}.json: modelRefs: expected unique model refs"
        for role_id, refs in model_refs.items()
        if role_id in graph.roles and len(refs) != len(set(refs))
    ]
    _fail(errors)


def _validate_selection(
    graph: ContractGraph, selected: ContractSelection | None
) -> None:
    if selected is None:
        return
    if (selected.profile_id is None) == (selected.operation_id is None):
        raise ContractGraphError(
            ["<selection>: target: select exactly one profile or operation"]
        )
    if selected.profile_id is not None:
        graph._find(graph.profiles, selected.profile_id, "profile")
    if selected.operation_id is not None:
        graph._find(graph.operations, selected.operation_id, "operation")


def _unique_sorted(rows: Sequence[ContractFile]) -> tuple[ContractFile, ...]:
    by_path = {row.relative_path: row for row in rows}
    return tuple(by_path[path] for path in sorted(by_path))


def _digest(path: Path) -> str:
    return "sha256:" + hashlib.sha256(path.read_bytes()).hexdigest()


def _fail(errors: Sequence[str]) -> None:
    if errors:
        raise ContractGraphError(sorted(errors))
