"""CLI entry point for ICSD."""

from __future__ import annotations

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

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

EXIT_OK = 0
EXIT_FINDINGS = 1
EXIT_TOOL_ERROR = 2
CLI_PROG_NAME = "icsd-dpa"

SUPPORTED_LINT_SUFFIXES = {".json", ".py"}
LINT_BASELINE_PROFILE = "baseline"
LINT_POLICY_PROFILE = "policy-v0.1"
LINT_PROFILES = (LINT_BASELINE_PROFILE, LINT_POLICY_PROFILE)
LINT_OUTPUT_FORMATS = ("json", "text")
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 build_parser() -> argparse.ArgumentParser:
    """Build the CLI parser."""
    parser = argparse.ArgumentParser(prog=CLI_PROG_NAME, description=f"ICSD CLI v{__version__}")
    parser.add_argument("--version", action="version", version=f"%(prog)s {__version__}")
    subparsers = parser.add_subparsers(dest="command")

    verify_parser = subparsers.add_parser("verify", help="Validate evidence format and integrity.")
    verify_parser.add_argument("evidence_json", help="Path to evidence JSON.")

    lint_parser = subparsers.add_parser("lint", help="Run basic static checks.")
    lint_parser.add_argument("path", help="Path to lint.")
    lint_parser.add_argument(
        "--profile",
        choices=LINT_PROFILES,
        default=LINT_BASELINE_PROFILE,
        help="Lint profile to apply (default: baseline).",
    )
    lint_parser.add_argument(
        "--format",
        choices=LINT_OUTPUT_FORMATS,
        default="text",
        dest="output_format",
        help="Output format for lint results (default: text).",
    )
    lint_parser.add_argument(
        "--jobs",
        type=int,
        default=1,
        help="Number of parallel lint workers (default: 1, single-threaded).",
    )
    return parser


def _emit(message: str) -> None:
    print(message, file=sys.stderr)


def _emit_json(payload: Mapping[str, object]) -> None:
    print(json.dumps(payload, sort_keys=True), file=sys.stdout)


def _run_verify(evidence_json: str) -> int:
    evidence_path = Path(evidence_json)
    try:
        raw_payload = evidence_path.read_text(encoding="utf-8")
    except OSError as exc:
        _emit(f"verify: unable to read {evidence_path}: {exc}.")
        return EXIT_TOOL_ERROR
    try:
        payload = json.loads(raw_payload)
    except json.JSONDecodeError as exc:
        _emit(
            f"verify: invalid JSON in {evidence_path}: {exc.msg} "
            f"(line {exc.lineno}, column {exc.colno})."
        )
        return EXIT_TOOL_ERROR
    if not isinstance(payload, Mapping):
        _emit(f"verify: evidence payload must be a JSON object in {evidence_path}.")
        return EXIT_TOOL_ERROR
    errors = validate_evidence_payload(payload)
    if errors:
        for error in errors:
            _emit(f"verify: {error}")
        return EXIT_TOOL_ERROR
    return EXIT_OK


def _iter_lint_targets(target: Path) -> list[Path]:
    if target.is_file():
        return [target]
    if target.is_dir():
        return sorted(path for path in target.rglob("*") if path.is_file())
    return []


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] = []
    if path.suffix not in SUPPORTED_LINT_SUFFIXES:
        return findings
    source_text = path.read_text(encoding="utf-8")
    if path.suffix == ".py":
        return _lint_python_file(path, source_text, profile=profile)
    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(f"lint: {error}")
        return exit_code
    for finding in findings:
        _emit(f"lint: {finding.as_text()}")
    return exit_code


def _run_lint(path: str, *, profile: str, output_format: str, jobs: int) -> int:
    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}.",
        )
    lint_targets = _iter_lint_targets(lint_path)
    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=(),
    )


def main(argv: Sequence[str] | None = None) -> int:
    """Run the ICSD CLI."""
    parser = build_parser()
    args = parser.parse_args(argv)
    if args.command == "verify":
        return _run_verify(args.evidence_json)
    if args.command == "lint":
        return _run_lint(
            args.path,
            profile=args.profile,
            output_format=args.output_format,
            jobs=args.jobs,
        )
    parser.print_help()
    return EXIT_TOOL_ERROR


if __name__ == "__main__":
    raise SystemExit(main())
