"""
Harper grammar checker integration.

This module provides a Python wrapper around harper-cli for checking
spelling and grammar in documentation files.

Harper is a fast, privacy-first grammar checker written in Rust.
See: https://github.com/Automattic/harper
"""

from __future__ import annotations

import json
import re
import shutil
import subprocess
from dataclasses import dataclass, field
from pathlib import Path
from typing import TYPE_CHECKING

from great_docs._subprocess import TEXT_MODE_KWARGS

if TYPE_CHECKING:
    pass


# =============================================================================
# Default Configuration for Documentation
# =============================================================================

# Rules that produce too many false positives in technical documentation.
# These are designed for prose, not code/config-heavy markdown files.
DEFAULT_IGNORE_RULES_FOR_DOCS = [
    "Spaces",  # Flags YAML indentation as "multiple spaces"
    "Dashes",  # Flags `---` frontmatter and `--` CLI options
    "QuoteSpacing",  # Issues with quotes in code blocks
    "UnclosedQuotes",  # Flags strings in code blocks
    "SentenceCapitalization",  # Flags code/config lines starting lowercase
    "LongSentences",  # Code examples often have long "sentences"
    "NoFrenchSpaces",  # Flags code indentation as "French spaces"
    "OrthographicConsistency",  # Flags technical terms like yaml/YAML, css/CSS
    "EllipsisLength",  # Flags `..` in file paths and ranges
    "MoreAdjective",  # Suggests "frequentest" instead of "most frequent"
    # Technical abbreviations are fine in docs
    "ExpandArgument",  # "arg" → "argument" - Args is standard in docstrings
    "ExpandParameter",  # "param" → "parameter" - param is standard
    # Compound word rules conflict with technical writing
    "CompoundNouns",  # "file path" → "filepath", "spell checking" → "spellchecking"
    "PhrasalVerbAsCompoundNoun",  # "setup" → "set up" - both are valid
    "RoadMap",  # "roadmap" is standard in tech/business contexts
]

