from __future__ import annotations

from dataclasses import dataclass, field
from typing import Any, Dict, List, Optional, Literal

OnFail = Literal["halt", "warn", "pass"]
StepMode = Literal["argv", "shell"]
FileMode = Literal["append", "truncate"]
HookOnFail = Literal["halt", "warn", "pass"]

ALLOWED_INSTALL_TARGETS = {
    "darwin",
    "ubuntu",
    "debian",
    "redhat",
    "rhel",
    "fedora",
    "alpine",
    "arch",
    "amzn",
    "windows",
}

ALLOWED_HOOK_NAMES = {"on_run_start", "on_run_end", "on_step_start", "on_step_end"}


@dataclass(frozen=True)
class BookMeta:
    version: int = 1
    name: Optional[str] = None
    title: Optional[str] = None
    description: Optional[str] = None
    tags: List[str] = field(default_factory=list)


@dataclass(frozen=True)
class Step:
    name: str
    desc: str = ""
    tags: List[str] = field(default_factory=list)

    cwd: Optional[str] = None

    # Exactly one of argv/shell
    mode: StepMode = "argv"
    argv: Optional[List[str]] = None
    shell: Optional[str] = None

    timeout: Optional[float] = None
    retries: int = 0
    retry_delay: float = 0.0
    when: Optional[str] = None
    on_fail: OnFail = "halt"
    requires: List[str] = field(default_factory=list)
    stdin_file: Optional[str] = None
    stdout_file: Optional[str] = None
    stderr_file: Optional[str] = None
    file_mode: Optional[FileMode] = None


@dataclass(frozen=True)
class Requirement:
    name: str
    min_version: Optional[str] = None
    check: Optional[str] = None
    install: Dict[str, str] = field(default_factory=dict)


@dataclass(frozen=True)
class Hook:
    command: str
    on_fail: HookOnFail = "pass"


@dataclass(frozen=True)
class Book:
    meta: BookMeta
    vars: Dict[str, str]
    requires: List[Requirement]
    hooks: Dict[str, Hook]
    sensitive: List[str]
    steps: List[Step]


def _as_str_dict(m: Any, *, path: str) -> Dict[str, str]:
    if m is None:
        return {}
    if not isinstance(m, dict):
        raise ValueError(f"{path} must be a mapping")
    out: Dict[str, str] = {}
    for k, v in m.items():
        out[str(k)] = "" if v is None else str(v)
    return out


def _as_str_list(x: Any, *, path: str) -> List[str]:
    if x is None:
        return []
    if not isinstance(x, list):
        raise ValueError(f"{path} must be a list")
    if not all(isinstance(i, (str, int, float, bool)) or i is None for i in x):
        raise ValueError(f"{path} must contain scalars")
    return ["" if i is None else str(i) for i in x]


