from __future__ import annotations

import shutil
import subprocess
from collections.abc import Mapping
from pathlib import Path
from typing import Any

import yaml

from scienith_openapi_contract_tooling.openapi.msw_handlers_ts import openapi_to_msw_handlers_ts
from scienith_openapi_contract_tooling.openapi.python_models import openapi_to_python_models
from scienith_openapi_contract_tooling.openapi.redoc import openapi_to_redoc_html
from scienith_openapi_contract_tooling.openapi.skills_management_contract import (
    build_skills_management_artifacts,
)
from scienith_openapi_contract_tooling.openapi.ts_types import openapi_to_ts_types
from scienith_openapi_contract_tooling.openapi.uml import openapi_to_uml
from scienith_openapi_contract_tooling.openapi.zod_ts import openapi_to_zod_ts


class ProfileConfigError(ValueError):
    """Raised when contract-tooling.yaml is invalid."""


def load_profile_config(config_path: str | Path) -> dict[str, Any]:
    path = Path(config_path).resolve()
    data = yaml.safe_load(path.read_text(encoding="utf-8"))
    if not isinstance(data, dict):
        raise ProfileConfigError("contract-tooling.yaml must be a mapping.")
    profiles = data.get("profiles")
    if not isinstance(profiles, dict) or not profiles:
        raise ProfileConfigError("contract-tooling.yaml must define non-empty `profiles` mapping.")
    return {"config_path": path, "profiles": profiles}


def build_from_config(
    *,
    config_path: str | Path,
    profile_name: str | None = None,
    dry_run: bool = False,
) -> None:
    config = load_profile_config(config_path)
    profiles: Mapping[str, Any] = config["profiles"]
    selected = profile_name
    if selected is None:
        if len(profiles) != 1:
            names = ", ".join(sorted(str(name) for name in profiles))
            raise ProfileConfigError(f"--profile is required when multiple profiles exist: {names}")
        selected = str(next(iter(profiles)))
    if selected not in profiles:
        names = ", ".join(sorted(str(name) for name in profiles))
        raise ProfileConfigError(f"unknown profile {selected!r}; available profiles: {names}")

    profile = profiles[selected]
    if not isinstance(profile, dict):
        raise ProfileConfigError(f"profile {selected!r} must be a mapping.")
    config_dir = Path(config["config_path"]).parent
    profile_type = _required_str(profile, "type", selected)
    if profile_type == "contract-package":
        _build_contract_package_profile(selected, profile, config_dir=config_dir, dry_run=dry_run)
    elif profile_type == "monorepo-adapter":
        _build_monorepo_adapter_profile(selected, profile, config_dir=config_dir, dry_run=dry_run)
    else:
        raise ProfileConfigError(
            f"profile {selected!r} has unsupported type {profile_type!r}; "
            "expected `contract-package` or `monorepo-adapter`."
        )


def _build_contract_package_profile(
    name: str,
    profile: Mapping[str, Any],
    *,
    config_dir: Path,
    dry_run: bool,
) -> None:
    root = _profile_root(profile, config_dir)
    openapi_path = _path(profile.get("openapi"), root=root, field=f"{name}.openapi")
    outputs = _outputs(profile, name)
    package_dir = _optional_path(profile.get("ts_package_dir"), root=root)

    actions: list[tuple[str, Any]] = [
        ("openapi views", lambda: _generate_views(openapi_path, outputs, root=root)),
        (
            "python models",
            lambda: openapi_to_python_models(
                root_path=str(openapi_path),
                out_path=str(_output(outputs, "python_models", root=root)),
                bundle=True,
                output_model_type=str(profile.get("output_model_type") or "pydantic_v2.BaseModel"),
                target_python_version=str(profile.get("target_python_version") or "3.13"),
                disable_timestamp=bool(profile.get("disable_timestamp", True)),
            ),
        ),
        (
            "ts types",
            lambda: openapi_to_ts_types(
                root_path=str(openapi_path),
                out_path=str(_output(outputs, "ts_types", root=root)),
                package_dir=str(package_dir) if package_dir else None,
            ),
        ),
        (
            "zod ts",
            lambda: openapi_to_zod_ts(
                root_path=str(openapi_path),
                out_path=str(_output(outputs, "zod_ts", root=root)),
                bundle=True,
            ),
        ),
        (
            "msw handlers",
            lambda: openapi_to_msw_handlers_ts(
                root_path=str(openapi_path),
                out_path=str(_output(outputs, "msw_handlers", root=root)),
                bundle=True,
            ),
        ),
        ("copy python openapi", lambda: _copy(openapi_path, _output(outputs, "python_openapi", root=root))),
        ("copy ts openapi", lambda: _copy(openapi_path, _output(outputs, "ts_openapi", root=root))),
    ]
    _run_actions(name, actions, dry_run=dry_run)

    if bool(profile.get("ts_build", False)):
        if package_dir is None:
            raise ProfileConfigError(f"profile {name!r} sets ts_build=true but omits ts_package_dir.")
        if dry_run:
            print(f"dry-run {name}: npm --prefix {package_dir} run build")
        else:
            subprocess.run(["npm", "--prefix", str(package_dir), "run", "build"], check=True)


