"""ReferenceTransformer - Converts Lark reference parse tree to ParsedContent list.

Handles both code_ref and test_ref content types.  Pre-scan data (function/class
context) is injected from external sources -- AST-based for Python, text-based
for others, or external prescan command.

The grammar pre-classifies lines as single_ref, block_header, block_ref,
test_name_ref, control_marker, or other_line.  This transformer:
1. Extracts requirement IDs from classified lines
2. Annotates with function/class context from pre-scan
3. Produces ParsedContent matching the old CodeParser/TestParser contracts

Keyword semantics per file type:

- **test files**: Only ``Verifies`` is valid.  ``Implements`` and ``Refines``
  produce a warning and are treated as ``Verifies`` for backward compatibility.
- **code files**: ``Implements`` creates IMPLEMENTS edges.  ``Verifies`` creates
  VERIFIES edges (for code that produces pass/fail results).  ``Refines`` is
  invalid (requirement-to-requirement only) and produces a warning; the
  reference is skipped.
"""

from __future__ import annotations

import logging
import re
from typing import TYPE_CHECKING, Any

from lark import Tree

from elspais.graph.parsers import ParsedContent

if TYPE_CHECKING:
    from elspais.utilities.patterns import IdResolver

_log = logging.getLogger(__name__)

# Hardcoded comment styles for empty-comment detection
_COMMENT_STYLES = ["#", "//", "--"]

# Keyword detection pattern — matches the keyword portion of a reference comment
_KEYWORD_RE = re.compile(r"(?:implements|verifies|refines)", re.IGNORECASE)


