"""Analyze code changes and extract documentation-relevant information."""

from __future__ import annotations

import argparse
import ast
import json
import re
import subprocess
from dataclasses import asdict, dataclass, field
from pathlib import Path
from typing import Literal

import yaml


@dataclass
class CodeChange:
    """Represents a code change detected in a Python file."""

    file_path: str
    change_type: Literal['added', 'modified', 'removed']
    element_type: Literal['function', 'class', 'method', 'module_docstring']
    element_name: str
    old_signature: str | None
    new_signature: str | None
    docstring_changed: bool
    breaking_change: bool
    impact_level: Literal['critical', 'high', 'medium', 'low']
    is_public: bool = True
    has_docstring: bool = True
    line_number: int = 0  # Line number in the new file


@dataclass
class DocContent:
    """Represents analyzed content from a documentation file."""

    file_path: str
    code_references: list[str] = field(default_factory=list)
    code_examples: list[str] = field(default_factory=list)
    api_signatures: list[str] = field(default_factory=list)
    # Maps API reference name to list of (line_number, line_content) tuples
    signature_lines: dict[str, list[tuple[int, str]]] = field(default_factory=dict)


@dataclass
class DocMapping:
    """Mapping from code file to documentation file."""

    doc_path: str
    mapping_type: Literal['api_reference', 'conceptual', 'project']
    priority: int


