import json
import locale
import re
import subprocess
import time

from .models import CommandResult, ResultCheckKind

DIAGNOSTIC_LIMIT = 8000


class ValidationFailure(RuntimeError):
    pass


def _decode(value):
    if value is None:
        return ""
    if isinstance(value, str):
        return value
    for encoding in ("utf-8", locale.getpreferredencoding(False)):
        try:
            return value.decode(encoding)
        except (UnicodeDecodeError, LookupError):
            continue
    return value.decode("utf-8", errors="replace")


def _check_result(check, result):
    if check.kind is ResultCheckKind.CMAKE_COMPILER_IS_MSVC:
        if check.path is None:
            raise ValidationFailure("MSVC check is missing binaryDir")
        files = sorted(check.path.glob("CMakeFiles/*/CMakeCXXCompiler.cmake"))
        if not files:
            raise ValidationFailure("CMake did not generate compiler metadata")
        text = files[-1].read_text(encoding="utf-8", errors="replace")
        if not re.search(
            r'set\s*\(\s*CMAKE_CXX_COMPILER_ID\s+"MSVC"\s*\)', text
        ):
            raise ValidationFailure("CMake selected a compiler other than MSVC")
    elif check.kind is ResultCheckKind.CTEST_HAS_TESTS:
        try:
            payload = json.loads(result.stdout)
        except json.JSONDecodeError as exc:
            raise ValidationFailure("CTest discovery did not return JSON") from exc
        if not isinstance(payload, dict):
            raise ValidationFailure("CTest discovery JSON must be an object")
        if not isinstance(payload.get("tests"), list) or not payload["tests"]:
            raise ValidationFailure("CTest discovered no tests")


def _diagnostic(stdout, stderr):
    diagnostic = (stdout + "\n" + stderr).rstrip()[-DIAGNOSTIC_LIMIT:]
    return diagnostic.replace("\r\n", " | ").replace("\n", " | ")


def _command_context(argv, cwd):
    return f"argv={list(argv)!r}; cwd={str(cwd)!r}"


def execute_plan(
    plan,
    root,
    *,
    env=None,
    total_timeout_seconds=1800,
    deadline=None,
    run=subprocess.run,
    clock=time.monotonic,
):
    root = root.resolve()
    if deadline is None:
        deadline = clock() + total_timeout_seconds
    results = []

    for step in plan.steps:
        cwd = step.command.cwd.resolve()
        context = _command_context(step.command.argv, cwd)
        try:
            cwd.relative_to(root)
        except ValueError as exc:
            raise ValidationFailure(
                f"{step.command.stage} working directory is outside the repository; "
                f"{context}"
            ) from exc

        remaining = deadline - clock()
        if remaining <= 0:
            raise ValidationFailure(
                f"{step.command.stage} timed out before it could start; {context}"
            )

        timeout = min(step.command.timeout_seconds, remaining)
        try:
            completed = run(
                list(step.command.argv),
                cwd=cwd,
                env=env,
                capture_output=True,
                shell=False,
                timeout=timeout,
            )
        except subprocess.TimeoutExpired as exc:
            stdout = _decode(exc.output)
            stderr = _decode(exc.stderr)
            diagnostic = _diagnostic(stdout, stderr)
            message = (
                f"{step.command.stage} timed out after {timeout} seconds; {context}"
            )
            if diagnostic:
                message = f"{message}; output tail: {diagnostic}"
            raise ValidationFailure(message) from exc

        stdout = _decode(completed.stdout)
        stderr = _decode(completed.stderr)
        result = CommandResult(step.command, completed.returncode, stdout, stderr)
        if completed.returncode != 0:
            diagnostic = _diagnostic(stdout, stderr)
            message = (
                f"{step.command.stage} failed with exit code {completed.returncode}; "
                f"{context}"
            )
            if diagnostic:
                message = f"{message}; output tail: {diagnostic}"
            raise ValidationFailure(message)

        try:
            for check in step.checks:
                _check_result(check, result)
        except ValidationFailure as exc:
            diagnostic = _diagnostic(stdout, stderr)
            message = (
                f"{step.command.stage} result check failed: {exc}; {context}"
            )
            if diagnostic:
                message = f"{message}; output tail: {diagnostic}"
            raise ValidationFailure(message) from exc
        results.append(result)

    return tuple(results)
