import json
from pathlib import Path
from typing import Any

from .models import CMakeConfig, HarnessConfig, MSBuildConfig, TDDConfig

DEFAULT_TEST_PATTERNS = (
    "{stem}_test.cpp",
    "{stem}_tests.cpp",
    "test_{stem}.cpp",
    "{stem}.test.cpp",
)


class ConfigError(ValueError):
    pass


def _reject_unknown(data: dict[str, Any], allowed: set[str], label: str) -> None:
    unknown = sorted(set(data) - allowed)
    if unknown:
        raise ConfigError(f"{label} contains unexpected field: {unknown[0]}")


def _repo_path(root: Path, raw: str, label: str) -> Path:
    candidate = Path(raw)
    if candidate.is_absolute():
        raise ConfigError(f"{label} must be repository-relative")
    resolved = (root / candidate).resolve()
    try:
        resolved.relative_to(root)
    except ValueError as exc:
        raise ConfigError(f"{label} resolves outside the repository") from exc
    return resolved


def _mapping(value: Any, label: str) -> dict[str, Any]:
    if not isinstance(value, dict):
        raise ConfigError(f"{label} must be an object")
    return value


def _nonempty_string(value: Any, label: str) -> str:
    if not isinstance(value, str) or not value:
        raise ConfigError(f"{label} must be a non-empty string")
    return value


def _string_array(value: Any, label: str) -> tuple[str, ...]:
    if not isinstance(value, list):
        raise ConfigError(f"{label} must be an array")
    if not value or any(not isinstance(item, str) or not item for item in value):
        raise ConfigError(f"{label} must be a non-empty string array")
    return tuple(value)


def load_config(root: Path) -> HarnessConfig:
    root = root.resolve()
    config_path = root / ".harness" / "config.json"
    if config_path.is_file():
        try:
            data = json.loads(config_path.read_text(encoding="utf-8"))
        except UnicodeDecodeError as exc:
            raise ConfigError(
                f"{config_path}: configuration must be valid UTF-8: {exc}"
            ) from exc
        except json.JSONDecodeError as exc:
            raise ConfigError(f"{config_path}: {exc}") from exc
    else:
        data = {"version": 1}

    data = _mapping(data, "config")
    _reject_unknown(data, {"version", "projectType", "cmake", "msbuild", "tdd"}, "config")

    if data.get("version") != 1 or isinstance(data.get("version"), bool):
        raise ConfigError("version must be 1")
    project_type = data.get("projectType", "auto")
    if project_type not in {"auto", "cmake", "msbuild"}:
        raise ConfigError("projectType must be auto, cmake, or msbuild")

    cmake_data = _mapping(data.get("cmake", {}), "cmake")
    _reject_unknown(
        cmake_data,
        {"sourceDir", "binaryDir", "configurePreset", "buildPreset", "testPreset"},
        "cmake",
    )
    source_dir = _repo_path(
        root, _nonempty_string(cmake_data.get("sourceDir", "."), "cmake.sourceDir"), "cmake.sourceDir"
    )
    binary_dir = None
    if "binaryDir" in cmake_data:
        binary_dir = _repo_path(
            root, _nonempty_string(cmake_data["binaryDir"], "cmake.binaryDir"), "cmake.binaryDir"
        )
    preset_keys = ("configurePreset", "buildPreset", "testPreset", "binaryDir")
    if any(key in cmake_data for key in preset_keys) and not all(key in cmake_data for key in preset_keys):
        raise ConfigError("cmake configurePreset, buildPreset, testPreset, and binaryDir must be specified together")
    configure_preset = (
        _nonempty_string(cmake_data["configurePreset"], "cmake.configurePreset")
        if "configurePreset" in cmake_data
        else None
    )
    build_preset = (
        _nonempty_string(cmake_data["buildPreset"], "cmake.buildPreset")
        if "buildPreset" in cmake_data
        else None
    )
    test_preset = (
        _nonempty_string(cmake_data["testPreset"], "cmake.testPreset")
        if "testPreset" in cmake_data
        else None
    )

    msbuild_data = _mapping(data.get("msbuild", {}), "msbuild")
    _reject_unknown(msbuild_data, {"solution", "configuration", "platform", "testCommand"}, "msbuild")
    solution = None
    if "solution" in msbuild_data:
        solution = _repo_path(
            root, _nonempty_string(msbuild_data["solution"], "msbuild.solution"), "msbuild.solution"
        )
    configuration = _nonempty_string(msbuild_data.get("configuration", "Debug"), "msbuild.configuration")
    platform = _nonempty_string(msbuild_data.get("platform", "x64"), "msbuild.platform")
    test_command = (
        _string_array(msbuild_data["testCommand"], "msbuild.testCommand")
        if "testCommand" in msbuild_data
        else None
    )

    tdd_data = _mapping(data.get("tdd", {}), "tdd")
    _reject_unknown(tdd_data, {"testRoots", "testPatterns", "exclude"}, "tdd")
    raw_test_roots = _string_array(tdd_data["testRoots"], "tdd.testRoots") if "testRoots" in tdd_data else ("tests", "test")
    test_roots = tuple(_repo_path(root, item, "tdd.testRoots") for item in raw_test_roots)
    test_patterns = _string_array(tdd_data["testPatterns"], "tdd.testPatterns") if "testPatterns" in tdd_data else DEFAULT_TEST_PATTERNS
    if any("{stem}" not in pattern for pattern in test_patterns):
        raise ConfigError("every tdd.testPatterns entry must contain {stem}")
    exclude = _string_array(tdd_data["exclude"], "tdd.exclude") if "exclude" in tdd_data else ()
    for item in exclude:
        _repo_path(root, item, "tdd.exclude")

    return HarnessConfig(
        version=1,
        project_type=project_type,
        cmake=CMakeConfig(source_dir, binary_dir, configure_preset, build_preset, test_preset),
        msbuild=MSBuildConfig(solution, configuration, platform, test_command),
        tdd=TDDConfig(test_roots, test_patterns, exclude),
    )