# Built-in dictionary of common technical terms for documentation projects.
# These are added automatically unless --no-builtin-dictionary is used.
BUILTIN_DICTIONARY = [
    # File extensions
    "yaml",
    "yml",
    "qmd",
    "md",
    "css",
    "scss",
    "json",
    "toml",
    "html",
    "py",
    "svg",
    "png",
    "jpg",
    "jpeg",
    "gif",
    "webp",
    "ico",
    "pdf",
    "rst",
    "txt",
    "js",
    "ts",
    "tsx",
    "jsx",
    "sh",
    "bash",
    "zsh",
    "ipynb",
    "rf",
    # Python keywords and types
    "def",
    "str",
    "int",
    "bool",
    "dict",
    "tuple",
    "args",
    "kwargs",
    "async",
    "await",
    "elif",
    "func",
    "exc",
    "cls",
    "repr",
    "init",
    # Common Python terms
    "dataclass",
    "dataclasses",
    "enum",
    "namedtuple",
    "TypedDict",
    "TypeVar",
    "Callable",
    "Iterable",
    "Iterator",
    "Sequence",
    "dicts",
    "subclass",
    "subclasses",
    "metaclass",
    # Configuration and CLI terms
    "navbar",
    "sidebar",
    "frontmatter",
    "permalink",
    "url",
    "urls",
    "env",
    "cli",
    "api",
    "src",
    "href",
    "px",
    "rgb",
    "rgba",
    "hex",
    "config",
    "configs",
    "desc",
    "param",
    "params",
    "nodoc",
    "ncol",
    # Common package/tool names
    "griffe",
    "quartodoc",
    "quarto",
    "jupyter",
    "numpy",
    "pandas",
    "pytest",
    "mypy",
    "ruff",
    "dateutil",
    "setuptools",
    "pyproject",
    "DuckDB",
    "ChromaDB",
    "OpenCode",
    "Mermaid",
    "markdown",
    "Markdown",
    # Web/markup terms
    "docstring",
    "docstrings",
    "codeblock",
    "callout",
    "tooltip",
    "dropdown",
    "tabset",
    "README",
    "changelog",
    "frontmatter",
    "lettermark",
    "wordmark",
    # Git/GitHub terms
    "github",
    "gitlab",
    "bitbucket",
    "repo",
    "repos",
    "pr",
    "prs",
    "ci",
    "cd",
    "workflow",
    "workflows",
    "CNAME",
    "gd",
    # Technical abbreviations
    "autogenerate",
    "autogenerated",
    "autolink",
    "autolinks",
    "autolinking",
    "reStructuredText",
    "scannable",
    # Hyphenated terms (Harper sometimes flags these)
    "auto-generate",
    "auto-generated",
    "Auto-Detection",
    "re-run",
    # Common in Great Docs
    "GDLS",
    "GreatDocs",
    "seealso",
    "versionadded",
    "versionchanged",
    "dismissable",
    "navbar",
    "lightswitch",
    # Error/exception names
    "ValueError",
    "TypeError",
    "KeyError",
    "ImportError",
    "AttributeError",
    "FileNotFoundError",
    "ConnectionError",
    "RuntimeError",
    "ParserError",
    "CircularImportError",
    "ScannerError",
    # Code identifiers (generic patterns - commonly appear in examples)
    "MyClass",
    "MyLargeClass",
    "MySmallClass",
    "SimpleClass",
    "BaseStore",
    "DuckDBStore",
    "ChromaDBStore",
    "mypackage",
    "InternalHelper",
    # Mermaid diagram terms
    "LR",
    "TB",
    "BT",
    "RL",  # direction indicators
    "sequenceDiagram",
    "classDiagram",
    "stateDiagram",
    "flowchart",
    "gantt",
    "erDiagram",
    "pie",
    # Misc technical
    "PyPI",
    "npm",
    "npx",
    "homebrew",
    "rustup",
    "cargo",
    "localhost",
    "stdin",
    "stdout",
    "stderr",
    "utf",
    "csv",
    "xml",
    "sql",
    "http",
    "https",
    "tcp",
    "udp",
    "io",
    "os",
    "sys",
    "re",
    "fs",  # module names
    "base64",
    "sha256",
    "md5",
    "uuid",
    # Common CSS/design terms
    "bg",
    "fg",
    "btn",
    "nav",
    "toc",
    "fab",
    "cta",
    "bs",  # Bootstrap prefix
    # Version/release terms
    "v1",
    "v2",
    "v3",
    "semver",
    "pre",
    "rc",
    "alpha",
    "beta",
    # Image/icon sizes (common in favicon context)
    "x16",
    "x32",
    "x48",
    "x64",
    "x128",
    "x180",
    "x192",
    "x256",
    "x512",
    # Additional technical terms
    "ORCID",
    "orcid",
    "mkdocs",
    "MkDocs",
    "YYYY",
    "MM",
    "DD",  # date format placeholders
    "ok",
    "OK",
    "cfg",
    "gitignore",
    "yaml12",  # YAML 1.2
    "dir",
    "dirs",
    "S3",
    "GH",  # GitHub abbreviation
    "roadmap",
    "Roadmap",
    "ROADMAP",
    "PackageNotFoundError",
    "PyO3",
    "Cython",
    "CPython",
    "tabset",
    "tabsets",
    "Tabset",
    "Tabsets",
    "sessionStorage",
    "localStorage",
    "autolinked",
    "Autolinked",
    "ABCMeta",
    "BaseException",
    "tz",  # timezone
]


