"""Auto-lint support for Otto file editing tools.

Detects the appropriate linter for a file based on project markers
and runs it after edits so the agent sees errors immediately.
"""

import asyncio
import shutil
from dataclasses import dataclass
from pathlib import Path
from typing import Any

from otto.log import get_logger

log = get_logger(__name__)

_LINT_TIMEOUT_SECONDS = 10
_MAX_LINT_OUTPUT_CHARS = 1500


@dataclass(frozen=True)
class LinterConfig:
    """Configuration for a detected linter."""

    name: str
    command: list[str]  # Command template; "{file}" is replaced with the target path.


# ---------------------------------------------------------------------------
# Detection helpers
# ---------------------------------------------------------------------------

_CONFIG_CACHE: dict[Path, LinterConfig | None] = {}


def _has_toml_section(path: Path, section: str) -> bool:
    """Cheaply check whether *path* contains ``[section]`` without a full TOML parse."""
    try:
        text = path.read_text(encoding="utf-8", errors="replace")
    except OSError:
        return False
    return f"[{section}]" in text


def _package_json_has(path: Path, key: str) -> bool:
    """Check whether *package.json* at *path* mentions *key* in devDependencies or dependencies."""
    import json as _json

    try:
        data: dict[str, Any] = _json.loads(path.read_text(encoding="utf-8", errors="replace"))
    except (OSError, ValueError):
        return False
    for section in ("devDependencies", "dependencies"):
        if key in (data.get(section) or {}):
            return True
    return False


def detect_linter(file_path: Path) -> LinterConfig | None:
    """Walk up from *file_path* to find project markers and return a linter config.

    Results are cached per directory for the lifetime of the process.
    """
    directory = file_path.parent.resolve()
    if directory in _CONFIG_CACHE:
        return _CONFIG_CACHE[directory]

    config = _detect_linter_uncached(file_path, directory)
    _CONFIG_CACHE[directory] = config
    return config


def _detect_linter_uncached(file_path: Path, start: Path) -> LinterConfig | None:
    suffix = file_path.suffix.lower()

    # Walk up looking for project markers (max 10 levels to avoid scanning entire FS).
    current = start
    for _ in range(10):
        # --- Python linters ---
        if suffix == ".py":
            if (current / "ruff.toml").exists() or _has_toml_section(
                current / "pyproject.toml", "tool.ruff"
            ):
                ruff = shutil.which("ruff")
                if ruff:
                    return LinterConfig(name="ruff", command=[ruff, "check", "--no-fix", "{file}"])

            if (current / ".flake8").exists() or _has_toml_section(
                current / "pyproject.toml", "tool.flake8"
            ):
                flake8 = shutil.which("flake8")
                if flake8:
                    return LinterConfig(name="flake8", command=[flake8, "{file}"])

        # --- JS/TS linters ---
        if suffix in (".js", ".jsx", ".ts", ".tsx", ".mjs", ".cjs"):
            eslintrc_patterns = [".eslintrc", ".eslintrc.js", ".eslintrc.json", ".eslintrc.yml"]
            has_eslintrc = any((current / p).exists() for p in eslintrc_patterns)
            pkg_json = current / "package.json"
            has_eslint_dep = pkg_json.exists() and _package_json_has(pkg_json, "eslint")

            if has_eslintrc or has_eslint_dep:
                npx = shutil.which("npx")
                if npx:
                    return LinterConfig(
                        name="eslint", command=[npx, "eslint", "--no-fix", "{file}"]
                    )

        # --- Go linter ---
        if suffix == ".go":
            if (current / "go.mod").exists():
                govet = shutil.which("go")
                if govet:
                    return LinterConfig(name="go vet", command=[govet, "vet", "{file}"])

        # --- Rust linter ---
        if suffix == ".rs":
            if (current / "Cargo.toml").exists():
                cargo = shutil.which("cargo")
                if cargo:
                    return LinterConfig(
                        name="cargo check",
                        command=[cargo, "check", "--message-format=short"],
                    )

        # --- Java linter (checkstyle via Maven/Gradle) ---
        # Too project-specific; skip auto-detection for now.

        parent = current.parent
        if parent == current:
            break
        current = parent

    # Fallback: try ruff/flake8 for .py without project markers
    if suffix == ".py":
        ruff = shutil.which("ruff")
        if ruff:
            return LinterConfig(name="ruff", command=[ruff, "check", "--no-fix", "{file}"])

    return None


def clear_cache() -> None:
    """Clear the linter detection cache (useful for testing)."""
    _CONFIG_CACHE.clear()


# ---------------------------------------------------------------------------
# Lint execution
# ---------------------------------------------------------------------------


def _truncate_lint(output: str) -> str:
    if len(output) <= _MAX_LINT_OUTPUT_CHARS:
        return output
    return output[:_MAX_LINT_OUTPUT_CHARS] + "\n... (truncated)"


async def run_lint(file_path: Path, config: LinterConfig) -> str | None:
    """Run *config.command* against *file_path*.

    Returns lint output if there are errors, ``None`` if clean or on failure.
    """
    cmd = [tok.replace("{file}", str(file_path)) for tok in config.command]
    try:
        proc = await asyncio.create_subprocess_exec(
            *cmd,
            stdout=asyncio.subprocess.PIPE,
            stderr=asyncio.subprocess.PIPE,
        )
        stdout, stderr = await asyncio.wait_for(proc.communicate(), timeout=_LINT_TIMEOUT_SECONDS)
    except TimeoutError:
        log.warning("lint timed out", extra={"linter": config.name, "file": str(file_path)})
        return None
    except FileNotFoundError:
        log.warning("linter not found", extra={"linter": config.name, "command": cmd[0]})
        return None
    except Exception as exc:
        log.warning("lint failed", extra={"linter": config.name, "error": str(exc)})
        return None

    if proc.returncode == 0:
        return None  # Clean — no errors.

    output = ((stdout or b"") + (stderr or b"")).decode("utf-8", errors="replace").strip()
    if not output:
        return None

    return _truncate_lint(output)


async def auto_lint(file_path: Path) -> str | None:
    """Detect and run the appropriate linter for *file_path*.

    Returns a formatted error string if lint errors are found, ``None`` otherwise.
    """
    resolved = file_path.resolve()
    config = detect_linter(resolved)
    if config is None:
        return None
    return await run_lint(resolved, config)