class ReferenceTransformer:
    """Transform a reference.lark parse tree into ParsedContent objects.

    Args:
        resolver: IdResolver for normalizing requirement IDs.
        content_type: Output content type -- "code_ref" or "test_ref".
        line_context: Pre-scan data mapping line_number -> (func_name, class_name, func_line).
        file_default_verifies: File-level default verifies (for test files).
        expected_broken_count: From elspais control marker (for test files).
        all_test_funcs: All test functions from pre-scan (for emitting unlinked tests).
    """

    def __init__(
        self,
        resolver: IdResolver,
        content_type: str,
        line_context: dict[int, tuple[str | None, str | None, int, int]] | None = None,
        file_default_verifies: list[str] | None = None,
        expected_broken_count: int = 0,
        all_test_funcs: list[tuple[int, str, str | None]] | None = None,
        source_id: str = "",
    ) -> None:
        self.resolver = resolver
        self.content_type = content_type
        self.line_context = line_context or {}
        self.file_default_verifies = file_default_verifies or []
        self.expected_broken_count = expected_broken_count
        self.all_test_funcs = all_test_funcs or []
        self.source_id = source_id
        self.warnings: list[str] = []

    def transform(self, tree: Tree) -> list[ParsedContent]:
        """Transform parse tree into ParsedContent list."""
        results: list[ParsedContent] = []
        emitted_func_lines: set[int] = set()

        # Track other_line ranges for remainder grouping
        other_lines: list[tuple[int, str]] = []

        # First pass: process classified lines
        i = 0
        children = tree.children
        while i < len(children):
            child = children[i]
            if not isinstance(child, Tree):
                i += 1
                continue

            if child.data == "other_line":
                token = child.children[0]
                other_lines.append((token.line, str(token)))  # type: ignore[attr-defined]
                i += 1
                continue

            if child.data == "single_ref":
                pc = self._handle_single_ref(child)
                if pc:
                    if pc.parsed_data.get("function_line"):
                        emitted_func_lines.add(pc.parsed_data["function_line"])
                    results.append(pc)

            elif child.data == "block_header":
                # Collect subsequent block_ref lines
                refs: list[str] = []
                start_ln = self._token_line(child)
                end_ln = start_ln
                header_text = self._token_text(child)
                raw_lines = [header_text]
                keyword = self._detect_keyword(header_text)
                i += 1
                while i < len(children):
                    next_child = children[i]
                    if isinstance(next_child, Tree) and next_child.data == "block_ref":
                        ref_text = self._token_text(next_child)
                        extracted = self._extract_ids(ref_text)
                        refs.extend(extracted)
                        end_ln = self._token_line(next_child)
                        raw_lines.append(ref_text)
                        i += 1
                    elif isinstance(next_child, Tree) and next_child.data == "other_line":
                        # Could be empty comment line -- skip
                        text = self._token_text(next_child)
                        if self._is_empty_comment(text):
                            i += 1
                            continue
                        break
                    else:
                        break

                if refs:
                    # Skip keywords invalid for this file type (silently —
                    # test fixtures legitimately contain cross-type keywords
                    # in string literals that the grammar also matches)
                    if self.content_type == "code_ref" and keyword == "refines":
                        continue
                    if self.content_type == "test_ref" and keyword != "verifies":
                        continue

                    func_name, class_name, func_line, func_end_line = self.line_context.get(
                        start_ln, (None, None, 0, 0)
                    )

                    if self.content_type == "code_ref":
                        parsed_data: dict[str, Any] = {
                            "implements": refs if keyword == "implements" else [],
                            "verifies": refs if keyword == "verifies" else [],
                            "function_name": func_name,
                            "class_name": class_name,
                            "function_line": func_line,
                            "function_end_line": func_end_line,
                        }
                    else:  # test_ref
                        parsed_data = {
                            "verifies": refs,
                            "function_name": func_name,
                            "class_name": class_name,
                            "function_line": func_line,
                            "function_end_line": func_end_line,
                            "file_default_verifies": self.file_default_verifies,
                        }

                    if func_line:
                        emitted_func_lines.add(func_line)
                    results.append(
                        ParsedContent(
                            content_type=self.content_type,
                            start_line=start_ln,
                            end_line=end_ln,
                            raw_text="\n".join(raw_lines),
                            parsed_data=parsed_data,
                        )
                    )
                continue

            elif child.data == "test_name_ref":
                pc = self._handle_test_name_ref(child)
                if pc:
                    if pc.parsed_data.get("function_line"):
                        emitted_func_lines.add(pc.parsed_data["function_line"])
                    results.append(pc)

            elif child.data == "control_marker":
                # Already extracted during pre-processing; skip
                pass

            i += 1

        # For test files: emit unlinked test functions (third pass)
        if self.content_type == "test_ref":
            for func_line, func_name, class_name in self.all_test_funcs:
                if func_line not in emitted_func_lines:
                    verifies = list(self.file_default_verifies)
                    results.append(
                        ParsedContent(
                            content_type="test_ref",
                            start_line=func_line,
                            end_line=func_line,
                            raw_text="",
                            parsed_data={
                                "verifies": verifies,
                                "function_name": func_name,
                                "class_name": class_name,
                                "function_line": func_line,
                                "file_default_verifies": self.file_default_verifies,
                            },
                        )
                    )

        # Emit remainder blocks for unclaimed lines.
        # For code/test files, merge all unclaimed lines into a single remainder
        # (coarse grouping). Spec files need line-precise grouping for round-trip
        # rendering, but code/test files only need remainders preserved for
        # potential future reference rewriting.
        coarse = self.content_type in ("code_ref", "test_ref")
        self._flush_remainder(other_lines, results, coarse=coarse)

        return results

    def _flush_remainder(
        self,
        lines: list[tuple[int, str]],
        results: list[ParsedContent],
        *,
        coarse: bool = False,
    ) -> None:
        """Group other_lines into remainder ParsedContent blocks.

        Args:
            lines: (line_number, text) pairs for unclaimed lines.
            results: List to append remainder ParsedContent to.
            coarse: If True, emit one remainder per file (all unclaimed lines
                merged). Used for code/test files where line-precise grouping
                isn't needed.
        """
        if not lines:
            return

        if coarse:
            # Single remainder covering all unclaimed lines
            results.append(
                ParsedContent(
                    content_type="remainder",
                    start_line=lines[0][0],
                    end_line=lines[-1][0],
                    raw_text="\n".join(text for _, text in lines),
                    parsed_data={},
                )
            )
            return

        # Fine-grained: group strictly contiguous lines (for spec files)
        groups: list[list[tuple[int, str]]] = []
        current: list[tuple[int, str]] = []

        for ln, text in lines:
            if not current:
                current.append((ln, text))
            elif ln == current[-1][0] + 1:
                current.append((ln, text))
            else:
                groups.append(current)
                current = [(ln, text)]
        if current:
            groups.append(current)

        for group in groups:
            results.append(
                ParsedContent(
                    content_type="remainder",
                    start_line=group[0][0],
                    end_line=group[-1][0],
                    raw_text="\n".join(text for _, text in group),
                    parsed_data={},
                )
            )

    # ------------------------------------------------------------------
    # Single reference handling
    # ------------------------------------------------------------------

    def _handle_single_ref(self, node: Tree) -> ParsedContent | None:
        """Handle a single-line reference comment."""
        token = node.children[0]
        text = str(token)
        line_num = token.line  # type: ignore[attr-defined]

        refs = self._extract_ids(text)
        if not refs:
            return None

        func_name, class_name, func_line, func_end_line = self.line_context.get(
            line_num, (None, None, 0, 0)
        )

        keyword = self._detect_keyword(text)

        if self.content_type == "code_ref":
            if keyword == "refines":
                return None  # Refines is requirement-to-requirement only
            parsed_data: dict[str, Any] = {
                "implements": refs if keyword == "implements" else [],
                "verifies": refs if keyword == "verifies" else [],
                "function_name": func_name,
                "class_name": class_name,
                "function_line": func_line,
                "function_end_line": func_end_line,
            }
        else:  # test_ref
            if keyword != "verifies":
                return None  # Only Verifies is valid in test files
            parsed_data = {
                "verifies": refs,
                "function_name": func_name,
                "class_name": class_name,
                "function_line": func_line,
                "file_default_verifies": self.file_default_verifies,
            }
            if self.expected_broken_count > 0:
                parsed_data["expected_broken_count"] = self.expected_broken_count

        return ParsedContent(
            content_type=self.content_type,
            start_line=line_num,
            end_line=line_num,
            raw_text=text,
            parsed_data=parsed_data,
        )

    # ------------------------------------------------------------------
    # Test name reference handling
    # ------------------------------------------------------------------

    def _handle_test_name_ref(self, node: Tree) -> ParsedContent | None:
        """Handle a test function name containing a REQ reference."""
        token = node.children[0]
        text = str(token)
        line_num = token.line  # type: ignore[attr-defined]

        # Extract REQ_xxx from function name (underscored form).
        # Test names use underscores: def test_foo_REQ_p00001_A
        # We need to match the ID part (REQ_p00001) and optional assertion
        # suffix (_A) but stop before lowercase continuation (_validates).
        prefix = self.resolver.config.namespace
        type_codes = self.resolver.all_type_alias_values()
        if type_codes:
            type_pattern = f"(?:{'|'.join(re.escape(t) for t in type_codes)})"
        else:
            type_pattern = r"[a-z]"
        comp = self.resolver.config.component
        if comp.style == "numeric":
            id_number = rf"\d{{{comp.digits}}}"
        else:
            id_number = r"[A-Za-z0-9]+"

        # Assertion: uppercase letter NOT followed by lowercase (to avoid
        # matching _validates as assertion V)
        assertion_pat = r"(?:_[A-Z](?![a-z]))+"

        full_pattern = re.compile(
            rf"(?P<ref>{re.escape(prefix)}_{type_pattern}{id_number}" rf"(?:{assertion_pat})?)",
            re.IGNORECASE,
        )
        match = full_pattern.search(text)
        if not match:
            return None

        ref = self.resolver.normalize_ref(match.group("ref"))
        func_name, class_name, func_line, _func_end = self.line_context.get(
            line_num, (None, None, 0, 0)
        )

        parsed_data: dict[str, Any] = {
            "verifies": [ref],
            "function_name": func_name,
            "class_name": class_name,
            "function_line": func_line,
            "file_default_verifies": self.file_default_verifies,
        }
        if self.expected_broken_count > 0:
            parsed_data["expected_broken_count"] = self.expected_broken_count

        return ParsedContent(
            content_type="test_ref",
            start_line=line_num,
            end_line=line_num,
            raw_text=text,
            parsed_data=parsed_data,
        )

    # ------------------------------------------------------------------
    # Helpers
    # ------------------------------------------------------------------

    def _extract_ids(self, text: str) -> list[str]:
        """Extract requirement IDs from a reference line (including multi-assertion syntax)."""
        prefix = self.resolver.config.namespace
        multi_sep = re.escape(self.resolver.config.assertions.multi_separator or "+")
        pattern = re.compile(
            rf"{re.escape(prefix)}[-_][A-Za-z0-9\-_]+(?:{multi_sep}[A-Za-z0-9]+)*",
            re.IGNORECASE,
        )
        refs = []
        for m in pattern.finditer(text):
            ref = self.resolver.normalize_ref(m.group(0))
            if ref not in refs:
                refs.append(ref)
        return refs

    def _detect_keyword(self, text: str) -> str:
        """Detect which reference keyword is used in *text*.

        Returns:
            ``"implements"``, ``"verifies"``, or ``"refines"``.
            Falls back to ``"implements"`` if no keyword is found.
        """
        m = _KEYWORD_RE.search(text)
        if m:
            return m.group(0).lower()
        return "implements"

    def _is_empty_comment(self, text: str) -> bool:
        """Check if a line is an empty comment."""
        stripped = text.strip()
        for style in _COMMENT_STYLES:
            if stripped.startswith(style):
                remainder = stripped[len(style) :].strip().rstrip("#/-").strip()
                if not remainder:
                    return True
        return False

    def _token_line(self, node: Tree) -> int:
        """Get line number from a tree node's first token."""
        token = node.children[0]
        return token.line  # type: ignore[attr-defined]

    def _token_text(self, node: Tree) -> str:
        """Get text from a tree node's first token."""
        return str(node.children[0])
