"""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 .rules import Pipeline
from .analysis import SourceAnalysisPayload
from .server import HYPERPARAMETERS, Hyperparameters, _analyze
from .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: dict,
    *,
    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: dict, 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: dict, 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, hyperparameters, pipeline=pipeline)
    result["source"] = source
    return result


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 = 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())
