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