def extract_prose_from_markdown(content: str) -> tuple[str, dict[int, int]]:
    """
    Extract prose sections from markdown, skipping fenced code blocks and frontmatter.

    Keeps inline code (`code`) in prose for checking - only filters out
    fenced code blocks (``` ... ```) and YAML frontmatter.

    Returns the prose-only content and a mapping from prose line numbers
    to original file line numbers for accurate error reporting.

    Parameters
    ----------
    content
        Full markdown/qmd file content.

    Returns
    -------
    tuple[str, dict[int, int]]
        (prose_content, line_mapping) where line_mapping maps prose line numbers
        (1-based) to original file line numbers (1-based).
    """
    lines = content.split("\n")
    prose_lines: list[str] = []
    line_mapping: dict[int, int] = {}  # prose_line -> original_line

    in_code_block = False
    in_frontmatter = False
    frontmatter_ended = False
    code_fence_pattern = re.compile(r"^(`{3,}|~{3,})")

    for orig_line_num, line in enumerate(lines, start=1):
        stripped = line.strip()

        # Handle YAML frontmatter (only at very start of file)
        if orig_line_num == 1 and stripped == "---":
            in_frontmatter = True
            # Add placeholder to preserve line numbers
            prose_lines.append("")
            line_mapping[len(prose_lines)] = orig_line_num
            continue

        if in_frontmatter:
            if stripped == "---" and not frontmatter_ended:
                in_frontmatter = False
                frontmatter_ended = True
            # Add placeholder to preserve line numbers
            prose_lines.append("")
            line_mapping[len(prose_lines)] = orig_line_num
            continue

        # Handle fenced code blocks
        fence_match = code_fence_pattern.match(stripped)
        if fence_match:
            in_code_block = not in_code_block
            # Add placeholder to preserve line numbers
            prose_lines.append("")
            line_mapping[len(prose_lines)] = orig_line_num
            continue

        if in_code_block:
            # Add placeholder to preserve line numbers
            prose_lines.append("")
            line_mapping[len(prose_lines)] = orig_line_num
            continue

        # Keep prose lines as-is (including inline `code` - that's part of prose)
        prose_lines.append(line)
        line_mapping[len(prose_lines)] = orig_line_num

    return "\n".join(prose_lines), line_mapping


def get_builtin_dictionary() -> list[str]:
    """
    Get the built-in dictionary of technical terms.

    Returns
    -------
    list[str]
        List of words that should be considered correct in technical docs.
    """
    return BUILTIN_DICTIONARY.copy()


def get_default_ignore_rules() -> list[str]:
    """
    Get the default list of rules to ignore for documentation.

    Returns
    -------
    list[str]
        List of Harper rule names to skip by default.
    """
    return DEFAULT_IGNORE_RULES_FOR_DOCS.copy()


@dataclass
class HarperLint:
    """A single lint result from Harper."""

    rule: str
    """Rule name, e.g., 'SpellCheck', 'TheirToTheyre'."""

    kind: str
    """Lint category, e.g., 'Spelling', 'Grammar', 'Capitalization'."""

    line: int
    """1-based line number."""

    column: int
    """1-based column number."""

    message: str
    """Human-readable description of the issue."""

    matched_text: str
    """The text that triggered the lint."""

    suggestions: list[str] = field(default_factory=list)
    """Suggested replacements."""

    priority: int = 0
    """Harper's priority score (higher = more important)."""

    file: str = ""
    """File path where the lint was found."""

    char_start: int = 0
    """Character offset (start) in the file."""

    char_end: int = 0
    """Character offset (end) in the file."""


@dataclass
class HarperFileResult:
    """Results from checking a single file."""

    file: str
    """File path."""

    lint_count: int
    """Number of lints found."""

    lints: list[HarperLint] = field(default_factory=list)
    """List of lints for this file."""

    error: str | None = None
    """Error message if file couldn't be checked."""


class HarperNotFoundError(Exception):
    """Raised when harper-cli is not installed."""

    pass


class HarperError(Exception):
    """Raised when harper-cli returns an error."""

    pass


def find_harper_cli() -> str | None:
    """
    Locate the harper-cli executable.

    Checks for:
    1. 'harper-cli' in PATH
    2. 'harper' in PATH (some installations use this name)
    3. ~/.cargo/bin/harper-cli (cargo install location)

    Returns
    -------
    str | None
        Path to harper-cli executable, or None if not found.
    """
    # Check PATH for harper-cli
    path = shutil.which("harper-cli")
    if path:
        return path

    # Check PATH for harper (alternative name)
    path = shutil.which("harper")
    if path:
        return path

    # Check cargo bin directory
    cargo_path = Path.home() / ".cargo" / "bin" / "harper-cli"
    if cargo_path.exists():
        return str(cargo_path)

    return None


