#!/usr/bin/env python3
"""Update LLM.txt entries by appending an autogenerated symbols block per file.

Rules implemented:
- Include top-level function definitions and class definitions
- Include direct class methods (first-level functions inside classes)
- Include names starting with underscores
- Skip nested functions (functions defined inside other functions or methods)

Behavior:
- Parses LLM.txt sections: "### /abs/or/relative/path" followed by
  "filename.py - Description..." and optional continuation lines
- For each file entry, reads the corresponding .py file and extracts symbols
- Removes any existing autogenerated symbols block, then appends a fresh one
  within the same description block (no blank line), to keep compatibility
  with the existing LLM verifier's parser

Idempotent:
- The block is marked with a sentinel header: "Symbols:"
  so re-running produces stable output with no duplicates.

Exit code:
- 0 on success; 1 if LLM.txt could not be updated or unexpected error
"""

from __future__ import annotations

import argparse
import ast
import logging
import os
import re
import sys

from collections.abc import Sequence
from dataclasses import dataclass, field
from itertools import islice

from kestrel_llm.logging_utils import setup_logging
from kestrel_llm.repo_utils import normalize_path
from kestrel_llm.verify_llm_docs import (
    FILE_LINE_PATTERN as _FILE_LINE_PATTERN,
)
from kestrel_llm.verify_llm_docs import (
    HEADER_PATTERN as _HEADER_PATTERN,
)

HEADER_PATTERN = (
    _HEADER_PATTERN
    if _HEADER_PATTERN is not None
    else re.compile(r"^\s*###\s+(?P<dir>\S+)\s*$")
)
FILE_LINE_PATTERN = (
    _FILE_LINE_PATTERN
    if _FILE_LINE_PATTERN is not None
    else re.compile(r"^\s*(?P<name>[A-Za-z0-9_]+\.py)\s*-\s*(?P<desc>.+?)\s*$")
)


AUTOGEN_SENTINEL = "Symbols:"


@dataclass
class ActiveEntry:
    """In-progress parse state for a single `filename.py - ...` entry."""

    header_dir_abs: str
    filename: str
    abs_path: str
    first_desc: str
    extra_desc_lines: list[str]


@dataclass
class ExtractedSymbols:
    """Symbols extracted from a Python module for inclusion in a `Symbols:` block."""

    functions: list[str]
    classes: list[str]
    methods_fq: list[str]  # ClassName.methodName
    # Optional line number mappings for enriched output
    function_lines: dict[str, int] = field(default_factory=dict)
    class_lines: dict[str, int] = field(default_factory=dict)
    method_lines: dict[str, int] = field(
        default_factory=dict
    )  # key: ClassName.methodName


def extract_symbols(py_file_path: str) -> ExtractedSymbols:
    """Parse the Python file and collect:
    - Top-level functions (FunctionDef/AsyncFunctionDef at module body)
    - Top-level classes (ClassDef at module body)
    - Direct class methods (FunctionDef/AsyncFunctionDef directly inside ClassDef)

    Skip nested functions defined inside other functions or methods.
    Include names starting with underscores.
    """
    with open(py_file_path, encoding="utf-8") as f:
        source = f.read()

    try:
        tree = ast.parse(source, filename=py_file_path)
    except SyntaxError as e:  # pragma: no cover - rare, but we should be robust
        logging.getLogger("update_llm_symbols").warning(
            "Skipping %s due to SyntaxError: %s", py_file_path, e
        )
        return ExtractedSymbols([], [], [])

    top_functions: list[str] = []
    top_classes: list[str] = []
    methods_fq: list[str] = []
    function_lines: dict[str, int] = {}
    class_lines: dict[str, int] = {}
    method_lines: dict[str, int] = {}

    for node in tree.body:
        if isinstance(node, ast.FunctionDef | ast.AsyncFunctionDef):
            top_functions.append(node.name)
            if hasattr(node, "lineno"):
                function_lines[node.name] = int(node.lineno)
        elif isinstance(node, ast.ClassDef):
            top_classes.append(node.name)
            if hasattr(node, "lineno"):
                class_lines[node.name] = int(node.lineno)
            for child in node.body:
                if isinstance(child, ast.FunctionDef | ast.AsyncFunctionDef):
                    fq_name = f"{node.name}.{child.name}"
                    methods_fq.append(fq_name)
                    if hasattr(child, "lineno"):
                        method_lines[fq_name] = int(child.lineno)
                # Intentionally ignore nested defs inside methods

    # Sort for stable output
    top_functions.sort()
    top_classes.sort()
    methods_fq.sort()

    return ExtractedSymbols(
        functions=top_functions,
        classes=top_classes,
        methods_fq=methods_fq,
        function_lines=function_lines,
        class_lines=class_lines,
        method_lines=method_lines,
    )