def _build_monorepo_adapter_profile(
    name: str,
    profile: Mapping[str, Any],
    *,
    config_dir: Path,
    dry_run: bool,
) -> None:
    root = _profile_root(profile, config_dir)
    openapi_path = _path(profile.get("openapi"), root=root, field=f"{name}.openapi")
    outputs = _outputs(profile, name)
    required = [
        "openapi_bundle",
        "contract_summary",
        "operation_maps_ts",
        "python_validator",
    ]
    for key in required:
        _output(outputs, key, root=root)
    python_init = _optional_output(outputs, "python_validator_init", root=root)
    if dry_run:
        print(f"dry-run {name}: build monorepo adapter from {openapi_path}")
        for key in [*required, "python_validator_init"]:
            if key in outputs:
                print(f"dry-run {name}: output {key} -> {_output(outputs, key, root=root)}")
        return

    build_skills_management_artifacts(
        openapi_path=openapi_path,
        openapi_bundle_out=_output(outputs, "openapi_bundle", root=root),
        contract_summary_out=_output(outputs, "contract_summary", root=root),
        operation_maps_ts_out=_output(outputs, "operation_maps_ts", root=root),
        python_validator_out=_output(outputs, "python_validator", root=root),
        python_validator_init_out=python_init,
    )


def _generate_views(openapi_path: Path, outputs: Mapping[str, Any], *, root: Path) -> None:
    openapi_to_uml(
        root_path=str(openapi_path),
        out_path=str(_output(outputs, "plantuml", root=root)),
        plantuml_jar=None,
        bundle=True,
        direction="LR",
        redoc_out_path=None,
    )
    openapi_to_redoc_html(
        root_path=str(openapi_path),
        out_path=str(_output(outputs, "redoc_html", root=root)),
        bundle=True,
    )


def _run_actions(name: str, actions: list[tuple[str, Any]], *, dry_run: bool) -> None:
    for label, action in actions:
        if dry_run:
            print(f"dry-run {name}: {label}")
        else:
            action()


def _copy(src: Path, dst: Path) -> None:
    dst.parent.mkdir(parents=True, exist_ok=True)
    shutil.copyfile(src, dst)


def _profile_root(profile: Mapping[str, Any], config_dir: Path) -> Path:
    return _path(profile.get("root") or ".", root=config_dir, field="root")


def _outputs(profile: Mapping[str, Any], name: str) -> Mapping[str, Any]:
    outputs = profile.get("outputs")
    if not isinstance(outputs, dict):
        raise ProfileConfigError(f"profile {name!r} must define mapping `outputs`.")
    return outputs


def _output(outputs: Mapping[str, Any], key: str, *, root: Path) -> Path:
    if key not in outputs:
        raise ProfileConfigError(f"missing required output: {key}")
    return _path(outputs[key], root=root, field=f"outputs.{key}")


def _optional_output(outputs: Mapping[str, Any], key: str, *, root: Path) -> Path | None:
    if key not in outputs or outputs[key] in (None, ""):
        return None
    return _path(outputs[key], root=root, field=f"outputs.{key}")


def _optional_path(value: Any, *, root: Path) -> Path | None:
    if value in (None, ""):
        return None
    return _path(value, root=root, field="path")


def _path(value: Any, *, root: Path, field: str) -> Path:
    if not isinstance(value, str) or not value:
        raise ProfileConfigError(f"{field} must be a non-empty string path.")
    path = Path(value)
    if not path.is_absolute():
        path = root / path
    return path.resolve()


def _required_str(profile: Mapping[str, Any], key: str, name: str) -> str:
    value = profile.get(key)
    if not isinstance(value, str) or not value:
        raise ProfileConfigError(f"profile {name!r} must define non-empty string `{key}`.")
    return value