class PythonChangeAnalyzer:
    """Analyze Python code changes using AST."""

    def analyze_diff(self, old_content: str, new_content: str, file_path: str) -> list[CodeChange]:
        """Analyze differences between two versions of Python code."""
        changes: list[CodeChange] = []

        old_ast = ast.parse(old_content) if old_content.strip() else None
        new_ast = ast.parse(new_content) if new_content.strip() else None

        # Extract elements from both versions
        old_elements = self._extract_elements(old_ast) if old_ast else {}
        new_elements = self._extract_elements(new_ast) if new_ast else {}

        # Detect added elements
        for name, node in new_elements.items():
            if name not in old_elements:
                changes.append(
                    self._create_change(
                        file_path=file_path,
                        change_type='added',
                        node=node,
                        name=name,
                        old_node=None,
                    )
                )

        # Detect removed elements
        for name, node in old_elements.items():
            if name not in new_elements:
                changes.append(
                    self._create_change(
                        file_path=file_path,
                        change_type='removed',
                        node=None,
                        name=name,
                        old_node=node,
                    )
                )

        # Detect modified elements
        for name in old_elements.keys() & new_elements.keys():
            old_node = old_elements[name]
            new_node = new_elements[name]

            if self._has_changes(old_node, new_node):
                changes.append(
                    self._create_change(
                        file_path=file_path,
                        change_type='modified',
                        node=new_node,
                        name=name,
                        old_node=old_node,
                    )
                )

        return changes

    def _extract_elements(self, tree: ast.AST) -> dict[str, ast.FunctionDef | ast.AsyncFunctionDef | ast.ClassDef]:
        """Extract functions and classes from AST."""
        elements: dict[str, ast.FunctionDef | ast.AsyncFunctionDef | ast.ClassDef] = {}

        for node in ast.walk(tree):
            if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):
                elements[node.name] = node
            elif isinstance(node, ast.ClassDef):
                elements[node.name] = node
                # Also extract methods
                for item in node.body:
                    if isinstance(item, (ast.FunctionDef, ast.AsyncFunctionDef)):
                        method_name = f'{node.name}.{item.name}'
                        elements[method_name] = item

        return elements

    def _create_change(
        self,
        file_path: str,
        change_type: Literal['added', 'modified', 'removed'],
        node: ast.AST | None,
        name: str,
        old_node: ast.AST | None,
    ) -> CodeChange:
        """Create a CodeChange object from AST nodes."""
        # Determine element type
        if '.' in name:
            element_type = 'method'
        elif node and isinstance(node, ast.ClassDef):
            element_type = 'class'
        elif old_node and isinstance(old_node, ast.ClassDef):
            element_type = 'class'
        else:
            element_type = 'function'

        # Get signatures
        old_signature = self._get_signature(old_node) if old_node else None
        new_signature = self._get_signature(node) if node else None

        # Check for breaking changes
        breaking_change = self._is_breaking_change(old_node, node, change_type)

        # Check docstring changes
        docstring_changed = self._docstring_changed(old_node, node)

        # Check if has docstring
        has_docstring = self._has_docstring(node) if node else False

        # Determine if public
        is_public = not name.startswith('_')

        # Determine impact level
        impact_level = self._assess_impact(breaking_change, element_type, is_public)

        # Get line number from node
        line_number = 0
        if node and hasattr(node, 'lineno'):
            line_number = node.lineno
        elif old_node and hasattr(old_node, 'lineno'):
            line_number = old_node.lineno

        return CodeChange(
            file_path=file_path,
            change_type=change_type,
            element_type=element_type,
            element_name=name,
            old_signature=old_signature,
            new_signature=new_signature,
            docstring_changed=docstring_changed,
            breaking_change=breaking_change,
            impact_level=impact_level,
            is_public=is_public,
            has_docstring=has_docstring,
            line_number=line_number,
        )

    def _get_signature(self, node: ast.AST | None) -> str | None:
        """Extract function/class signature as string."""
        if node is None:
            return None

        if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):
            args = []
            for arg in node.args.args:
                arg_str = arg.arg
                if arg.annotation:
                    arg_str += f': {ast.unparse(arg.annotation)}'
                args.append(arg_str)

            # Add defaults
            defaults_offset = len(node.args.args) - len(node.args.defaults)
            for i, default in enumerate(node.args.defaults):
                idx = defaults_offset + i
                args[idx] += f' = {ast.unparse(default)}'

            returns = ''
            if node.returns:
                returns = f' -> {ast.unparse(node.returns)}'

            async_prefix = 'async ' if isinstance(node, ast.AsyncFunctionDef) else ''
            return f'{async_prefix}def {node.name}({", ".join(args)}){returns}:'

        elif isinstance(node, ast.ClassDef):
            bases = [ast.unparse(base) for base in node.bases]
            base_str = f'({", ".join(bases)})' if bases else ''
            return f'class {node.name}{base_str}:'

        return None

    def _has_changes(self, old_node: ast.AST, new_node: ast.AST) -> bool:
        """Check if there are meaningful changes between nodes."""
        old_sig = self._get_signature(old_node)
        new_sig = self._get_signature(new_node)

        if old_sig != new_sig:
            return True

        if self._docstring_changed(old_node, new_node):
            return True

        return False

    def _is_breaking_change(
        self,
        old_node: ast.AST | None,
        new_node: ast.AST | None,
        change_type: str,
    ) -> bool:
        """Determine if change is breaking."""
        if change_type == 'removed':
            return True

        if change_type == 'added':
            return False

        if not isinstance(old_node, (ast.FunctionDef, ast.AsyncFunctionDef)):
            return False
        if not isinstance(new_node, (ast.FunctionDef, ast.AsyncFunctionDef)):
            return False

        # Check for required parameter additions
        old_required = self._get_required_params(old_node)
        new_required = self._get_required_params(new_node)

        if new_required - old_required:
            return True

        # Check for parameter removals
        old_params = {arg.arg for arg in old_node.args.args}
        new_params = {arg.arg for arg in new_node.args.args}

        if old_params - new_params:
            return True

        # Check for return type changes
        old_return = ast.unparse(old_node.returns) if old_node.returns else None
        new_return = ast.unparse(new_node.returns) if new_node.returns else None

        if old_return and new_return and old_return != new_return:
            return True

        return False

    def _get_required_params(self, node: ast.FunctionDef | ast.AsyncFunctionDef) -> set[str]:
        """Get set of required parameter names."""
        num_defaults = len(node.args.defaults)
        num_args = len(node.args.args)
        required_count = num_args - num_defaults

        return {arg.arg for arg in node.args.args[:required_count] if arg.arg != 'self'}

    def _docstring_changed(self, old_node: ast.AST | None, new_node: ast.AST | None) -> bool:
        """Check if docstring has changed."""
        old_doc = ast.get_docstring(old_node) if old_node else None
        new_doc = ast.get_docstring(new_node) if new_node else None

        return old_doc != new_doc

    def _has_docstring(self, node: ast.AST | None) -> bool:
        """Check if node has a docstring."""
        if node is None:
            return False
        return ast.get_docstring(node) is not None

    def _assess_impact(
        self, breaking: bool, element_type: str, is_public: bool
    ) -> Literal['critical', 'high', 'medium', 'low']:
        """Assess impact level of a change."""
        if not is_public:
            return 'low'

        if breaking:
            return 'critical'

        if element_type == 'class':
            return 'high'

        if element_type == 'function':
            return 'high'

        return 'medium'