def _remove_existing_autogen_block(desc_lines: list[str]) -> list[str]:
    """Remove an existing autogenerated symbols block if present.

    We remove from the sentinel line to the end of the description block because
    the block is always appended at the end by this tool.
    """
    if not desc_lines:
        return desc_lines
    try:
        idx = next(
            i for i, line in enumerate(desc_lines) if line.strip() == AUTOGEN_SENTINEL
        )
    except StopIteration:
        return desc_lines
    return desc_lines[:idx]


def _format_symbols_block(
    symbols: ExtractedSymbols, include_line_numbers: bool = False
) -> list[str]:
    lines: list[str] = []
    if not symbols.functions and not symbols.classes and not symbols.methods_fq:
        return lines
    lines.append(AUTOGEN_SENTINEL)
    if symbols.functions:
        if include_line_numbers and symbols.function_lines:
            formatted = [
                f"{name} (L{symbols.function_lines.get(name, '?')})"
                for name in symbols.functions
            ]
            lines.append(f"  - Functions: {', '.join(formatted)}")
        else:
            lines.append(f"  - Functions: {', '.join(symbols.functions)}")
    if symbols.classes:
        if include_line_numbers and symbols.class_lines:
            formatted = [
                f"{name} (L{symbols.class_lines.get(name, '?')})"
                for name in symbols.classes
            ]
            lines.append(f"  - Classes: {', '.join(formatted)}")
        else:
            lines.append(f"  - Classes: {', '.join(symbols.classes)}")
    if symbols.methods_fq:
        if include_line_numbers and symbols.method_lines:
            formatted = [
                f"{name} (L{symbols.method_lines.get(name, '?')})"
                for name in symbols.methods_fq
            ]
            lines.append(f"  - Methods: {', '.join(formatted)}")
        else:
            lines.append(f"  - Methods: {', '.join(symbols.methods_fq)}")
    return lines


def _resolve_header_dir(repo_root: str, header_dir: str) -> str:
    if os.path.isabs(header_dir):
        return normalize_path(header_dir)
    return normalize_path(os.path.join(repo_root, header_dir))


def _flush_active_entry(
    out_lines: list[str],
    active: ActiveEntry,
    include_line_numbers: bool,
    logger: logging.Logger,
) -> None:
    desc_lines: list[str] = [active.first_desc] + active.extra_desc_lines

    if os.path.exists(active.abs_path):
        desc_lines = _remove_existing_autogen_block(desc_lines)
        symbols = extract_symbols(active.abs_path)
        block = _format_symbols_block(
            symbols, include_line_numbers=include_line_numbers
        )
        if block:
            desc_lines.extend(block)
    else:
        logger.debug(
            "File not found for entry, leaving description unchanged: %s",
            active.abs_path,
        )

    out_lines.append(f"{active.filename} - {desc_lines[0]}")
    out_lines.extend(islice(desc_lines, 1, None))


