"""Project actions: run named scripts from ``pyproject.toml`` or ``actions.toml``."""

from __future__ import annotations

import os
import subprocess
import tomllib
from abc import abstractmethod
from dataclasses import dataclass
from pathlib import Path

from abcreg import AnnotatedABC  # type: ignore[import-untyped]

_TOOL_TABLE = "pgt"
_ACTIONS_KEY = "actions"
_ACTIONS_FILE = "actions.toml"
_DEFAULT_SET_NAME = "default"
_FLAT_FILE_SET_NAME = "actions"


@dataclass(frozen=True, slots=True)
class ActionSpec:
    name: str
    script: str
    args: tuple[str, ...] = ()


@dataclass(frozen=True, slots=True)
class ActionSet:
    """A named group of actions the TUI can cycle through."""

    name: str
    specs: tuple[ActionSpec, ...]


class Action(AnnotatedABC):
    """Run a named project action and return ``(ok, message)``."""

    @abstractmethod
    def action(self, name: str) -> tuple[bool, str]: ...


def find_project_root(start: Path) -> Path | None:
    """Walk upward for ``pyproject.toml``, else the nearest ``actions.toml``."""
    current = start.resolve()
    actions_root: Path | None = None
    for path in [current, *current.parents]:
        if (path / "pyproject.toml").is_file():
            return path
        if actions_root is None and (path / _ACTIONS_FILE).is_file():
            actions_root = path
    return actions_root


def load_action_specs(project_root: Path) -> list[ActionSpec]:
    """Read ``[tool.pgt].actions`` from the project's ``pyproject.toml``."""
    pgt = _pyproject_pgt(project_root)
    if pgt is None:
        return []
    raw = pgt.get(_ACTIONS_KEY)
    if raw is None:
        return []
    return _parse_actions(raw, where="[tool.pgt].actions")


def load_action_sets(project_root: Path) -> list[ActionSet]:
    """Load action sets from ``pyproject.toml`` and a root ``actions.toml``.

    ``[tool.pgt].actions`` is one set (named ``default``, or ``[tool.pgt].name``).
    ``[tool.pgt.sets.<name>]`` tables are further sets. ``actions.toml`` may repeat
    that shape, and each other top-level table is also a set:

    .. code-block:: toml

    [quality]
    actions = [
            { name = "lint", script = "scripts/lint.py" },
    ]
    """
    sets = [*_pyproject_sets(project_root), *_actions_file_sets(project_root)]
    seen: set[str] = set()
    for action_set in sets:
        if action_set.name in seen:
            raise ValueError(f"Duplicate action set name: {action_set.name}")
        seen.add(action_set.name)
    return sets


def _pyproject_pgt(project_root: Path) -> dict[str, object] | None:
    pyproject = project_root / "pyproject.toml"
    if not pyproject.is_file():
        return None
    data = tomllib.loads(pyproject.read_text(encoding="utf-8"))
    tool = data.get("tool")
    if not isinstance(tool, dict):
        return None
    pgt = tool.get(_TOOL_TABLE)
    if not isinstance(pgt, dict):
        return None
    return pgt


def _pyproject_sets(project_root: Path) -> list[ActionSet]:
    pgt = _pyproject_pgt(project_root)
    if pgt is None:
        return []
    sets: list[ActionSet] = []
    flat = _parse_flat_set(
        pgt,
        default_name=_DEFAULT_SET_NAME,
        actions_where="[tool.pgt].actions",
        name_where="[tool.pgt].name",
    )
    if flat is not None:
        sets.append(flat)
    raw_sets = pgt.get("sets")
    if raw_sets is not None:
        sets.extend(_parse_named_tables(raw_sets, where="[tool.pgt].sets"))
    return sets


def _actions_file_sets(project_root: Path) -> list[ActionSet]:
    path = project_root / _ACTIONS_FILE
    if not path.is_file():
        return []
    data = tomllib.loads(path.read_text(encoding="utf-8"))
    if not isinstance(data, dict):
        raise ValueError("actions.toml must be a table")
    sets: list[ActionSet] = []
    actions_value = data.get(_ACTIONS_KEY)
    consumed = {"name"}
    if isinstance(actions_value, list):
        consumed.add(_ACTIONS_KEY)
        flat = _parse_flat_set(
            data,
            default_name=_FLAT_FILE_SET_NAME,
            actions_where="actions.toml actions",
            name_where="actions.toml name",
        )
        if flat is not None:
            sets.append(flat)
    elif "name" in data:
        raise ValueError("actions.toml 'name' requires an 'actions' array")
    for key, value in data.items():
        if key in consumed:
            continue
        if not isinstance(key, str) or not key.strip():
            raise ValueError("actions.toml set name must be a non-empty string")
        if not isinstance(value, dict):
            raise ValueError(f"actions.toml unexpected key {key!r}")
        specs = _specs_from_set_table(value, where=f"actions.toml [{key.strip()}]")
        if specs:
            sets.append(ActionSet(name=key.strip(), specs=specs))
    return sets


def _parse_flat_set(
    data: dict[str, object],
    *,
    default_name: str,
    actions_where: str,
    name_where: str,
) -> ActionSet | None:
    if _ACTIONS_KEY not in data:
        if "name" in data:
            raise ValueError(f"{name_where} requires 'actions'")
        return None
    raw_name = data.get("name", default_name)
    if not isinstance(raw_name, str) or not raw_name.strip():
        raise ValueError(f"{name_where} must be a non-empty string")
    specs = tuple(_parse_actions(data[_ACTIONS_KEY], where=actions_where))
    if not specs:
        return None
    return ActionSet(name=raw_name.strip(), specs=specs)