class DocumentAnalyzer:
    """Analyze Markdown documentation files."""

    def analyze_document(self, doc_path: str, content: str) -> DocContent:
        """Analyze markdown document content."""
        signature_lines = self._extract_signature_lines(content)
        return DocContent(
            file_path=doc_path,
            code_references=self._extract_code_references(content),
            code_examples=self._extract_code_examples(content),
            api_signatures=self._extract_api_signatures(content),
            signature_lines=signature_lines,
        )

    def _extract_code_references(self, content: str) -> list[str]:
        """Extract code references from markdown."""
        references: list[str] = []

        # Pattern: `ClassName`, `function_name()`, `module.Class`
        inline_code = re.findall(r'`([A-Za-z_][A-Za-z0-9_\.]*(?:\(\))?)`', content)
        references.extend(inline_code)

        # Pattern: synapse_sdk.plugins.action
        module_refs = re.findall(r'synapse_sdk\.[A-Za-z0-9_\.]+', content)
        references.extend(module_refs)

        return list(set(references))

    def _extract_code_examples(self, content: str) -> list[str]:
        """Extract Python code blocks from markdown."""
        pattern = r'```python\s*(.*?)```'
        return re.findall(pattern, content, re.DOTALL)

    def _extract_api_signatures(self, content: str) -> list[str]:
        """Extract API signatures from markdown."""
        signatures: list[str] = []

        # Pattern: def function_name(args) -> return_type:
        func_pattern = r'def\s+([A-Za-z_][A-Za-z0-9_]*)'
        signatures.extend(re.findall(func_pattern, content))

        # Pattern: class ClassName(BaseClass):
        class_pattern = r'class\s+([A-Za-z_][A-Za-z0-9_]*)'
        signatures.extend(re.findall(class_pattern, content))

        return list(set(signatures))

    def _extract_signature_lines(self, content: str) -> dict[str, list[tuple[int, str]]]:
        """Extract API signatures with their line numbers from markdown.

        Returns:
            Dict mapping function/class names to list of (line_number, full_line) tuples.
        """
        signature_lines: dict[str, list[tuple[int, str]]] = {}
        lines = content.split('\n')

        # Patterns for function and class signatures
        func_pattern = r'def\s+([A-Za-z_][A-Za-z0-9_]*)\s*\([^)]*\)'
        class_pattern = r'class\s+([A-Za-z_][A-Za-z0-9_]*)'

        for line_num, line in enumerate(lines, start=1):
            # Check for function signatures
            func_match = re.search(func_pattern, line)
            if func_match:
                name = func_match.group(1)
                if name not in signature_lines:
                    signature_lines[name] = []
                signature_lines[name].append((line_num, line))

            # Check for class signatures
            class_match = re.search(class_pattern, line)
            if class_match:
                name = class_match.group(1)
                if name not in signature_lines:
                    signature_lines[name] = []
                signature_lines[name].append((line_num, line))

        return signature_lines


