"""Claude Code integration for AI-assisted reviews."""

from __future__ import annotations

import logging
import shutil
import subprocess
import sys
import termios
from dataclasses import dataclass
from importlib.resources import files
from pathlib import Path
from typing import TYPE_CHECKING

from jinja2 import BaseLoader, Environment

from .analyze import analyze_module
from .constants import CLAUDE_REVIEW_TIMEOUT, MAX_SOURCE_CHARS, MAX_TEST_FILE_CHARS
from .state_machine import Status

if TYPE_CHECKING:
    from .claude_context import ModuleContext, ProjectContext
    from .config import Config
    from .registry import ModuleStatus


@dataclass
class ChunkInfo:
    """Information about a code chunk for review."""

    content: str
    start_line: int
    end_line: int
    chunk_num: int
    total_chunks: int


def claude_available() -> bool:
    """Check if claude CLI is available."""
    return shutil.which("claude") is not None


def chunk_source_code(
    source: str, max_chars: int = MAX_SOURCE_CHARS
) -> list[ChunkInfo]:
    """Split source code into chunks if too large.

    Tries to split on function/class boundaries when possible.

    Args:
        source: Source code string.
        max_chars: Maximum characters per chunk.

    Returns:
        List of ChunkInfo objects.
    """
    if len(source) <= max_chars:
        line_count = source.count("\n") + 1
        return [
            ChunkInfo(
                content=source,
                start_line=1,
                end_line=line_count,
                chunk_num=1,
                total_chunks=1,
            )
        ]

    # Split into lines
    lines: list[str] = source.split("\n")
    chunks: list[ChunkInfo] = []
    current_chunk_lines: list[str] = []
    current_chunk_start = 1
    current_chars = 0

    for line in lines:
        line_with_newline = line + "\n"

        # Check if adding this line would exceed limit
        if current_chars + len(line_with_newline) > max_chars and current_chunk_lines:
            # Try to find a good break point (def, class, or blank line)
            break_point = len(current_chunk_lines)

            # Look back for a good break point
            for j in range(
                len(current_chunk_lines) - 1, max(0, len(current_chunk_lines) - 50), -1
            ):
                check_line = current_chunk_lines[j].strip()
                if (
                    check_line.startswith("def ")
                    or check_line.startswith("class ")
                    or check_line.startswith("async def ")
                    or check_line == ""
                ):
                    break_point = j
                    break

            # Save current chunk
            chunk_content = "\n".join(current_chunk_lines[:break_point])
            chunks.append(
                ChunkInfo(
                    content=chunk_content,
                    start_line=current_chunk_start,
                    end_line=current_chunk_start + break_point - 1,
                    chunk_num=len(chunks) + 1,
                    total_chunks=0,  # Will update later
                )
            )

            # Start new chunk with remaining lines
            current_chunk_lines = current_chunk_lines[break_point:]
            current_chunk_start = current_chunk_start + break_point
            current_chars = sum(len(ln) + 1 for ln in current_chunk_lines)

        current_chunk_lines.append(line)
        current_chars += len(line_with_newline)

    # Don't forget the last chunk
    if current_chunk_lines:
        chunks.append(
            ChunkInfo(
                content="\n".join(current_chunk_lines),
                start_line=current_chunk_start,
                end_line=current_chunk_start + len(current_chunk_lines) - 1,
                chunk_num=len(chunks) + 1,
                total_chunks=0,
            )
        )

    # Update total_chunks
    for chunk in chunks:
        chunk.total_chunks = len(chunks)

    return chunks


def _format_project_context(project_context: "ProjectContext | None") -> str:
    """Format project context as a markdown string.

    Args:
        project_context: ProjectContext object, or None.

    Returns:
        Formatted markdown string, empty if no context.
    """
    if not project_context:
        return ""

    parts = []
    if project_context.conventions:
        parts.append(f"**Conventions:** {project_context.conventions}")
    if project_context.architecture:
        parts.append(f"**Architecture:** {project_context.architecture}")

    return "\n\n".join(parts) if parts else ""


