"""Agent and skill file validator for Sage agent configurations."""

from __future__ import annotations

import logging
import re
from pathlib import Path
from typing import Any

from pydantic import ValidationError as PydanticValidationError
from sage.config import AgentConfig
from sage.frontmatter import parse_frontmatter

from sage_evaluator.models import Severity, ValidationIssue, ValidationResult

logger = logging.getLogger(__name__)

# ── Constants ────────────────────────────────────────────────────────────────

#: Built-in tool names that the Sage agent runtime provides natively.
BUILTIN_TOOLS: frozenset[str] = frozenset(
    {
        "shell",
        "file_read",
        "file_write",
        "http_request",
        "memory_store",
        "memory_recall",
    }
)

#: Regex for a dotted Python module path, optionally followed by :name.
#: Examples: "my_module.tools", "package.subpackage:my_tool"
_MODULE_PATH_RE = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*(\.[A-Za-z_][A-Za-z0-9_]*)*(:[\w]+)?$")

#: Minimum acceptable body length before a "very short" warning fires.
_SHORT_BODY_THRESHOLD = 50

#: Default value for max_turns in AgentConfig (used to suggest review).
_DEFAULT_MAX_TURNS = 10


# ── Public API ───────────────────────────────────────────────────────────────


class AgentValidator:
    """Validate Sage agent and skill markdown files at three levels.

    Levels (applied in order):
    1. **Structural** – frontmatter can be parsed and the Pydantic model
       accepts the fields.
    2. **Semantic** – field values make sense (model format, tool names,
       subagent paths).
    3. **Best-practice** – heuristic warnings that do not block execution
       but indicate likely oversights.

    Example::

        validator = AgentValidator()
        result = validator.validate("path/to/AGENTS.md")
        for issue in result.issues:
            print(issue.severity, issue.message)
    """

    def validate(self, path: str | Path) -> ValidationResult:
        """Validate a single agent or skill file.

        Args:
            path: Absolute or relative path to a ``.md`` file.  If a
                directory is given, ``AGENTS.md`` inside it is checked.

        Returns:
            A :class:`~sage_evaluator.models.ValidationResult` whose
            ``valid`` flag is ``True`` when no ERROR-level issues exist.
        """
        resolved = Path(path)
        if resolved.is_dir():
            resolved = resolved / "AGENTS.md"

        logger.info("Validating %s", resolved)
        issues: list[ValidationIssue] = []

        # ── File existence ────────────────────────────────────────────────
        if not resolved.exists():
            issues.append(
                ValidationIssue(
                    severity=Severity.ERROR,
                    message=f"File not found: {resolved}",
                    path=str(resolved),
                )
            )
            return ValidationResult(path=str(path), valid=False, issues=issues)

        try:
            raw = resolved.read_text(encoding="utf-8")
        except OSError as exc:
            issues.append(
                ValidationIssue(
                    severity=Severity.ERROR,
                    message=f"Cannot read file: {exc}",
                    path=str(resolved),
                )
            )
            return ValidationResult(path=str(path), valid=False, issues=issues)

        # ── Parse frontmatter ─────────────────────────────────────────────
        meta, body = parse_frontmatter(raw)

        if not meta:
            issues.append(
                ValidationIssue(
                    severity=Severity.ERROR,
                    message="No valid YAML frontmatter found (file must start with '---').",
                    path=str(resolved),
                )
            )
            return ValidationResult(path=str(path), valid=False, issues=issues)

        # ── Detect skill vs agent ─────────────────────────────────────────
        # AGENTS.md is always treated as an agent file regardless of content.
        # For other filenames, use frontmatter heuristics.
        is_skill = resolved.name.upper() != "AGENTS.MD" and _looks_like_skill(meta)

        if is_skill:
            issues.extend(_validate_skill(meta, body, resolved))
        else:
            issues.extend(_validate_agent(meta, body, resolved))

        valid = not any(i.severity == Severity.ERROR for i in issues)
        logger.info(
            "Validation result for %s: %s (%d issue(s))",
            path,
            "PASS" if valid else "FAIL",
            len(issues),
        )
        return ValidationResult(path=str(path), valid=valid, issues=issues)


# ── Internal helpers ─────────────────────────────────────────────────────────


def _looks_like_skill(meta: dict[str, Any]) -> bool:
    """Return True when the frontmatter looks like a skill rather than an agent.

    A skill has ``name`` and/or ``description`` but **no** ``model`` field.
    """
    has_agent_fields = bool(meta.get("model"))
    has_skill_signals = "name" in meta or "description" in meta
    return has_skill_signals and not has_agent_fields


def _validate_agent(
    meta: dict[str, Any],
    body: str,
    path: Path,
) -> list[ValidationIssue]:
    """Run all three validation levels for an agent file."""
    issues: list[ValidationIssue] = []

    # ── Level 1: Structural ───────────────────────────────────────────────
    try:
        config = AgentConfig(**meta)
    except PydanticValidationError as exc:
        for error in exc.errors():
            field = ".".join(str(loc) for loc in error["loc"]) if error["loc"] else "unknown"
            issues.append(
                ValidationIssue(
                    severity=Severity.ERROR,
                    message=f"Field '{field}': {error['msg']}",
                    path=str(path),
                )
            )
        # Cannot perform semantic checks without a valid config object.
        return issues
    except Exception as exc:  # noqa: BLE001  (pydantic may raise raw ValueError)
        issues.append(
            ValidationIssue(
                severity=Severity.ERROR,
                message=f"Configuration parse error: {exc}",
                path=str(path),
            )
        )
        return issues

    # ── Level 2: Semantic ─────────────────────────────────────────────────
    issues.extend(_check_model_format(config.model, path))
    issues.extend(_check_tools(config.tools, path))
    issues.extend(_check_subagents(config.subagents, path))

    # ── Level 3: Best-practice warnings ──────────────────────────────────
    issues.extend(_best_practice_checks(config, body, path))

    return issues