def get_harper_version(harper_path: str | None = None) -> str | None:
    """
    Get the installed Harper version.

    Parameters
    ----------
    harper_path
        Path to harper-cli. If None, will search for it.

    Returns
    -------
    str | None
        Version string (e.g., "1.12.0"), or None if unavailable.
    """
    if harper_path is None:
        harper_path = find_harper_cli()

    if harper_path is None:
        return None

    try:
        result = subprocess.run(
            [harper_path, "--version"],
            capture_output=True,
            **TEXT_MODE_KWARGS,
            timeout=5,
        )
        if result.returncode == 0:
            # Output is like "harper-cli 1.12.0"
            version = result.stdout.strip()
            if " " in version:
                return version.split()[-1]
            return version
    except (subprocess.SubprocessError, OSError):
        pass

    return None


def run_harper(
    files: list[Path] | list[str],
    *,
    dialect: str = "us",
    user_dict_path: Path | str | None = None,
    ignore_rules: list[str] | None = None,
    only_rules: list[str] | None = None,
    harper_path: str | None = None,
    timeout: int = 120,
) -> list[HarperFileResult]:
    """
    Run harper-cli on files and parse the results.

    Parameters
    ----------
    files
        List of file paths to check.
    dialect
        English dialect: 'us', 'uk', 'au', 'in', 'ca'.
    user_dict_path
        Path to a custom dictionary file (one word per line).
    ignore_rules
        List of rule names to skip.
    only_rules
        List of rule names to run exclusively.
    harper_path
        Path to harper-cli. If None, will search for it.
    timeout
        Maximum seconds to wait for harper-cli.

    Returns
    -------
    list[HarperFileResult]
        Results for each file checked.

    Raises
    ------
    HarperNotFoundError
        If harper-cli is not installed.
    HarperError
        If harper-cli returns an unexpected error.
    """
    if harper_path is None:
        harper_path = find_harper_cli()

    if harper_path is None:
        raise HarperNotFoundError(
            "harper-cli is not installed.\n\n"
            "Install it with one of:\n"
            "  brew install harper      # macOS (Homebrew)\n"
            "  cargo install harper-cli # Any platform with Rust\n\n"
            "Or download from: https://github.com/Automattic/harper/releases"
        )

    # Build command
    cmd = [
        harper_path,
        "lint",
        "--format",
        "json",
        "--dialect",
        dialect,
    ]

    # Add custom dictionary
    if user_dict_path:
        cmd.extend(["--user-dict-path", str(user_dict_path)])

    # Add rule filters
    if ignore_rules:
        cmd.extend(["--ignore", ",".join(ignore_rules)])

    if only_rules:
        cmd.extend(["--only", ",".join(only_rules)])

    # Add files
    cmd.extend(str(f) for f in files)

    # Run harper-cli
    try:
        result = subprocess.run(
            cmd,
            capture_output=True,
            **TEXT_MODE_KWARGS,
            timeout=timeout,
        )
    except subprocess.TimeoutExpired as e:
        raise HarperError(f"harper-cli timed out after {timeout} seconds") from e
    except OSError as e:
        raise HarperError(f"Failed to run harper-cli: {e}") from e

    # Parse JSON output (ignore stderr which has informational messages)
    stdout = result.stdout.strip()

    if not stdout:
        # No output - could be no files or an error
        if result.returncode != 0 and result.stderr:
            # Filter out the "Note:" messages about user dictionary
            stderr_lines = [
                line for line in result.stderr.strip().split("\n") if not line.startswith("Note:")
            ]
            if stderr_lines:
                raise HarperError(f"harper-cli error: {' '.join(stderr_lines)}")
        return []

    try:
        data = json.loads(stdout)
    except json.JSONDecodeError as e:
        raise HarperError(f"Failed to parse harper-cli output: {e}") from e

    # Convert to HarperFileResult objects
    results = []
    for file_data in data:
        lints = []
        for lint_data in file_data.get("lints", []):
            span = lint_data.get("span", {})
            lints.append(
                HarperLint(
                    rule=lint_data.get("rule", ""),
                    kind=lint_data.get("kind", ""),
                    line=lint_data.get("line", 0),
                    column=lint_data.get("column", 0),
                    message=lint_data.get("message", ""),
                    matched_text=lint_data.get("matched_text", ""),
                    suggestions=lint_data.get("suggestions", []),
                    priority=lint_data.get("priority", 0),
                    file=file_data.get("file", ""),
                    char_start=span.get("char_start", 0),
                    char_end=span.get("char_end", 0),
                )
            )

        results.append(
            HarperFileResult(
                file=file_data.get("file", ""),
                lint_count=file_data.get("lint_count", 0),
                lints=lints,
                error=file_data.get("error"),
            )
        )

    return results