def _load_test_files(
    module_context: "ModuleContext | None",
    config: "Config",
) -> list[dict[str, str]]:
    """Load test files from module context with truncation.

    Args:
        module_context: ModuleContext with test file paths.
        config: Config with project root.

    Returns:
        List of dicts with 'path' and 'content' keys.
    """
    test_files: list[dict[str, str]] = []

    if not module_context or not module_context.test_files:
        return test_files

    for test_path in module_context.test_files:
        test_full_path = config.root / test_path
        if not test_full_path.exists():
            continue
        try:
            test_content = test_full_path.read_text()
            if len(test_content) > MAX_TEST_FILE_CHARS:
                test_content = (
                    test_content[:MAX_TEST_FILE_CHARS] + "\n\n# ... (truncated)"
                )
            test_files.append({"path": test_path, "content": test_content})
        except (OSError, UnicodeDecodeError):
            pass

    return test_files


def _build_template_context(
    path: str,
    source_code: str,
    module_status: "ModuleStatus",
    config: "Config",
    diff: str | None = None,
    project_context: "ProjectContext | None" = None,
    module_context: "ModuleContext | None" = None,
) -> dict:
    """Assemble the template context dict for a review prompt.

    Args:
        path: Module path.
        source_code: Source code to review.
        module_status: ModuleStatus object.
        config: Config object.
        diff: Diff since last review (for re-reviews).
        project_context: ProjectContext object (optional).
        module_context: ModuleContext object for this module (optional).

    Returns:
        Context dict for template rendering.
    """
    summary = analyze_module(path, cwd=config.root)
    module_contents = summary.format_summary()

    is_rereview = (
        module_status.last_reviewed_commit is not None
        and module_status.status in (Status.IN_PROGRESS.value, Status.REVIEWED.value)
    )

    return {
        "path": path,
        "lines": module_status.lines,
        "module_contents": module_contents,
        "source_code": source_code,
        "is_rereview": is_rereview,
        "previous_commit": module_status.last_reviewed_commit,
        "previous_date": module_status.last_reviewed_date or "unknown",
        "previous_reviewers": ", ".join(module_status.reviewers) or "none",
        "diff": diff,
        "project_context": _format_project_context(project_context),
        "module_purpose": module_context.purpose if module_context else "",
        "test_files": _load_test_files(module_context, config),
        "custom_instructions": config.review_instructions,
    }


def _apply_chunk_header(prompt: str, chunk_info: ChunkInfo) -> str:
    """Insert chunk header into prompt when reviewing in parts.

    Args:
        prompt: The rendered prompt string.
        chunk_info: Chunk information.

    Returns:
        Prompt with chunk header inserted after the first line.
    """
    chunk_header = f"""
**NOTE: This is chunk {chunk_info.chunk_num} of {chunk_info.total_chunks}**
**Lines {chunk_info.start_line}-{chunk_info.end_line}**

Review this portion. Note: you may not have full context from other chunks.

---

"""
    lines = prompt.split("\n", 1)
    return lines[0] + "\n" + chunk_header + lines[1]


def build_review_prompt(
    path: str,
    source_code: str,
    module_status: "ModuleStatus",
    config: "Config",
    diff: str | None = None,
    chunk_info: ChunkInfo | None = None,
    project_context: "ProjectContext | None" = None,
    module_context: "ModuleContext | None" = None,
) -> str:
    """Build the review prompt for Claude.

    Args:
        path: Module path.
        source_code: Source code to review.
        module_status: ModuleStatus object.
        config: Config object.
        diff: Diff since last review (for re-reviews).
        chunk_info: Chunk information if reviewing in parts.
        project_context: ProjectContext object (optional).
        module_context: ModuleContext object for this module (optional).

    Returns:
        Formatted prompt string.
    """
    template_file = files("glreview.templates").joinpath("claude_review_prompt.md")
    template_content = template_file.read_text()

    # autoescape=False is safe: output is markdown for GitLab/Claude, not browser HTML
    env = Environment(loader=BaseLoader(), autoescape=False)  # noqa: S701
    template = env.from_string(template_content)

    context = _build_template_context(
        path,
        source_code,
        module_status,
        config,
        diff=diff,
        project_context=project_context,
        module_context=module_context,
    )

    prompt = template.render(**context)

    if chunk_info and chunk_info.total_chunks > 1:
        prompt = _apply_chunk_header(prompt, chunk_info)

    return prompt