def _validate_skill(
    meta: dict[str, Any],
    body: str,
    path: Path,
) -> list[ValidationIssue]:
    """Validate a skill file (name + description, no model)."""
    issues: list[ValidationIssue] = []

    if not meta.get("name"):
        issues.append(
            ValidationIssue(
                severity=Severity.ERROR,
                message="Skill is missing required field 'name'.",
                path=str(path),
            )
        )

    if not meta.get("description"):
        issues.append(
            ValidationIssue(
                severity=Severity.WARNING,
                message="Skill has no 'description'; consumers cannot discover its purpose.",
                path=str(path),
            )
        )

    if not body.strip():
        issues.append(
            ValidationIssue(
                severity=Severity.WARNING,
                message="Skill body is empty; no instructions will be injected.",
                path=str(path),
            )
        )
    elif len(body.strip()) < _SHORT_BODY_THRESHOLD:
        issues.append(
            ValidationIssue(
                severity=Severity.WARNING,
                message=(
                    f"Skill body is very short ({len(body.strip())} chars); "
                    "consider adding more detailed instructions."
                ),
                path=str(path),
            )
        )

    return issues


# ── Level 2 helpers ───────────────────────────────────────────────────────────


def _check_model_format(model: str, path: Path) -> list[ValidationIssue]:
    """Validate that the model string looks like a litellm model identifier.

    Valid formats (non-exhaustive):
    - ``gpt-4o``
    - ``claude-3-5-sonnet-20241022``
    - ``azure/gpt-4o``
    - ``azure_ai/phi-3``
    - ``anthropic/claude-3-opus-20240229``
    - ``ollama/llama3``
    """
    issues: list[ValidationIssue] = []

    if not model:
        issues.append(
            ValidationIssue(
                severity=Severity.ERROR,
                message="Required field 'model' is missing; specify a valid litellm model ID.",
                path=str(path),
            )
        )
        return issues

    # Must contain only printable, non-whitespace characters.
    if any(c.isspace() for c in model):
        issues.append(
            ValidationIssue(
                severity=Severity.ERROR,
                message=(
                    f"'model' value '{model}' contains whitespace; use a valid litellm model ID."
                ),
                path=str(path),
            )
        )
        return issues

    # Warn on obviously invalid-looking values (numbers only, or very short).
    if len(model) < 3:
        issues.append(
            ValidationIssue(
                severity=Severity.WARNING,
                message=(
                    f"'model' value '{model}' is suspiciously short; "
                    "verify it is a valid litellm model ID."
                ),
                path=str(path),
            )
        )

    return issues


def _check_tools(tools: list[str], path: Path) -> list[ValidationIssue]:
    """Verify each tool name is either a built-in or a valid module path."""
    issues: list[ValidationIssue] = []

    for tool in tools:
        if tool in BUILTIN_TOOLS:
            continue
        if _MODULE_PATH_RE.match(tool):
            continue
        issues.append(
            ValidationIssue(
                severity=Severity.WARNING,
                message=(
                    f"Tool '{tool}' is not a known built-in "
                    f"({', '.join(sorted(BUILTIN_TOOLS))}) "
                    "and does not look like a valid module path (e.g. 'my_pkg.tools:my_fn')."
                ),
                path=str(path),
            )
        )

    return issues


def _check_subagents(subagents: list[AgentConfig], path: Path) -> list[ValidationIssue]:
    """Check that any config-file references in subagents point to real files."""
    issues: list[ValidationIssue] = []
    base_dir = path.parent

    for sub in subagents:
        if sub.config is not None:
            ref_path = base_dir / sub.config
            if not ref_path.exists():
                issues.append(
                    ValidationIssue(
                        severity=Severity.ERROR,
                        message=(
                            f"Subagent config reference '{sub.config}' not found "
                            f"(resolved to '{ref_path}')."
                        ),
                        path=str(path),
                    )
                )

    return issues


# ── Level 3 helpers ───────────────────────────────────────────────────────────


def _best_practice_checks(
    config: AgentConfig,
    body: str,
    path: Path,
) -> list[ValidationIssue]:
    """Return best-practice warnings for an agent configuration."""
    issues: list[ValidationIssue] = []
    stripped_body = body.strip()

    if not stripped_body:
        issues.append(
            ValidationIssue(
                severity=Severity.WARNING,
                message="Agent body is empty; the system prompt will be blank.",
                path=str(path),
            )
        )
    elif len(stripped_body) < _SHORT_BODY_THRESHOLD:
        issues.append(
            ValidationIssue(
                severity=Severity.WARNING,
                message=(
                    f"Agent body is very short ({len(stripped_body)} chars); "
                    "consider adding a detailed system prompt."
                ),
                path=str(path),
            )
        )

    if not config.description:
        issues.append(
            ValidationIssue(
                severity=Severity.WARNING,
                message="No 'description' field; add one to improve agent discoverability.",
                path=str(path),
            )
        )

    if not config.tools:
        issues.append(
            ValidationIssue(
                severity=Severity.WARNING,
                message="No tools defined; the agent will only be able to respond with text.",
                path=str(path),
            )
        )

    if config.max_turns == _DEFAULT_MAX_TURNS:
        issues.append(
            ValidationIssue(
                severity=Severity.INFO,
                message=(
                    f"'max_turns' is at the default value ({_DEFAULT_MAX_TURNS}); "
                    "review whether this limit is appropriate for your use case."
                ),
                path=str(path),
            )
        )

    return issues
