from __future__ import annotations

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

OnFail = Literal["halt", "warn", "pass"]
StepMode = Literal["argv", "module"]
HookOnFail = Literal["halt", "warn", "pass"]
RequirementType = Literal["os", "py"]

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


@dataclass(frozen=True)
class BookMeta:
    kind: str = "pyplines.runbook.v1"
    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 sh/fn in YAML.
    mode: StepMode = "argv"
    argv: Optional[List[str]] = None
    module: Optional[str] = None

    timeout: Optional[float] = None
    retries: int = 0
    retry_delay: float = 0.0
    when: Optional[str] = None
    on_fail: OnFail = "halt"
    vars_map: Dict[str, str] = field(default_factory=dict)
    outputs_map: Dict[str, str] = field(default_factory=dict)


@dataclass(frozen=True)
class Requirement:
    name: str
    req_type: RequirementType = "os"
    min_version: Optional[str] = None
    check: Optional[str] = None
    install_cmd: Optional[str] = None


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


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


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")

    kind = data.get("kind")
    if kind != "pyplines.runbook.v1":
        if "version" in data:
            raise ValueError(
                "Legacy runbooks are no longer supported; use kind: pyplines.runbook.v1"
            )
        raise ValueError("book.kind is required and must be 'pyplines.runbook.v1'")

    return _parse_book_kind_v1(data)


def _parse_book_kind_v1(data: Dict[str, Any]) -> Book:
    meta = BookMeta(
        kind="pyplines.runbook.v1",
        name=data.get("name"),
        title=data.get("title"),
        description=data.get("description"),
        tags=_as_str_list(data.get("tags"), path="book.tags"),
    )
    inputs = _parse_inputs(data.get("inputs"))
    input_defaults = _input_defaults_as_vars(inputs)
    requires = _parse_packages(data.get("packages"))
    hooks = _parse_hooks(data.get("hooks"))

    outputs_raw = data.get("outputs")
    outputs = _parse_top_outputs(outputs_raw)

    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")

        sh = s.get("sh")
        fn = s.get("fn")
        mode_count = sum(x is not None for x in (sh, fn))
        if mode_count != 1:
            raise ValueError(f"steps[{i}] must specify exactly one of: sh | fn")
        if sh is not None:
            if (
                not isinstance(sh, list)
                or not sh
                or not all(isinstance(x, str) and x.strip() for x in sh)
            ):
                raise ValueError(f"steps[{i}].sh must be a non-empty list of strings")
            mode: StepMode = "argv"
            argv_norm: Optional[List[str]] = list(sh)
            module_norm: Optional[str] = None
        else:
            if not isinstance(fn, str) or not fn.strip():
                raise ValueError(f"steps[{i}].fn must be a non-empty string")
            mode = "module"
            argv_norm = None
            module_norm = fn.strip()

        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")

        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")

        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_vars_map = _as_expr_dict(s.get("vars"), path=f"steps[{i}].vars")
        outputs_map = _as_expr_dict(s.get("outputs"), path=f"steps[{i}].outputs")

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

        steps.append(
            Step(
                name=name,
                desc=desc,
                tags=tags,
                cwd=cwd,
                mode=mode,
                argv=argv_norm,
                module=module_norm,
                timeout=timeout_norm,
                retries=retries,
                retry_delay=retry_delay,
                when=when,
                on_fail=on_fail,  # type: ignore[arg-type]
                vars_map=step_vars_map,
                outputs_map=outputs_map,
            )
        )

    return Book(
        meta=meta,
        vars=input_defaults,
        inputs=inputs,
        requires=requires,
        outputs=outputs,
        hooks=hooks,
        sensitive=[],
        steps=steps,
    )


def _parse_packages(raw: Any) -> List[Requirement]:
    if raw is None:
        return []
    if not isinstance(raw, dict):
        raise ValueError("book.packages must be a mapping")
    allowed = {"os", "py"}
    unknown = [k for k in raw.keys() if k not in allowed]
    if unknown:
        raise ValueError(
            f"book.packages contains unsupported key(s): {', '.join(sorted(unknown))}"
        )

    out: List[Requirement] = []
    seen: set[str] = set()
    for key, req_type in (("os", "os"), ("py", "py")):
        items = raw.get(key)
        if items is None:
            continue
        if not isinstance(items, list):
            raise ValueError(f"book.packages.{key} must be a list")
        for i, item in enumerate(items):
            req = _parse_requirement_item(
                item=item,
                path=f"book.packages.{key}[{i}]",
                req_type=req_type,  # type: ignore[arg-type]
            )
            rkey = req.name.lower()
            if rkey in seen:
                raise ValueError(f"Duplicate requirement name: {req.name}")
            seen.add(rkey)
            out.append(req)
    return out


