"""Project actions: run named scripts from ``pyproject.toml`` in the project venv."""

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"


@dataclass(frozen=True, slots=True)
class ActionSpec:
	name: str
	script: str


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 from ``start`` looking for a ``pyproject.toml``."""
	current = start.resolve()
	for path in [current, *current.parents]:
		if (path / "pyproject.toml").is_file():
			return path
	return None


def load_action_specs(project_root: Path) -> list[ActionSpec]:
	"""Read ``[tool.pgt].actions`` from the project's ``pyproject.toml``."""
	pyproject = project_root / "pyproject.toml"
	if not pyproject.is_file():
		return []
	data = tomllib.loads(pyproject.read_text(encoding="utf-8"))
	tool = data.get("tool")
	if not isinstance(tool, dict):
		return []
	pgt = tool.get(_TOOL_TABLE)
	if not isinstance(pgt, dict):
		return []
	raw = pgt.get(_ACTIONS_KEY)
	if raw is None:
		return []
	return _parse_actions(raw)


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


def _parse_action_entry(entry: object, index: int) -> 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"[tool.pgt].actions[{index}] missing string 'name'")
		if not isinstance(script, str) or not script.strip():
			raise ValueError(f"[tool.pgt].actions[{index}] missing string 'script'")
		return ActionSpec(name=name.strip(), script=script.strip())
	if isinstance(entry, list) and len(entry) == 2:
		name, script = entry
		if not isinstance(name, str) or not name.strip():
			raise ValueError(f"[tool.pgt].actions[{index}] name must be a non-empty string")
		if not isinstance(script, str) or not script.strip():
			raise ValueError(f"[tool.pgt].actions[{index}] script must be a non-empty string")
		return ActionSpec(name=name.strip(), script=script.strip())
	raise ValueError(
		f"[tool.pgt].actions[{index}] must be {{name=..., script=...}} or [name, script]",
	)


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) -> 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)]
	elif os.access(script_path, os.X_OK):
		cmd = [str(script_path)]
	else:
		cmd = ["bash", str(script_path)]

	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``."""

	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)


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