"""Plugin validation engine — static compliance checks (R1–R13).

Validates a plugin directory against compliance rules without importing
any plugin code. Uses filesystem checks and ``ast`` for class inspection.
"""


import ast
from dataclasses import dataclass, field
from pathlib import Path

try:
    import tomllib
except ModuleNotFoundError:  # Python < 3.11
    import tomli as tomllib  # type: ignore[no-redef]


# ---------------------------------------------------------------------------
# Data classes
# ---------------------------------------------------------------------------


@dataclass
class CheckResult:
    """Outcome of a single compliance check."""

    rule: str  # e.g. "R1", "R2", ..., "R13"
    name: str  # e.g. "docs/ directory"
    passed: bool
    detail: str = ""  # human-readable explanation when failed


@dataclass
class ValidationResult:
    """Aggregated result for one plugin directory."""

    plugin_name: str
    path: Path
    checks: list[CheckResult] = field(default_factory=list)

    @property
    def passed(self) -> bool:
        return all(c.passed for c in self.checks)


# ---------------------------------------------------------------------------
# Canonical rule list
# ---------------------------------------------------------------------------

PLUGIN_RULES: tuple[str, ...] = (
    "R1", "R2", "R3", "R4", "R5", "R6", "R8", "R9", "R10", "R12", "R13",
)
"""Ordered tuple of all compliance rule IDs (R1–R13, skipping R7/R11)."""


# ---------------------------------------------------------------------------
# Internal helpers
# ---------------------------------------------------------------------------


def _read_toml(path: Path) -> dict | None:
    """Parse a TOML file, returning *None* on any error."""
    try:
        return tomllib.loads(path.read_text(encoding="utf-8"))
    except (FileNotFoundError, PermissionError, tomllib.TOMLDecodeError):
        return None


def _check_pyproject(path: Path) -> list[CheckResult]:
    """R8 — entry point declared in pyproject.toml."""
    pyproject_path = path / "pyproject.toml"
    data = _read_toml(pyproject_path)

    if data is None:
        return [
            CheckResult(
                rule="R8",
                name="entry point",
                passed=False,
                detail="pyproject.toml not found",
            )
        ]

    entry_points = (
        data.get("project", {})
        .get("entry-points", {})
        .get("fastpluggy.plugins", {})
    )

    if entry_points:
        return [
            CheckResult(rule="R8", name="entry point", passed=True)
        ]

    return [
        CheckResult(
            rule="R8",
            name="entry point",
            passed=False,
            detail='no [project.entry-points."fastpluggy.plugins"] section or it is empty',
        )
    ]


def _resolve_entry_point_file(path: Path) -> Path | None:
    """Derive the source file from the first pyproject entry-point value.

    Uses ``[tool.setuptools.package-dir]`` to resolve the mapping.  For
    example, if the entry-point is ``fastpluggy_plugin.foo.plugin:FooPlugin``
    and ``package-dir`` maps ``fastpluggy_plugin.foo`` → ``src``, the result
    is ``<path>/src/plugin.py``.

    Falls back to stripping the ``fastpluggy_plugin.`` prefix and resolving
    under ``src/`` if no ``package-dir`` is declared.
    """
    data = _read_toml(path / "pyproject.toml")
    if data is None:
        return None

    entry_points = (
        data.get("project", {})
        .get("entry-points", {})
        .get("fastpluggy.plugins", {})
    )
    if not entry_points:
        return None

    # Take the first entry-point value
    ep_value = next(iter(entry_points.values()))
    module_part = ep_value.split(":")[0]  # e.g. "fastpluggy_plugin.foo.plugin"

    # Try to resolve via [tool.setuptools.package-dir]
    package_dir = (
        data.get("tool", {})
        .get("setuptools", {})
        .get("package-dir", {})
    )

    # Find the longest matching package prefix in package-dir
    best_prefix = ""
    best_dir = ""
    for pkg, directory in package_dir.items():
        if module_part == pkg or module_part.startswith(pkg + "."):
            if len(pkg) > len(best_prefix):
                best_prefix = pkg
                best_dir = directory

    if best_prefix:
        # Strip the matched prefix from the module path
        remainder = module_part[len(best_prefix):]
        if remainder.startswith("."):
            remainder = remainder[1:]
        if remainder:
            rel = remainder.replace(".", "/") + ".py"
        else:
            rel = "__init__.py"
        return path / best_dir / rel

    # Fallback: strip "fastpluggy_plugin." prefix and resolve under src/
    prefix = "fastpluggy_plugin."
    if module_part.startswith(prefix):
        module_part = module_part[len(prefix):]

    rel = module_part.replace(".", "/") + ".py"
    return path / "src" / rel