class CodeToDocMapper:
    """Map code files to documentation files dynamically.

    Uses convention-based mapping with glob patterns:
    1. API Reference: synapse_sdk/X/Y.py -> docs/docs/developer-guides/reference/synapse_sdk/X/Y.md
    2. Conceptual: Search for docs that reference the module/class names
    3. Project: AGENT.md, README.md for public API changes
    """

    def __init__(self, config_path: str):
        self.config_path = config_path
        self.config = self._load_config()
        self._doc_cache: dict[str, list[str]] = {}

    def _load_config(self) -> dict:
        """Load mapping configuration from YAML."""
        config_file = Path(self.config_path)
        if not config_file.exists():
            return {'mappings': {}}

        with open(config_file) as f:
            return yaml.safe_load(f)

    def map_code_to_docs(self, code_path: str) -> list[DocMapping]:
        """Map a code file path to relevant documentation files.

        Uses dynamic discovery:
        1. Convention-based API reference mapping (code path -> doc path)
        2. Content-based search (find docs mentioning the module/class)
        3. Static mappings from config for special cases
        """
        mappings: list[DocMapping] = []
        seen_paths: set[str] = set()

        # 1. API Reference - convention-based (highest priority)
        ref_doc = self._find_api_reference_doc(code_path)
        if ref_doc and ref_doc not in seen_paths:
            mappings.append(
                DocMapping(
                    doc_path=ref_doc,
                    mapping_type='api_reference',
                    priority=1,
                )
            )
            seen_paths.add(ref_doc)

        # 2. Conceptual docs - search for references
        module_name = self._extract_module_name(code_path)
        conceptual_docs = self._find_docs_referencing(module_name)
        for doc in conceptual_docs:
            if doc not in seen_paths:
                mappings.append(
                    DocMapping(
                        doc_path=doc,
                        mapping_type='conceptual',
                        priority=2,
                    )
                )
                seen_paths.add(doc)

        # 3. Static mappings from config (for special cases)
        static_mappings = self._get_static_mappings(code_path)
        for doc, priority in static_mappings:
            if doc not in seen_paths:
                mappings.append(
                    DocMapping(
                        doc_path=doc,
                        mapping_type='conceptual',
                        priority=priority,
                    )
                )
                seen_paths.add(doc)

        # 4. Project docs (lowest priority, always include for public APIs)
        for project_doc in ['AGENT.md', 'README.md']:
            if Path(project_doc).exists() and project_doc not in seen_paths:
                mappings.append(
                    DocMapping(
                        doc_path=project_doc,
                        mapping_type='project',
                        priority=3,
                    )
                )
                seen_paths.add(project_doc)

        return sorted(mappings, key=lambda m: m.priority)

    def _find_api_reference_doc(self, code_path: str) -> str | None:
        """Find API reference doc using convention-based path transformation."""
        if not code_path.startswith('synapse_sdk/'):
            return None

        # Convention: synapse_sdk/X/Y.py -> docs/docs/developer-guides/reference/synapse_sdk/X/Y.md
        doc_path = code_path.replace('synapse_sdk/', 'docs/docs/developer-guides/reference/synapse_sdk/').replace(
            '.py', '.md'
        )

        if Path(doc_path).exists():
            return doc_path

        # Also try index.md for __init__.py
        if code_path.endswith('__init__.py'):
            index_path = doc_path.replace('__init__.md', 'index.md')
            if Path(index_path).exists():
                return index_path

        return None

    def _extract_module_name(self, code_path: str) -> str:
        """Extract module/class name from code path for searching."""
        # synapse_sdk/plugins/action.py -> action, plugins.action
        path = Path(code_path)
        stem = path.stem  # action

        # Get parent module for more specific search
        if path.parent.name and path.parent.name != 'synapse_sdk':
            return f'{path.parent.name}.{stem}'

        return stem

    def _find_docs_referencing(self, module_name: str) -> list[str]:
        """Find documentation files that reference the given module."""
        if not module_name or module_name == '__init__':
            return []

        # Cache doc content for performance
        if not self._doc_cache:
            self._build_doc_cache()

        matching_docs: list[str] = []
        search_terms = [
            module_name,
            module_name.replace('.', '/'),
            module_name.split('.')[-1],  # Just the file name
        ]

        for doc_path, content in self._doc_cache.items():
            content_lower = content.lower()
            for term in search_terms:
                if term.lower() in content_lower:
                    matching_docs.append(doc_path)
                    break

        return matching_docs

    def _build_doc_cache(self) -> None:
        """Build cache of documentation file contents."""
        docs_dir = Path('docs/docs')
        if not docs_dir.exists():
            return

        for md_file in docs_dir.rglob('*.md'):
            try:
                content = md_file.read_text(errors='ignore')
                self._doc_cache[str(md_file)] = content
            except Exception:
                pass

    def _get_static_mappings(self, code_path: str) -> list[tuple[str, int]]:
        """Get static mappings from config for special cases."""
        mappings: list[tuple[str, int]] = []
        mappings_config = self.config.get('mappings', {})

        # Check conceptual docs mapping
        conceptual = mappings_config.get('conceptual', [])
        for rule in conceptual:
            sources = rule.get('sources', [])
            docs = rule.get('docs', [])
            priority = rule.get('priority', 2)

            for source_pattern in sources:
                if self._matches_pattern(code_path, source_pattern):
                    for doc in docs:
                        if Path(doc).exists():
                            mappings.append((doc, priority))

        return mappings

    def _matches_pattern(self, path: str, pattern: str) -> bool:
        """Check if path matches glob pattern."""
        import fnmatch

        return fnmatch.fnmatch(path, pattern)