def parse_book(data: Dict[str, Any]) -> Book:
    if not isinstance(data, dict):
        raise ValueError("book must be a mapping at the top level")

    version = data.get("version", 1)
    if version != 1:
        raise ValueError(f"Unsupported book version: {version} (expected 1)")
    if "env" in data:
        raise ValueError(
            "book.env is no longer supported; use book.vars with {{ var }} templating"
        )

    meta = BookMeta(
        version=1,
        name=data.get("name"),
        title=data.get("title"),
        description=data.get("description"),
        tags=_as_str_list(data.get("tags"), path="book.tags"),
    )

    vars_map = _as_str_dict(data.get("vars"), path="book.vars")
    requires = _parse_requires(data.get("requires"))
    hooks = _parse_hooks(data.get("hooks"))
    sensitive = _parse_sensitive(data.get("sensitive"))

    steps_raw = data.get("steps")
    if not isinstance(steps_raw, list) or not steps_raw:
        raise ValueError("book.steps must be a non-empty list")

    steps: List[Step] = []
    seen: set[str] = set()

    for i, s in enumerate(steps_raw):
        if not isinstance(s, dict):
            raise ValueError(f"steps[{i}] must be a mapping")

        name = s.get("name")
        if not isinstance(name, str) or not name.strip():
            raise ValueError(f"steps[{i}].name is required")
        name = name.strip()
        if name in seen:
            raise ValueError(f"Duplicate step name: {name}")
        seen.add(name)

        desc = s.get("desc") or ""
        if not isinstance(desc, str):
            raise ValueError(f"steps[{i}].desc must be a string")

        tags = _as_str_list(s.get("tags"), path=f"steps[{i}].tags")

        cwd = s.get("cwd")
        if cwd is not None and not isinstance(cwd, str):
            raise ValueError(f"steps[{i}].cwd must be a string")
        if "env" in s:
            raise ValueError(
                f"steps[{i}].env is no longer supported; use book.vars with {{ var }} templating"
            )

        argv = s.get("argv")
        shell = s.get("shell")

        if argv is not None and shell is not None:
            raise ValueError(f"steps[{i}] must specify only one of argv or shell")
        if argv is None and shell is None:
            raise ValueError(f"steps[{i}] must specify one of argv or shell")

        mode: StepMode
        argv_norm: Optional[List[str]] = None
        shell_norm: Optional[str] = None

        if argv is not None:
            if (
                not isinstance(argv, list)
                or not argv
                or not all(isinstance(x, str) for x in argv)
            ):
                raise ValueError(f"steps[{i}].argv must be a non-empty list of strings")
            mode = "argv"
            argv_norm = list(argv)
        else:
            if not isinstance(shell, str) or not shell.strip():
                raise ValueError(f"steps[{i}].shell must be a non-empty string")
            mode = "shell"
            shell_norm = shell

        timeout = s.get("timeout")
        timeout_norm: Optional[float]
        if timeout is None:
            timeout_norm = None
        else:
            try:
                timeout_norm = float(timeout)
            except Exception as e:
                raise ValueError(f"steps[{i}].timeout must be numeric seconds") from e
            if timeout_norm <= 0:
                raise ValueError(f"steps[{i}].timeout must be greater than 0")

        retries_raw = s.get("retries", 0)
        try:
            retries = int(retries_raw)
        except Exception as e:
            raise ValueError(f"steps[{i}].retries must be an integer >= 0") from e
        if retries < 0:
            raise ValueError(f"steps[{i}].retries must be >= 0")

        retry_delay_raw = s.get("retry_delay", 0)
        try:
            retry_delay = float(retry_delay_raw)
        except Exception as e:
            raise ValueError(
                f"steps[{i}].retry_delay must be numeric seconds >= 0"
            ) from e
        if retry_delay < 0:
            raise ValueError(f"steps[{i}].retry_delay must be >= 0")

        when = s.get("when")
        if when is not None and (not isinstance(when, str) or not when.strip()):
            raise ValueError(f"steps[{i}].when must be a non-empty string if provided")

        on_fail = s.get("on_fail", "halt")
        if on_fail not in ("halt", "warn", "pass"):
            raise ValueError(f"steps[{i}].on_fail must be one of: halt|warn|pass")

        step_requires = _as_str_list(s.get("requires"), path=f"steps[{i}].requires")

        stdin_file = s.get("stdin_file")
        if stdin_file is not None and not isinstance(stdin_file, str):
            raise ValueError(f"steps[{i}].stdin_file must be a string if provided")

        stdout_file = s.get("stdout_file")
        if stdout_file is not None and not isinstance(stdout_file, str):
            raise ValueError(f"steps[{i}].stdout_file must be a string if provided")

        stderr_file = s.get("stderr_file")
        if stderr_file is not None and not isinstance(stderr_file, str):
            raise ValueError(f"steps[{i}].stderr_file must be a string if provided")

        file_mode_raw = s.get("file_mode")
        if file_mode_raw is None:
            file_mode = None
        elif file_mode_raw in ("append", "truncate"):
            file_mode = file_mode_raw
        else:
            raise ValueError(f"steps[{i}].file_mode must be one of: append|truncate")

        steps.append(
            Step(
                name=name,
                desc=desc,
                tags=tags,
                cwd=cwd,
                mode=mode,
                argv=argv_norm,
                shell=shell_norm,
                timeout=timeout_norm,
                retries=retries,
                retry_delay=retry_delay,
                when=when,
                on_fail=on_fail,  # type: ignore[arg-type]
                requires=step_requires,
                stdin_file=stdin_file,
                stdout_file=stdout_file,
                stderr_file=stderr_file,
                file_mode=file_mode,  # type: ignore[arg-type]
            )
        )

    return Book(
        meta=meta,
        vars=vars_map,
        requires=requires,
        hooks=hooks,
        sensitive=sensitive,
        steps=steps,
    )