def _check_plugin_class(path: Path) -> CheckResult:
    """R13 — plugin file contains a class extending FastPluggyBaseModule."""
    source_file = _resolve_entry_point_file(path)
    if source_file is None:
        return CheckResult(
            rule="R13",
            name="plugin class",
            passed=False,
            detail="cannot resolve entry point from pyproject.toml",
        )

    if not source_file.is_file():
        return CheckResult(
            rule="R13",
            name="plugin class",
            passed=False,
            detail=f"entry point file not found: {source_file.relative_to(path)}",
        )

    try:
        tree = ast.parse(source_file.read_text(encoding="utf-8"), filename=str(source_file))
    except SyntaxError as exc:
        return CheckResult(
            rule="R13",
            name="plugin class",
            passed=False,
            detail=f"syntax error in {source_file.name}: {exc}",
        )

    for node in ast.walk(tree):
        if not isinstance(node, ast.ClassDef):
            continue
        for base in node.bases:
            base_name: str | None = None
            if isinstance(base, ast.Name):
                base_name = base.id
            elif isinstance(base, ast.Attribute):
                base_name = base.attr
            if base_name == "FastPluggyBaseModule":
                return CheckResult(
                    rule="R13",
                    name="plugin class",
                    passed=True,
                )

    return CheckResult(
        rule="R13",
        name="plugin class",
        passed=False,
        detail="no class extending FastPluggyBaseModule",
    )


# ---------------------------------------------------------------------------
# Public API
# ---------------------------------------------------------------------------


def validate_plugin(path: Path) -> ValidationResult:
    """Validate a plugin directory against compliance rules R1–R13.

    Parameters
    ----------
    path:
        Absolute or relative path to the plugin root directory
        (the folder that contains ``pyproject.toml``).

    Returns
    -------
    ValidationResult
        Aggregated results for every applicable rule.
    """
    plugin_name = path.name
    result = ValidationResult(plugin_name=plugin_name, path=path)

    # R1 — docs/ directory
    result.checks.append(
        CheckResult(
            rule="R1",
            name="docs/ directory",
            passed=(path / "docs").is_dir(),
            detail="" if (path / "docs").is_dir() else "docs/ directory not found",
        )
    )

    # R2 — README.md exists and is non-empty
    readme = path / "README.md"
    readme_ok = readme.is_file() and readme.stat().st_size > 0
    result.checks.append(
        CheckResult(
            rule="R2",
            name="README.md",
            passed=readme_ok,
            detail="" if readme_ok else "README.md missing or empty",
        )
    )

    # R3 — pyproject.toml has [e2e] optional dependencies
    pyproject_data = _read_toml(path / "pyproject.toml")
    if pyproject_data is not None:
        optional_deps = pyproject_data.get("project", {}).get("optional-dependencies", {})
        has_e2e_deps = "e2e" in optional_deps
    else:
        has_e2e_deps = False
    result.checks.append(
        CheckResult(
            rule="R3",
            name="e2e optional deps",
            passed=has_e2e_deps,
            detail="" if has_e2e_deps else "[e2e] key not found in [project.optional-dependencies]",
        )
    )

    # R4 — e2e/ directory exists (+ lockfile check)
    has_e2e_dir = (path / "e2e").is_dir()
    r4_detail = ""
    if not has_e2e_dir:
        r4_detail = "e2e/ directory not found"
    elif (path / "e2e" / "package.json").is_file() and not (path / "e2e" / "package-lock.json").is_file():
        r4_detail = "package-lock.json missing"
    result.checks.append(
        CheckResult(
            rule="R4",
            name="e2e/ directory",
            passed=has_e2e_dir,
            detail=r4_detail,
        )
    )

    # R5 — .gitlab-ci.yml exists and contains e2e_fastpluggy_test
    ci_path = path / ".gitlab-ci.yml"
    ci_ok = False
    ci_detail = ""
    if ci_path.is_file():
        try:
            ci_content = ci_path.read_text(encoding="utf-8")
            ci_ok = "e2e_fastpluggy_test" in ci_content
            if not ci_ok:
                ci_detail = ".gitlab-ci.yml does not extend e2e_fastpluggy_test"
        except (PermissionError, OSError):
            ci_detail = "cannot read .gitlab-ci.yml"
    else:
        ci_detail = ".gitlab-ci.yml not found"
    result.checks.append(
        CheckResult(rule="R5", name="CI template", passed=ci_ok, detail=ci_detail)
    )

    # R6 — docs/screenshots/e2e/ directory exists
    screenshots_ok = (path / "docs" / "screenshots" / "e2e").is_dir()
    result.checks.append(
        CheckResult(
            rule="R6",
            name="e2e screenshots",
            passed=screenshots_ok,
            detail="" if screenshots_ok else "docs/screenshots/e2e/ not found",
        )
    )

    # R9 — tests/ directory with at least one test_*.py
    tests_dir = path / "tests"
    has_tests = tests_dir.is_dir() and any(tests_dir.glob("test_*.py"))
    result.checks.append(
        CheckResult(
            rule="R9",
            name="tests/ directory",
            passed=has_tests,
            detail="" if has_tests else "tests/ missing or contains no test_*.py files",
        )
    )

    # R10 — .gitignore exists and covers essential patterns
    gitignore = path / ".gitignore"
    _r10_required = ["__pycache__/", "*.py[cod]", ".env", "*.db", "*.egg-info"]
    if gitignore.is_file():
        gi_text = gitignore.read_text()
        missing = [p for p in _r10_required if p not in gi_text]
        if missing:
            r10_ok = False
            r10_detail = f".gitignore missing patterns: {', '.join(missing)}"
        else:
            r10_ok = True
            r10_detail = ""
    else:
        r10_ok = False
        r10_detail = ".gitignore not found"
    result.checks.append(
        CheckResult(rule="R10", name=".gitignore", passed=r10_ok, detail=r10_detail)
    )

    # R12 — e2e/routes.json exists (only when e2e/ dir is present)
    if has_e2e_dir:
        routes_ok = (path / "e2e" / "routes.json").is_file()
        result.checks.append(
            CheckResult(
                rule="R12",
                name="e2e/routes.json",
                passed=routes_ok,
                detail="" if routes_ok else "e2e/routes.json not found",
            )
        )
    # When no e2e/ dir, R12 is omitted — consumers treat missing rules as N/A

    # R8 — entry point in pyproject.toml
    result.checks.extend(_check_pyproject(path))

    # R13 — plugin class extending FastPluggyBaseModule
    result.checks.append(_check_plugin_class(path))

    return result


