"""CLI entry point for the ``sg`` prose linter.

Usage examples::

    # Lint files by name
    sg README.md docs/*.md

    # Lint inline text
    sg "This is some test text"

    # Lint from stdin
    cat essay.txt | sg -

    # Machine-readable JSON output
    sg -j report.md

    # Verbose: show individual violations
    sg -v draft.md

    # Score only
    sg -s draft.md

    # Use a custom JSONL rule config
    sg -c config.jsonl draft.md

    # Set exit code threshold (default: 0 = always exit 0 unless error)
    sg -t 60 draft.md   # exit 1 if any file scores below 60

    # Quiet mode: only print filenames that fail the threshold
    sg -q -t 60 docs/*.md
"""

import argparse
import json
import sys
from dataclasses import dataclass
from pathlib import Path
from typing import Literal, TextIO, TypeAlias

from slop_guard.config import DEFAULT_HYPERPARAMETERS, Hyperparameters
from slop_guard.engine import analyze_text
from slop_guard.models import AnalysisPayload, SourceAnalysisPayload
from slop_guard.rules.pipeline import Pipeline
from slop_guard.version import PACKAGE_VERSION

# ---------------------------------------------------------------------------
# Exit codes
# ---------------------------------------------------------------------------

EXIT_OK = 0
EXIT_THRESHOLD_FAILURE = 1
EXIT_ERROR = 2

# ---------------------------------------------------------------------------
# Band decorations for terminal output
# ---------------------------------------------------------------------------

_BAND_SYMBOLS: dict[str, str] = {
    "clean": ".",
    "light": "*",
    "moderate": "!",
    "heavy": "!!",
    "saturated": "!!!",
}

InputValue: TypeAlias = str | Path
ConfigLoadError: TypeAlias = OSError | UnicodeDecodeError


@dataclass(frozen=True)
class InputTarget:
    """Typed representation of a CLI input target."""

    kind: Literal["file", "stdin", "text"]
    value: InputValue
    display_label: str


class InputResolutionError(ValueError):
    """Raised when a CLI positional input cannot be resolved."""


def _format_score_line(
    label: str,
    result: AnalysisPayload | SourceAnalysisPayload,
    *,
    show_counts: bool = False,
) -> str:
    """Build a one-line summary for a single analyzed input."""
    score = result["score"]
    band = result["band"]
    wc = result["word_count"]
    sym = _BAND_SYMBOLS.get(band, "?")
    line = f"{label}: {score}/100 [{band}] ({wc} words) {sym}"
    if show_counts:
        active = {k: v for k, v in result["counts"].items() if v}
        if active:
            parts = " ".join(f"{k}={v}" for k, v in active.items())
            line += f"  ({parts})"
    return line


def _print_violations(
    result: AnalysisPayload | SourceAnalysisPayload,
    file: TextIO = sys.stdout,
) -> None:
    """Print individual violations grouped under the result."""
    for v in result["violations"]:
        rule = v["rule"]
        match = v["match"]
        penalty = v["penalty"]
        ctx = v["context"]
        print(f"  {rule}: {match} ({penalty})  {ctx}", file=file)


def _print_advice(
    result: AnalysisPayload | SourceAnalysisPayload,
    file: TextIO = sys.stdout,
) -> None:
    """Print deduped advice list."""
    for item in result["advice"]:
        print(f"  - {item}", file=file)


# ---------------------------------------------------------------------------
# Core analysis dispatch
# ---------------------------------------------------------------------------


def _analyze_text(
    text: str,
    source: str,
    hyperparameters: Hyperparameters,
    pipeline: Pipeline,
) -> SourceAnalysisPayload:
    """Run analysis and attach the source label."""
    result = analyze_text(text, hyperparameters=hyperparameters, pipeline=pipeline)
    return SourceAnalysisPayload(
        score=result["score"],
        band=result["band"],
        word_count=result["word_count"],
        violations=result["violations"],
        counts=result["counts"],
        total_penalty=result["total_penalty"],
        weighted_sum=result["weighted_sum"],
        density=result["density"],
        advice=result["advice"],
        source=source,
    )