def _parse_requires(raw: Any) -> List[Requirement]:
    if raw is None:
        return []
    if not isinstance(raw, list):
        raise ValueError("book.requires must be a list")

    out: List[Requirement] = []
    seen: set[str] = set()
    for i, item in enumerate(raw):
        if isinstance(item, str):
            name = item.strip()
            if not name:
                raise ValueError(f"book.requires[{i}] cannot be empty")
            req = Requirement(name=name)
        elif isinstance(item, dict):
            name = item.get("name")
            if not isinstance(name, str) or not name.strip():
                raise ValueError(f"book.requires[{i}].name is required")
            name = name.strip()

            min_version = item.get("min_version")
            if min_version is not None and not isinstance(min_version, str):
                raise ValueError(
                    f"book.requires[{i}].min_version must be a string if provided"
                )

            check = item.get("check")
            if check is not None and (not isinstance(check, str) or not check.strip()):
                raise ValueError(
                    f"book.requires[{i}].check must be a non-empty string if provided"
                )

            install_raw = item.get("install")
            install = _as_str_dict(install_raw, path=f"book.requires[{i}].install")
            for key in install.keys():
                if key not in ALLOWED_INSTALL_TARGETS:
                    raise ValueError(
                        f"book.requires[{i}].install key '{key}' is not allowed; "
                        f"allowed: {', '.join(sorted(ALLOWED_INSTALL_TARGETS))}"
                    )

            req = Requirement(
                name=name, min_version=min_version, check=check, install=install
            )
        else:
            raise ValueError(f"book.requires[{i}] must be a string or mapping")

        key = req.name.lower()
        if key in seen:
            raise ValueError(f"Duplicate requirement name: {req.name}")
        seen.add(key)
        out.append(req)
    return out


def _parse_hooks(raw: Any) -> Dict[str, Hook]:
    if raw is None:
        return {}
    if not isinstance(raw, dict):
        raise ValueError("book.hooks must be a mapping")

    out: Dict[str, Hook] = {}
    for name, val in raw.items():
        if name not in ALLOWED_HOOK_NAMES:
            raise ValueError(
                f"book.hooks key '{name}' is not allowed; allowed: {', '.join(sorted(ALLOWED_HOOK_NAMES))}"
            )

        if isinstance(val, str):
            cmd = val.strip()
            on_fail: HookOnFail = "pass"
        elif isinstance(val, dict):
            cmd_raw = val.get("command", val.get("shell"))
            if not isinstance(cmd_raw, str) or not cmd_raw.strip():
                raise ValueError(f"book.hooks.{name}.command is required")
            cmd = cmd_raw.strip()
            on_fail_raw = val.get("on_fail", "pass")
            if on_fail_raw not in ("halt", "warn", "pass"):
                raise ValueError(
                    f"book.hooks.{name}.on_fail must be one of: halt|warn|pass"
                )
            on_fail = on_fail_raw  # type: ignore[assignment]
        else:
            raise ValueError(f"book.hooks.{name} must be a string or mapping")

        out[name] = Hook(command=cmd, on_fail=on_fail)
    return out


def _parse_sensitive(raw: Any) -> List[str]:
    if raw is None:
        return []
    if not isinstance(raw, list):
        raise ValueError("book.sensitive must be a list of var keys")
    return _as_str_list(raw, path="book.sensitive")