def validate_repo(path: Path) -> ValidationResult:
    """Validate any repo directory — auto-detects plugin vs non-plugin.

    Plugin repos (those declaring ``fastpluggy.plugins`` entry points in
    ``pyproject.toml``) receive the full R1–R13 validation via
    :func:`validate_plugin`.

    Non-plugin repos receive a subset of applicable checks:
    R1 (docs/), R2 (README), R4 (e2e/), R5 (CI), R6 (screenshots),
    R10 (.gitignore).  Plugin-specific rules are omitted from the result.

    Parameters
    ----------
    path:
        Absolute or relative path to the repo root directory.

    Returns
    -------
    ValidationResult
        Aggregated results for every applicable rule.
    """
    data = _read_toml(path / "pyproject.toml")
    if data is not None:
        entry_points = (
            data.get("project", {})
            .get("entry-points", {})
            .get("fastpluggy.plugins", {})
        )
        if entry_points:
            return validate_plugin(path)

    # Non-plugin repo — subset checks
    repo_name = path.name
    result = ValidationResult(plugin_name=repo_name, path=path)

    # R1 — docs/ directory
    has_docs = (path / "docs").is_dir()
    result.checks.append(
        CheckResult(
            rule="R1",
            name="docs/ directory",
            passed=has_docs,
            detail="" if has_docs else "docs/ directory not found",
        )
    )

    # R2 — README.md exists and is non-empty
    readme = path / "README.md"
    readme_ok = readme.is_file() and readme.stat().st_size > 0
    result.checks.append(
        CheckResult(
            rule="R2",
            name="README.md",
            passed=readme_ok,
            detail="" if readme_ok else "README.md missing or empty",
        )
    )

    # R4 — e2e/ directory (+ lockfile check)
    e2e_dir = path / "e2e"
    has_e2e = e2e_dir.is_dir()
    r4_detail = ""
    if not has_e2e:
        r4_detail = "e2e/ directory not found"
    elif (e2e_dir / "package.json").is_file() and not (e2e_dir / "package-lock.json").is_file():
        r4_detail = "package-lock.json missing"
    result.checks.append(
        CheckResult(
            rule="R4",
            name="e2e/ directory",
            passed=has_e2e,
            detail=r4_detail,
        )
    )

    # R5 — .gitlab-ci.yml exists and contains e2e_fastpluggy_test
    ci_path = path / ".gitlab-ci.yml"
    ci_ok = False
    ci_detail = ""
    if ci_path.is_file():
        try:
            ci_content = ci_path.read_text(encoding="utf-8")
            ci_ok = "e2e_fastpluggy_test" in ci_content
            if not ci_ok:
                ci_detail = ".gitlab-ci.yml does not extend e2e_fastpluggy_test"
        except (PermissionError, OSError):
            ci_detail = "cannot read .gitlab-ci.yml"
    else:
        ci_detail = ".gitlab-ci.yml not found"
    result.checks.append(
        CheckResult(rule="R5", name="CI template", passed=ci_ok, detail=ci_detail)
    )

    # R6 — docs/screenshots/e2e/ directory exists
    screenshots_ok = (path / "docs" / "screenshots" / "e2e").is_dir()
    result.checks.append(
        CheckResult(
            rule="R6",
            name="e2e screenshots",
            passed=screenshots_ok,
            detail="" if screenshots_ok else "docs/screenshots/e2e/ not found",
        )
    )

    # R10 — .gitignore exists and covers essential patterns
    gitignore = path / ".gitignore"
    _r10_required = ["__pycache__/", "*.py[cod]", ".env", "*.db", "*.egg-info"]
    if gitignore.is_file():
        gi_text = gitignore.read_text()
        missing = [p for p in _r10_required if p not in gi_text]
        if missing:
            r10_ok = False
            r10_detail = f".gitignore missing patterns: {', '.join(missing)}"
        else:
            r10_ok = True
            r10_detail = ""
    else:
        r10_ok = False
        r10_detail = ".gitignore not found"
    result.checks.append(
        CheckResult(rule="R10", name=".gitignore", passed=r10_ok, detail=r10_detail)
    )

    return result