def _parse_named_tables(raw: object, *, where: str) -> list[ActionSet]:
    if not isinstance(raw, dict):
        raise ValueError(f"{where} must be a table of action sets")
    sets: list[ActionSet] = []
    for key, value in raw.items():
        if not isinstance(key, str) or not key.strip():
            raise ValueError(f"{where} set name must be a non-empty string")
        if not isinstance(value, dict):
            raise ValueError(f"{where}.{key} must be a table")
        name = key.strip()
        specs = _specs_from_set_table(value, where=f"{where}.{name}")
        if specs:
            sets.append(ActionSet(name=name, specs=specs))
    return sets


def _specs_from_set_table(value: dict[str, object], *, where: str) -> tuple[ActionSpec, ...]:
    if _ACTIONS_KEY not in value:
        raise ValueError(f"{where} missing 'actions'")
    return tuple(_parse_actions(value[_ACTIONS_KEY], where=f"{where}.actions"))


def _parse_actions(raw: object, *, where: str) -> list[ActionSpec]:
    if not isinstance(raw, list):
        raise ValueError(f"{where} must be an array of name/script entries")
    specs: list[ActionSpec] = []
    for index, entry in enumerate(raw):
        specs.append(_parse_action_entry(entry, where=f"{where}[{index}]"))
    return specs


def _parse_action_entry(entry: object, *, where: str) -> ActionSpec:
    if isinstance(entry, dict):
        name = entry.get("name")
        script = entry.get("script")
        if not isinstance(name, str) or not name.strip():
            raise ValueError(f"{where} missing string 'name'")
        if not isinstance(script, str) or not script.strip():
            raise ValueError(f"{where} missing string 'script'")
        return ActionSpec(name=name.strip(), script=script.strip(), args=_parse_args(entry.get("args"), where=where))
    if isinstance(entry, list) and len(entry) == 2:
        name, script = entry
        if not isinstance(name, str) or not name.strip():
            raise ValueError(f"{where} name must be a non-empty string")
        if not isinstance(script, str) or not script.strip():
            raise ValueError(f"{where} script must be a non-empty string")
        return ActionSpec(name=name.strip(), script=script.strip())
    raise ValueError(f"{where} must be {{name=..., script=...}} or [name, script]")


def _parse_args(raw: object, *, where: str) -> tuple[str, ...]:
    if raw is None:
        return ()
    if not isinstance(raw, list):
        raise ValueError(f"{where} 'args' must be an array of strings")
    args: list[str] = []
    for index, item in enumerate(raw):
        if not isinstance(item, str) or item == "":
            raise ValueError(f"{where} args[{index}] must be a non-empty string")
        args.append(item)
    return tuple(args)


def project_venv_python(project_root: Path) -> Path | None:
    candidate = project_root / ".venv" / "bin" / "python"
    if candidate.is_file():
        return candidate
    windows = project_root / ".venv" / "Scripts" / "python.exe"
    if windows.is_file():
        return windows
    return None


def run_action_script(project_root: Path, script: str, args: tuple[str, ...] = ()) -> tuple[bool, str]:
    """Execute ``script`` inside the project's ``.venv`` and return ``(ok, message)``."""
    raw_script = Path(script)
    script_path = raw_script.resolve() if raw_script.is_absolute() else (project_root / raw_script).resolve()
    if not script_path.is_file():
        return False, f"Script not found: {script_path}"

    venv_python = project_venv_python(project_root)
    if venv_python is None:
        return False, f"Project venv not found under {project_root / '.venv'}"

    venv_dir = venv_python.parent.parent
    venv_bin = venv_python.parent
    env = os.environ.copy()
    env["VIRTUAL_ENV"] = str(venv_dir)
    env["PATH"] = f"{venv_bin}{os.pathsep}{env.get('PATH', '')}"
    env.pop("PYTHONHOME", None)

    if script_path.suffix == ".py":
        cmd = [str(venv_python), str(script_path), *args]
    elif os.access(script_path, os.X_OK):
        cmd = [str(script_path), *args]
    else:
        cmd = ["bash", str(script_path), *args]

    proc = subprocess.run(
        cmd,
        cwd=project_root,
        capture_output=True,
        text=True,
        env=env,
        check=False,
    )
    output = _result_message(proc.stdout, proc.stderr)
    if proc.returncode == 0:
        return True, output or "ok"
    if output:
        return False, output
    return False, f"exit {proc.returncode}"


def _result_message(stdout: str, stderr: str) -> str:
    text = (stdout or "").strip() or (stderr or "").strip()
    if not text:
        return ""
    lines = text.splitlines()
    return lines[-1] if lines else text


@Action.register
class ProjectActions:
    """Default action runner backed by ``[tool.pgt].actions``.

    Pass ``specs`` to run one set loaded by :func:`load_action_sets`.
    """

    def __init__(self, project_root: Path, specs: list[ActionSpec] | None = None) -> None:
        self._root = project_root.resolve()
        self._specs = {spec.name: spec for spec in (specs if specs is not None else load_action_specs(self._root))}

    @property
    def specs(self) -> list[ActionSpec]:
        return list(self._specs.values())

    def action(self, name: str) -> tuple[bool, str]:
        spec = self._specs.get(name)
        if spec is None:
            return False, f"Unknown action: {name}"
        return run_action_script(self._root, spec.script, spec.args)


def project_actions(project_root: Path) -> ProjectActions:
    return ProjectActions(project_root)