"""Lint command implementation for the ICSD CLI."""

from __future__ import annotations

import ast
import json
import tokenize
from collections.abc import Mapping, Sequence
from dataclasses import dataclass
from pathlib import Path

from icsd.core.evidence import validate_evidence_payload
from icsd.tools.parallel import lint_targets_parallel

from ._shared import EXIT_FINDINGS, EXIT_OK, EXIT_TOOL_ERROR, emit_json, emit_stderr

SUPPORTED_LINT_SUFFIXES = {".json", ".py"}
LINT_IGNORED_DIRECTORY_NAMES = frozenset(
    {
        ".git",
        ".hg",
        ".nox",
        ".pytest_cache",
        ".ruff_cache",
        ".svn",
        ".tox",
        ".venv",
        "__pycache__",
        "build",
        "dist",
        "env",
        "node_modules",
        "site-packages",
        "venv",
    }
)
LINT_BASELINE_PROFILE = "baseline"
LINT_POLICY_PROFILE = "policy-v0.1"
LINT_PROFILES = (LINT_BASELINE_PROFILE, LINT_POLICY_PROFILE)
POLICY_BLOCKED_PYTHON_CALLS = frozenset({"eval", "exec"})
EVIDENCE_HINT_FIELDS = frozenset({"schema_version", "transition_id", "before_hash", "after_hash"})


@dataclass(frozen=True, slots=True)
class LintFinding:
    """Machine-readable lint finding."""

    path: str
    rule: str
    message: str
    line: int | None = None
    column: int | None = None

    def as_dict(self) -> dict[str, object]:
        """Return finding payload for JSON output."""
        payload: dict[str, object] = {
            "path": self.path,
            "rule": self.rule,
            "message": self.message,
        }
        if self.line is not None:
            payload["line"] = self.line
        if self.column is not None:
            payload["column"] = self.column
        return payload

    def as_text(self) -> str:
        """Return a stable human-readable message."""
        if self.line is not None and self.column is not None:
            return f"{self.path}: line {self.line}, column {self.column}: {self.message}"
        return f"{self.path}: {self.message}"


def _iter_lint_targets(target: Path) -> list[Path]:
    if target.is_file():
        return [target]
    if not target.is_dir():
        return []

    lint_targets: list[Path] = []
    for child in sorted(target.iterdir(), key=lambda path: path.name):
        if child.is_dir():
            # Keep recursive directory lint focused on project-owned content and
            # avoid environment/cache trees that frequently contain third-party fixtures.
            if child.is_symlink() or child.name.casefold() in LINT_IGNORED_DIRECTORY_NAMES:
                continue
            lint_targets.extend(_iter_lint_targets(child))
            continue
        if child.is_file():
            lint_targets.append(child)
    return lint_targets


def _call_name(function_node: ast.expr) -> str | None:
    if isinstance(function_node, ast.Name):
        return function_node.id
    if isinstance(function_node, ast.Attribute):
        return function_node.attr
    return None


def _lint_python_policy(tree: ast.AST, path: Path) -> list[LintFinding]:
    findings: list[LintFinding] = []
    for node in ast.walk(tree):
        if not isinstance(node, ast.Call):
            continue
        call_name = _call_name(node.func)
        if call_name not in POLICY_BLOCKED_PYTHON_CALLS:
            continue
        line = node.lineno if isinstance(node.lineno, int) else None
        column = node.col_offset + 1 if isinstance(node.col_offset, int) else None
        findings.append(
            LintFinding(
                path=str(path),
                rule="python-dynamic-exec",
                message=(f"use of '{call_name}' is disallowed by profile '{LINT_POLICY_PROFILE}'."),
                line=line,
                column=column,
            )
        )
    return findings


def _looks_like_evidence_payload(payload: Mapping[str, object]) -> bool:
    return any(key in payload for key in EVIDENCE_HINT_FIELDS)


def _lint_python_file(path: Path, source_text: str, *, profile: str) -> list[LintFinding]:
    findings: list[LintFinding] = []
    try:
        tree = ast.parse(source_text, filename=str(path))
    except SyntaxError as exc:
        line = exc.lineno if isinstance(exc.lineno, int) else None
        column = exc.offset if isinstance(exc.offset, int) else None
        findings.append(
            LintFinding(
                path=str(path),
                rule="python-syntax",
                message=f"python syntax error: {exc.msg}.",
                line=line,
                column=column,
            )
        )
        return findings
    if profile == LINT_POLICY_PROFILE:
        findings.extend(_lint_python_policy(tree, path))
    return findings


def _lint_json_file(path: Path, source_text: str, *, profile: str) -> list[LintFinding]:
    findings: list[LintFinding] = []
    try:
        payload = json.loads(source_text)
    except json.JSONDecodeError as exc:
        findings.append(
            LintFinding(
                path=str(path),
                rule="json-syntax",
                message=f"invalid JSON: {exc.msg}.",
                line=exc.lineno,
                column=exc.colno,
            )
        )
        return findings
    if profile != LINT_POLICY_PROFILE:
        return findings
    if not isinstance(payload, Mapping):
        findings.append(
            LintFinding(
                path=str(path),
                rule="json-root-object",
                message=f"JSON root must be an object for profile '{LINT_POLICY_PROFILE}'.",
            )
        )
        return findings
    if _looks_like_evidence_payload(payload):
        for error in validate_evidence_payload(payload):
            findings.append(LintFinding(path=str(path), rule="evidence-schema", message=error))
    return findings