def run_claude_review(
    prompt: str,
    model: str = "opus",
) -> tuple[str, bool]:
    """Run Claude CLI with the review prompt.

    Passes the prompt via stdin to avoid OS argument length limits.

    Args:
        prompt: The review prompt.
        model: Claude model to use.

    Returns:
        Tuple of (response_text, success).
    """
    logger = logging.getLogger(__name__)

    if not claude_available():
        return "Error: claude CLI not found. Install Claude Code first.", False

    proc = None
    try:
        # Use Popen for better signal handling.
        # Prompt is passed via stdin (using -p -) to avoid OS argument length
        # limits (~256KB on Linux) which large source files can exceed.
        proc = subprocess.Popen(
            ["claude", "--print", "--model", model, "-p", "-"],
            stdin=subprocess.PIPE,
            stdout=subprocess.PIPE,
            stderr=subprocess.PIPE,
            text=True,
        )

        stdout, stderr = proc.communicate(input=prompt, timeout=CLAUDE_REVIEW_TIMEOUT)

        if proc.returncode != 0:
            return f"Error running claude: {stderr}", False

        return stdout, True

    except subprocess.TimeoutExpired:
        if proc:
            proc.kill()
            proc.communicate()  # Reap the process
        timeout_mins = CLAUDE_REVIEW_TIMEOUT // 60
        return f"Error: Claude review timed out after {timeout_mins} minutes.", False
    except KeyboardInterrupt:
        if proc:
            proc.terminate()
            try:
                proc.wait(timeout=5)
            except subprocess.TimeoutExpired:
                proc.kill()
                proc.wait()  # Reap the process after SIGKILL
        raise  # Re-raise to let the caller handle it
    except Exception as e:
        logger.debug("Claude review failed", exc_info=True)
        if proc:
            proc.kill()
            proc.wait()  # Reap the process
        return f"Error: {e}", False


def get_file_diff(
    path: str,
    since_commit: str,
    cwd: Path | None = None,
) -> str | None:
    """Get diff for a file since a given commit.

    Args:
        path: File path.
        since_commit: Commit to diff from.
        cwd: Working directory.

    Returns:
        Diff string, or None if no diff or error.
    """
    try:
        result = subprocess.run(
            ["git", "diff", since_commit, "HEAD", "--", path],
            cwd=cwd,
            capture_output=True,
            text=True,
        )
        if result.returncode == 0 and result.stdout.strip():
            return result.stdout
    except OSError:
        # git not available or other OS error
        return None
    return None


@dataclass
class ReviewItem:
    """A single item from a code review."""

    criterion: str
    rating: str  # OK, SUGGESTED, REQUIRED
    description: str


@dataclass
class ParsedReview:
    """Parsed review findings."""

    overall_assessment: str
    items: list[ReviewItem]

    @property
    def required_items(self) -> list[ReviewItem]:
        """Get items marked as REQUIRED."""
        return [item for item in self.items if item.rating == "REQUIRED"]

    @property
    def suggested_items(self) -> list[ReviewItem]:
        """Get items marked as SUGGESTED."""
        return [item for item in self.items if item.rating == "SUGGESTED"]


def parse_review_output(review_text: str) -> ParsedReview:
    """Parse Claude review output to extract findings.

    Args:
        review_text: Raw review output from Claude.

    Returns:
        ParsedReview with extracted items.
    """
    import re

    items = []
    overall_assessment = ""

    # Extract overall assessment: **Verdict:** [OK | REQUIRED]
    assessment_match = re.search(
        r"\*\*Verdict:\*\*\s*\[?(OK|REQUIRED)\]?",
        review_text,
        re.IGNORECASE,
    )
    if assessment_match:
        overall_assessment = assessment_match.group(1).upper()

    # Extract individual review items: ### Criterion: REQUIRED
    item_pattern = re.compile(
        r"###\s*([^:\n]+):\s*(SUGGESTED|REQUIRED)\s*\n(.*?)(?=###|\n---|\Z)",
        re.DOTALL | re.IGNORECASE,
    )

    for match in item_pattern.finditer(review_text):
        criterion = match.group(1).strip()
        rating = match.group(2).upper()
        description = match.group(3).strip()

        if description:
            items.append(
                ReviewItem(
                    criterion=criterion,
                    rating=rating,
                    description=description,
                )
            )

    return ParsedReview(
        overall_assessment=overall_assessment,
        items=items,
    )