def run_harper_on_text(
    text: str,
    *,
    dialect: str = "us",
    user_dict_path: Path | str | None = None,
    ignore_rules: list[str] | None = None,
    only_rules: list[str] | None = None,
    harper_path: str | None = None,
    timeout: int = 30,
) -> list[HarperLint]:
    """
    Run harper-cli on text content via stdin.

    This is useful for checking content that doesn't have a file extension
    Harper recognizes (like .qmd files).

    Parameters
    ----------
    text
        Text content to check.
    dialect
        English dialect: 'us', 'uk', 'au', 'in', 'ca'.
    user_dict_path
        Path to a custom dictionary file.
    ignore_rules
        List of rule names to skip.
    only_rules
        List of rule names to run exclusively.
    harper_path
        Path to harper-cli. If None, will search for it.
    timeout
        Maximum seconds to wait.

    Returns
    -------
    list[HarperLint]
        List of lints found in the text.

    Raises
    ------
    HarperNotFoundError
        If harper-cli is not installed.
    HarperError
        If harper-cli returns an error.
    """
    if harper_path is None:
        harper_path = find_harper_cli()

    if harper_path is None:
        raise HarperNotFoundError(
            "harper-cli is not installed.\n\n"
            "Install it with one of:\n"
            "  brew install harper      # macOS (Homebrew)\n"
            "  cargo install harper-cli # Any platform with Rust\n\n"
            "Or download from: https://github.com/Automattic/harper/releases"
        )

    # Build command
    cmd = [
        harper_path,
        "lint",
        "--format",
        "json",
        "--dialect",
        dialect,
    ]

    # Add custom dictionary
    if user_dict_path:
        cmd.extend(["--user-dict-path", str(user_dict_path)])

    # Add rule filters
    if ignore_rules:
        cmd.extend(["--ignore", ",".join(ignore_rules)])

    if only_rules:
        cmd.extend(["--only", ",".join(only_rules)])

    # Run harper-cli with stdin
    try:
        result = subprocess.run(
            cmd,
            input=text,
            capture_output=True,
            **TEXT_MODE_KWARGS,
            timeout=timeout,
        )
    except subprocess.TimeoutExpired as e:
        raise HarperError(f"harper-cli timed out after {timeout} seconds") from e
    except OSError as e:
        raise HarperError(f"Failed to run harper-cli: {e}") from e

    # Parse JSON output
    stdout = result.stdout.strip()

    if not stdout:
        return []

    try:
        data = json.loads(stdout)
    except json.JSONDecodeError as e:
        raise HarperError(f"Failed to parse harper-cli output: {e}") from e

    # Extract lints from the single result
    lints = []
    if data and len(data) > 0:
        for lint_data in data[0].get("lints", []):
            span = lint_data.get("span", {})
            lints.append(
                HarperLint(
                    rule=lint_data.get("rule", ""),
                    kind=lint_data.get("kind", ""),
                    line=lint_data.get("line", 0),
                    column=lint_data.get("column", 0),
                    message=lint_data.get("message", ""),
                    matched_text=lint_data.get("matched_text", ""),
                    suggestions=lint_data.get("suggestions", []),
                    priority=lint_data.get("priority", 0),
                    file="<stdin>",
                    char_start=span.get("char_start", 0),
                    char_end=span.get("char_end", 0),
                )
            )

    return lints


def check_harper_available() -> tuple[bool, str | None]:
    """
    Check if harper-cli is available and return version info.

    Returns
    -------
    tuple[bool, str | None]
        (is_available, version_string or error_message)
    """
    path = find_harper_cli()
    if path is None:
        return False, (
            "harper-cli is not installed.\n"
            "Install with: brew install harper (macOS) or cargo install harper-cli"
        )

    version = get_harper_version(path)
    if version:
        return True, f"harper-cli {version}"
    else:
        return True, f"harper-cli (version unknown) at {path}"