def _lint_file(path: Path, *, profile: str) -> list[LintFinding]:
    findings: list[LintFinding] = []
    suffix = path.suffix.casefold()
    if suffix not in SUPPORTED_LINT_SUFFIXES:
        return findings
    if suffix == ".py":
        try:
            with tokenize.open(path) as source_file:
                source_text = source_file.read()
        except SyntaxError as exc:
            line = exc.lineno if isinstance(exc.lineno, int) else None
            column = exc.offset if isinstance(exc.offset, int) else None
            return [
                LintFinding(
                    path=str(path),
                    rule="python-encoding",
                    message="python source is not decodable using its declared/default encoding.",
                    line=line,
                    column=column,
                )
            ]
        except UnicodeDecodeError:
            return [
                LintFinding(
                    path=str(path),
                    rule="python-encoding",
                    message="python source is not decodable using its declared/default encoding.",
                )
            ]
        return _lint_python_file(path, source_text, profile=profile)
    try:
        source_text = path.read_text(encoding="utf-8")
    except UnicodeDecodeError:
        return [
            LintFinding(
                path=str(path),
                rule="json-encoding",
                message="JSON files must be UTF-8 encoded.",
            )
        ]
    return _lint_json_file(path, source_text, profile=profile)


def _lint_status_from_exit_code(exit_code: int) -> str:
    if exit_code == EXIT_OK:
        return "ok"
    if exit_code == EXIT_FINDINGS:
        return "findings"
    return "error"


def _lint_report(
    *,
    lint_path: Path,
    profile: str,
    exit_code: int,
    findings: Sequence[LintFinding],
    error: str | None = None,
) -> dict[str, object]:
    payload: dict[str, object] = {
        "command": "lint",
        "path": str(lint_path),
        "profile": profile,
        "status": _lint_status_from_exit_code(exit_code),
        "exit_code": exit_code,
        "finding_count": len(findings),
        "findings": [finding.as_dict() for finding in findings],
    }
    if error is not None:
        payload["error"] = error
    return payload


def _finalise_lint_result(
    *,
    lint_path: Path,
    profile: str,
    output_format: str,
    exit_code: int,
    findings: Sequence[LintFinding],
    error: str | None = None,
) -> int:
    if output_format == "json":
        emit_json(
            _lint_report(
                lint_path=lint_path,
                profile=profile,
                exit_code=exit_code,
                findings=findings,
                error=error,
            )
        )
        return exit_code
    if error is not None:
        emit_stderr(f"lint: {error}")
        return exit_code
    for finding in findings:
        emit_stderr(f"lint: {finding.as_text()}")
    return exit_code


def run_lint(path: str, *, profile: str, output_format: str, jobs: int) -> int:
    """Run the lint command."""
    lint_path = Path(path)
    if jobs < 1:
        return _finalise_lint_result(
            lint_path=lint_path,
            profile=profile,
            output_format=output_format,
            exit_code=EXIT_TOOL_ERROR,
            findings=(),
            error=f"jobs must be >= 1: {jobs}.",
        )
    if not lint_path.exists():
        return _finalise_lint_result(
            lint_path=lint_path,
            profile=profile,
            output_format=output_format,
            exit_code=EXIT_TOOL_ERROR,
            findings=(),
            error=f"path does not exist: {lint_path}.",
        )
    try:
        lint_targets = _iter_lint_targets(lint_path)
    except OSError as exc:
        return _finalise_lint_result(
            lint_path=lint_path,
            profile=profile,
            output_format=output_format,
            exit_code=EXIT_TOOL_ERROR,
            findings=(),
            error=f"unable to traverse {lint_path}: {exc}.",
        )
    if not lint_targets:
        return _finalise_lint_result(
            lint_path=lint_path,
            profile=profile,
            output_format=output_format,
            exit_code=EXIT_TOOL_ERROR,
            findings=(),
            error=f"path is not a file or directory: {lint_path}.",
        )
    findings: list[LintFinding] = []
    if jobs == 1:
        for target in lint_targets:
            try:
                findings.extend(_lint_file(target, profile=profile))
            except (OSError, UnicodeDecodeError) as exc:
                return _finalise_lint_result(
                    lint_path=lint_path,
                    profile=profile,
                    output_format=output_format,
                    exit_code=EXIT_TOOL_ERROR,
                    findings=(),
                    error=f"unable to read {target}: {exc}.",
                )
    else:
        parallel_results = lint_targets_parallel(
            lint_targets,
            lint_file=lambda target: _lint_file(target, profile=profile),
            workers=jobs,
        )
        for result in parallel_results:
            if result.error is not None:
                return _finalise_lint_result(
                    lint_path=lint_path,
                    profile=profile,
                    output_format=output_format,
                    exit_code=EXIT_TOOL_ERROR,
                    findings=(),
                    error=result.error,
                )
            findings.extend(result.findings)
    if findings:
        return _finalise_lint_result(
            lint_path=lint_path,
            profile=profile,
            output_format=output_format,
            exit_code=EXIT_FINDINGS,
            findings=findings,
        )
    return _finalise_lint_result(
        lint_path=lint_path,
        profile=profile,
        output_format=output_format,
        exit_code=EXIT_OK,
        findings=(),
    )