def get_changed_files(base_ref: str, head_ref: str) -> list[str]:
    """Get list of changed files between two git refs."""
    result = subprocess.run(
        ['git', 'diff', '--name-only', f'{base_ref}...{head_ref}', '--', '*.py'],
        capture_output=True,
        text=True,
        check=True,
    )
    return [f.strip() for f in result.stdout.strip().split('\n') if f.strip()]


def get_file_content(file_path: str, ref: str | None = None) -> str:
    """Get file content from git ref or working directory."""
    if ref:
        try:
            result = subprocess.run(
                ['git', 'show', f'{ref}:{file_path}'],
                capture_output=True,
                text=True,
                check=True,
            )
            return result.stdout
        except subprocess.CalledProcessError:
            return ''
    else:
        path = Path(file_path)
        return path.read_text() if path.exists() else ''


def _should_ignore_file(file_path: str, ignore_patterns: list[str]) -> bool:
    """Check if file matches any ignore pattern.

    Args:
        file_path: Path to check.
        ignore_patterns: List of glob patterns to ignore.

    Returns:
        True if file should be ignored.
    """
    import fnmatch

    for pattern in ignore_patterns:
        if fnmatch.fnmatch(file_path, pattern):
            return True
    return False


def analyze_changes(base_ref: str, head_ref: str, output_path: str | None = None) -> dict:
    """Analyze all code changes between two refs."""
    analyzer = PythonChangeAnalyzer()
    mapper = CodeToDocMapper('.github/doc-sync-mapping.yaml')

    # Load ignore patterns from config
    ignore_patterns = mapper.config.get('ignore', [])

    changed_files = get_changed_files(base_ref, head_ref)
    all_changes: list[dict] = []
    doc_mappings: dict[str, list[str]] = {}

    for file_path in changed_files:
        if not file_path.endswith('.py'):
            continue

        # Skip ignored files
        if _should_ignore_file(file_path, ignore_patterns):
            continue

        old_content = get_file_content(file_path, base_ref)
        new_content = get_file_content(file_path, head_ref)

        changes = analyzer.analyze_diff(old_content, new_content, file_path)

        for change in changes:
            all_changes.append(asdict(change))

        # Map to documentation
        mappings = mapper.map_code_to_docs(file_path)
        doc_mappings[file_path] = [m.doc_path for m in mappings]

    # Filter out ignored files from the analyzed list
    analyzed_files = [f for f in changed_files if f.endswith('.py') and not _should_ignore_file(f, ignore_patterns)]

    result = {
        'changes': all_changes,
        'doc_mappings': doc_mappings,
        'files_analyzed': analyzed_files,
    }

    if output_path:
        with open(output_path, 'w') as f:
            json.dump(result, f, indent=2, ensure_ascii=False)

    return result


def main():
    """Main entry point for analyze script."""
    parser = argparse.ArgumentParser(description='Analyze code changes for documentation sync')
    parser.add_argument('--base', required=True, help='Base git ref')
    parser.add_argument('--head', default='HEAD', help='Head git ref')
    parser.add_argument('--output', '-o', help='Output JSON file path')
    args = parser.parse_args()

    result = analyze_changes(args.base, args.head, args.output)

    if not args.output:
        print(json.dumps(result, indent=2, ensure_ascii=False))


if __name__ == '__main__':
    main()