def _analyze_file(
    path: Path,
    hyperparameters: Hyperparameters,
    pipeline: Pipeline,
) -> SourceAnalysisPayload:
    """Read a file and analyze its contents."""
    text = path.read_text(encoding="utf-8")
    return _analyze_text(text, str(path), hyperparameters, pipeline)


# ---------------------------------------------------------------------------
# Argument parsing
# ---------------------------------------------------------------------------


def _build_parser() -> argparse.ArgumentParser:
    """Construct the CLI argument parser."""
    p = argparse.ArgumentParser(
        prog="sg",
        description="Prose linter for AI slop patterns.",
        epilog="Pass file paths, '-' for stdin, or quoted inline text.",
    )
    p.add_argument(
        "--version",
        action="version",
        version=PACKAGE_VERSION,
        help="Show package version and exit.",
    )
    p.add_argument(
        "inputs",
        nargs="+",
        metavar="INPUT",
        help="Inputs to lint: files, '-' for stdin, or quoted inline text.",
    )
    p.add_argument(
        "-j",
        "--json",
        action="store_true",
        default=False,
        help="Output results as JSON.",
    )
    p.add_argument(
        "-v",
        "--verbose",
        action="store_true",
        default=False,
        help="Show individual violations and advice.",
    )
    p.add_argument(
        "-q",
        "--quiet",
        action="store_true",
        default=False,
        help="Only print sources that fail the threshold.",
    )
    p.add_argument(
        "-t",
        "--threshold",
        type=int,
        default=0,
        metavar="SCORE",
        help="Minimum passing score (0-100). Exit 1 if any input scores below this.",
    )
    p.add_argument(
        "-c",
        "--config",
        default=None,
        metavar="JSONL",
        help="Path to JSONL rule configuration. Defaults to packaged settings.",
    )
    p.add_argument(
        "-s",
        "--score-only",
        action="store_true",
        default=False,
        help="Print score only.",
    )
    p.add_argument(
        "--counts",
        action="store_true",
        default=False,
        help="Show per-rule hit counts in the summary line.",
    )
    return p


# ---------------------------------------------------------------------------
# Main
# ---------------------------------------------------------------------------


def _is_inline_text_argument(value: str) -> bool:
    """Return whether a positional argument should be treated as inline text."""
    return any(ch.isspace() for ch in value)


def _path_points_to_existing_file(path: Path) -> bool:
    """Return whether ``path`` exists as a regular file.

    Args:
        path: Candidate filesystem path supplied on the CLI.

    Returns:
        ``True`` when the path can be stat'ed and resolves to a regular file.
        ``False`` when the path does not exist or the OS rejects the stat call,
        such as ``ENAMETOOLONG`` for long inline prose strings.
    """
    try:
        return path.is_file()
    except OSError:
        return False


def _resolve_inputs(args: argparse.Namespace) -> list[InputTarget]:
    """Resolve positional args into typed input targets.

    Raises:
        InputResolutionError: If any positional input is an empty string.
    """
    inputs: list[InputTarget] = []
    for index, raw in enumerate(args.inputs, start=1):
        if raw == "":
            raise InputResolutionError(
                f"input {index} is empty; pass non-empty inline text, a file path, or '-' for stdin"
            )
        if raw == "-":
            inputs.append(InputTarget(kind="stdin", value=raw, display_label="<stdin>"))
            continue
        candidate_path = Path(raw)
        if _path_points_to_existing_file(candidate_path):
            inputs.append(
                InputTarget(
                    kind="file",
                    value=candidate_path,
                    display_label=str(candidate_path),
                )
            )
            continue
        if _is_inline_text_argument(raw):
            inputs.append(
                InputTarget(kind="text", value=raw, display_label=f"<text:{index}>")
            )
            continue
        inputs.append(
            InputTarget(
                kind="file",
                value=candidate_path,
                display_label=str(candidate_path),
            )
        )
    return inputs