def update_llm_text(
    llm_text: str, repo_root: str, include_line_numbers: bool = False
) -> str:
    """Return a new LLM.txt content with refreshed autogenerated symbols blocks
    for each file that exists on disk.
    """
    logger = logging.getLogger("update_llm_symbols")
    out_lines: list[str] = []

    current_dir_abs: str | None = None
    active: ActiveEntry | None = None

    for raw_line in llm_text.splitlines():
        header_match = HEADER_PATTERN.match(raw_line)
        if header_match:
            if active is not None:
                _flush_active_entry(out_lines, active, include_line_numbers, logger)
                active = None
            current_dir_abs = _resolve_header_dir(repo_root, header_match.group("dir"))
            out_lines.append(raw_line)
            continue

        file_match = FILE_LINE_PATTERN.match(raw_line)
        if file_match and current_dir_abs is not None:
            if active is not None:
                _flush_active_entry(out_lines, active, include_line_numbers, logger)
            filename = file_match.group("name")
            first_desc = file_match.group("desc").strip()
            abs_path = normalize_path(os.path.join(current_dir_abs, filename))
            active = ActiveEntry(
                header_dir_abs=current_dir_abs,
                filename=filename,
                abs_path=abs_path,
                first_desc=first_desc,
                extra_desc_lines=[],
            )
            continue

        if active is not None:
            if raw_line.strip() == "":
                _flush_active_entry(out_lines, active, include_line_numbers, logger)
                active = None
                out_lines.append(raw_line)
            else:
                active.extra_desc_lines.append(raw_line)
            continue

        out_lines.append(raw_line)

    if active is not None:
        _flush_active_entry(out_lines, active, include_line_numbers, logger)

    return "\n".join(out_lines) + "\n"


def update_llm_file(
    repo_root: str, llm_file_path: str, include_line_numbers: bool = False
) -> None:
    """Update an LLM.txt file in-place by refreshing its autogenerated `Symbols:` blocks."""
    with open(llm_file_path, encoding="utf-8") as f:
        original = f.read()
    updated = update_llm_text(
        original,
        repo_root=normalize_path(repo_root),
        include_line_numbers=include_line_numbers,
    )
    if updated != original:
        with open(llm_file_path, "w", encoding="utf-8") as f:
            f.write(updated)


def build_arg_parser() -> argparse.ArgumentParser:
    """Build the CLI argument parser for the standalone script entry point."""
    parser = argparse.ArgumentParser(
        description="Append autogenerated symbols blocks to LLM.txt entries"
    )
    parser.add_argument(
        "--repo-root",
        default=os.environ.get("CONDOR_REPO_ROOT", os.getcwd()),
        help="Absolute path to the repository root (default: CWD or $CONDOR_REPO_ROOT)",
    )
    parser.add_argument(
        "--llm-file",
        default=os.path.join(
            os.environ.get("CONDOR_REPO_ROOT", os.getcwd()), "LLM.txt"
        ),
        help="Absolute path to LLM.txt (default: <repo-root>/LLM.txt)",
    )
    return parser


def main(argv: Sequence[str] | None = None) -> int:
    """Standalone CLI entry point for updating `Symbols:` blocks in an LLM.txt file."""
    parser = build_arg_parser()
    args = parser.parse_args(argv)

    repo_root = normalize_path(args.repo_root)
    llm_file = args.llm_file
    if llm_file == os.path.join(
        os.environ.get("CONDOR_REPO_ROOT", os.getcwd()), "LLM.txt"
    ):
        llm_file = os.path.join(repo_root, "LLM.txt")

    try:
        update_llm_file(
            repo_root=repo_root,
            llm_file_path=llm_file,
            include_line_numbers=True,
        )
    except Exception as e:  # pragma: no cover - safety net for CLI
        logging.getLogger("update_llm_symbols").error("Failed to update LLM.txt: %s", e)
        return 1
    return 0


if __name__ == "__main__":
    setup_logging()
    sys.exit(main())