def _parse_inputs(raw: Any) -> Dict[str, Dict[str, Any]]:
    if raw is None:
        return {}
    if not isinstance(raw, dict):
        raise ValueError("book.inputs must be a mapping")
    out: Dict[str, Dict[str, Any]] = {}
    for key, val in raw.items():
        name = str(key).strip()
        if not name:
            raise ValueError("book.inputs contains an empty input name")
        if not isinstance(val, dict):
            raise ValueError(f"book.inputs.{name} must be a mapping")
        typ = val.get("type")
        if typ not in ("string", "boolean", "array", "number", "integer"):
            raise ValueError(
                f"book.inputs.{name}.type must be one of: string|boolean|array|number|integer"
            )
        out[name] = dict(val)
    return out


def _input_defaults_as_vars(inputs: Dict[str, Dict[str, Any]]) -> Dict[str, str]:
    out: Dict[str, str] = {}
    for name, spec in inputs.items():
        if "default" not in spec:
            continue
        v = spec.get("default")
        if isinstance(v, list):
            import json

            out[name] = json.dumps(v)
        elif v is None:
            out[name] = ""
        elif isinstance(v, bool):
            out[name] = "true" if v else "false"
        else:
            out[name] = str(v)
    return out


def _as_expr_dict(raw: Any, *, path: str) -> Dict[str, str]:
    if raw is None:
        return {}
    if not isinstance(raw, dict):
        raise ValueError(f"{path} must be a mapping")
    out: Dict[str, str] = {}
    for k, v in raw.items():
        key = str(k).strip()
        if not key:
            raise ValueError(f"{path} contains an empty key")
        if not isinstance(v, str) or not v.strip():
            raise ValueError(f"{path}.{key} must be a non-empty CEL expression string")
        out[key] = v.strip()
    return out


def _parse_top_outputs(raw: Any) -> Dict[str, Dict[str, Any]]:
    if raw is None:
        return {}
    if not isinstance(raw, dict):
        raise ValueError("book.outputs must be a mapping")
    out: Dict[str, Dict[str, Any]] = {}
    for key, val in raw.items():
        name = str(key).strip()
        if not name:
            raise ValueError("book.outputs contains an empty output name")
        if not isinstance(val, dict):
            raise ValueError(f"book.outputs.{name} must be a mapping")
        value_expr = val.get("value")
        if not isinstance(value_expr, str) or not value_expr.strip():
            raise ValueError(f"book.outputs.{name}.value must be a non-empty string")
        when_expr = val.get("when")
        if when_expr is not None and (
            not isinstance(when_expr, str) or not when_expr.strip()
        ):
            raise ValueError(
                f"book.outputs.{name}.when must be a non-empty string if provided"
            )
        out[name] = dict(val)
    return out


def _parse_requirement_item(
    *, item: Any, path: str, req_type: RequirementType
) -> Requirement:
    if isinstance(item, str):
        name = item.strip()
        if not name:
            raise ValueError(f"{path} cannot be empty")
        return Requirement(name=name, req_type=req_type)

    if not isinstance(item, dict):
        raise ValueError(f"{path} must be a string or mapping")

    name = item.get("name")
    if not isinstance(name, str) or not name.strip():
        raise ValueError(f"{path}.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"{path}.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"{path}.check must be a non-empty string if provided")

    install_cmd = item.get("install")
    install_cmd_norm: Optional[str] = None
    if install_cmd is not None:
        if not isinstance(install_cmd, str) or not install_cmd.strip():
            raise ValueError(f"{path}.install must be a non-empty string")
        install_cmd_norm = install_cmd.strip()

    # Standardized environment defaults (Rocky + pip) when install omitted.
    if install_cmd_norm is None:
        if req_type == "os":
            install_cmd_norm = f"dnf install -y {name}"
        else:
            pkg_spec = (
                f"{name}{min_version}"
                if min_version and min_version.startswith(">=")
                else name
            )
            install_cmd_norm = f"python3 -m pip install {pkg_spec}"

    return Requirement(
        name=name,
        req_type=req_type,
        min_version=min_version,
        check=check,
        install_cmd=install_cmd_norm,
    )


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")
            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