def _format_config_load_error(path: Path, exc: ConfigLoadError) -> str:
    """Render a stable user-facing config load error.

    Args:
        path: Config path passed to ``--config``.
        exc: Read-time error raised while resolving or opening the config.

    Returns:
        Stable ``<path>: <detail>`` text for CLI stderr output.
    """
    if isinstance(exc, FileNotFoundError):
        detail = "No such file"
    elif isinstance(exc, IsADirectoryError):
        detail = "Is a directory"
    elif isinstance(exc, UnicodeDecodeError):
        detail = "Invalid UTF-8"
    else:
        detail = exc.strerror or str(exc)
    return f"{path}: {detail}"


def _load_pipeline(config_path: str | None) -> Pipeline:
    """Load the CLI rule pipeline with user-facing config path errors.

    Args:
        config_path: Optional JSONL rule configuration path from ``--config``.

    Returns:
        Loaded rule pipeline.

    Raises:
        TypeError: The JSONL config schema is invalid.
        ValueError: The config path or JSONL payload is invalid.
    """
    if config_path is None:
        return Pipeline.from_jsonl()

    path = Path(config_path)
    try:
        return Pipeline.from_jsonl(str(path))
    except (OSError, UnicodeDecodeError) as exc:
        raise ValueError(_format_config_load_error(path, exc)) from exc


def _emit_result(
    display_label: str,
    result: SourceAnalysisPayload,
    args: argparse.Namespace,
) -> None:
    """Print one analyzed result immediately."""
    fails_threshold = args.threshold > 0 and result["score"] < args.threshold
    if args.quiet and not fails_threshold:
        return
    if args.score_only:
        print(result["score"], flush=True)
        return

    print(
        _format_score_line(display_label, result, show_counts=args.counts),
        flush=True,
    )
    if args.verbose:
        if result["violations"]:
            _print_violations(result)
        if result["advice"]:
            _print_advice(result)


def cli_main(argv: list[str] | None = None) -> int:
    """Entry point for the ``sg`` command.

    Args:
        argv: Argument list (defaults to sys.argv[1:]).

    Returns:
        Exit code suitable for ``sys.exit``.
    """
    parser = _build_parser()
    args = parser.parse_args(argv)

    try:
        inputs = _resolve_inputs(args)
    except InputResolutionError as exc:
        print(f"sg: {exc}", file=sys.stderr)
        return EXIT_ERROR

    results: list[SourceAnalysisPayload] = []
    threshold_failed = False
    hp = DEFAULT_HYPERPARAMETERS
    try:
        pipeline = _load_pipeline(args.config)
    except (OSError, TypeError, ValueError) as exc:
        print(f"sg: {exc}", file=sys.stderr)
        return EXIT_ERROR

    for target in inputs:
        if target.kind == "stdin":
            text = sys.stdin.read()
            result = _analyze_text(text, text, hp, pipeline)
        elif target.kind == "text":
            assert isinstance(target.value, str)
            result = _analyze_text(target.value, target.value, hp, pipeline)
        else:
            assert isinstance(target.value, Path)
            path = target.value
            if not path.is_file():
                print(f"sg: {path}: No such file", file=sys.stderr)
                continue
            try:
                result = _analyze_file(path, hp, pipeline)
            except (OSError, UnicodeDecodeError) as exc:
                print(f"sg: {path}: {exc}", file=sys.stderr)
                continue

        results.append(result)
        if args.threshold > 0 and result["score"] < args.threshold:
            threshold_failed = True

        if not args.json:
            _emit_result(target.display_label, result, args)

    if not results:
        return EXIT_ERROR

    # --- Output ---
    if args.json:
        out = results if len(results) > 1 else results[0]
        json.dump(out, sys.stdout, indent=2)
        sys.stdout.write("\n")

    # --- Exit code ---
    if threshold_failed:
        return EXIT_THRESHOLD_FAILURE

    return EXIT_OK


def main() -> None:
    """Thin wrapper that calls ``sys.exit`` with the CLI return code."""
    sys.exit(cli_main())