def build_fix_prompt(
    path: str,
    source_code: str,
    module_status: "ModuleStatus",
    config: "Config",
    required_items: list[ReviewItem],
    suggested_items: list[ReviewItem] | None = None,
    project_context: "ProjectContext | None" = None,
    module_context: "ModuleContext | None" = None,
    fix_all: bool = False,
) -> str:
    """Build the fix prompt for Claude.

    Args:
        path: Module path.
        source_code: Source code to fix.
        module_status: ModuleStatus object (unused but kept for API consistency).
        config: Config object.
        required_items: List of REQUIRED review items.
        suggested_items: List of SUGGESTED review items (optional).
        project_context: ProjectContext object (optional).
        module_context: ModuleContext object for this module (optional).
        fix_all: Whether to fix SUGGESTED items too.

    Returns:
        Formatted prompt string.
    """
    # Silence unused parameter warning - kept for API consistency
    _ = module_status

    # Load template
    template_file = files("glreview.templates").joinpath("claude_fix_prompt.md")
    template_content = template_file.read_text()

    env = Environment(loader=BaseLoader(), autoescape=False)  # noqa: S701
    template = env.from_string(template_content)

    # Convert ReviewItems to dicts for template
    required_dicts = [
        {"criterion": item.criterion, "description": item.description}
        for item in required_items
    ]
    suggested_dicts = []
    if fix_all and suggested_items:
        suggested_dicts = [
            {"criterion": item.criterion, "description": item.description}
            for item in suggested_items
        ]

    context = {
        "path": path,
        "source_code": source_code,
        "project_context": _format_project_context(project_context),
        "test_files": _load_test_files(module_context, config),
        "required_items": required_dicts,
        "suggested_items": suggested_dicts if fix_all else [],
        "fix_all": fix_all,
        "custom_instructions": config.fix_instructions,
    }

    return template.render(**context)


def _save_terminal_state() -> list | None:
    """Save terminal attributes if stdin is a TTY.

    Returns:
        Terminal attributes list, or None if not a TTY.
    """
    try:
        if sys.stdin.isatty():
            return termios.tcgetattr(sys.stdin)
    except (termios.error, OSError):
        pass
    return None


def _restore_terminal_state(saved: list | None) -> None:
    """Restore terminal attributes if previously saved.

    Args:
        saved: Terminal attributes from _save_terminal_state().
    """
    if saved is None:
        return
    try:
        termios.tcsetattr(sys.stdin, termios.TCSADRAIN, saved)
    except (termios.error, OSError):
        pass


def run_claude_interactive(
    prompt: str,
    model: str = "opus",
) -> bool:
    """Run Claude CLI interactively with an initial prompt.

    Saves and restores terminal state to protect against the subprocess
    crashing while in raw mode (which would leave the terminal unusable).

    Args:
        prompt: The initial prompt to start the session.
        model: Claude model to use.

    Returns:
        True if session completed successfully.
    """
    logger = logging.getLogger(__name__)

    if not claude_available():
        return False

    saved_term = _save_terminal_state()
    try:
        # Run claude interactively - pass prompt as positional argument
        # (no -p/--print flag, which would make it non-interactive)
        result = subprocess.run(
            ["claude", "--model", model, prompt],
        )
        return result.returncode == 0

    except KeyboardInterrupt:
        logger.debug("Interactive Claude session interrupted by user")
        return False
    except Exception:
        logger.debug("Interactive Claude session failed", exc_info=True)
        return False
    finally:
        _restore_terminal_state(saved_term)
