"""Wrapper for ICSharpCode.ILSpyCmd command line tool."""

import asyncio
import logging
import os
import re
import shutil
import tempfile
from pathlib import Path
from typing import Any

from .models import (
    AssemblyInfo,
    AssemblyInfoRequest,
    DecompileRequest,
    DecompileResponse,
    GenerateDiagrammerRequest,
    ListTypesRequest,
    ListTypesResponse,
    TypeInfo,
)

logger = logging.getLogger(__name__)


class ILSpyWrapper:
    """Wrapper class for ILSpy command line tool."""

    def __init__(self, ilspycmd_path: str | None = None) -> None:
        """Initialize the wrapper.

        Args:
            ilspycmd_path: Path to ilspycmd executable. If None, will try to find it in PATH.
        """
        self.ilspycmd_path = ilspycmd_path or self._find_ilspycmd()
        if not self.ilspycmd_path:
            raise RuntimeError(
                "ILSpyCmd not found. Please install it with: dotnet tool install --global ilspycmd"
            )

    def _find_ilspycmd(self) -> str | None:
        """Find ilspycmd executable in PATH or common install locations.

        Checks:
        1. Standard PATH (via shutil.which)
        2. ~/.dotnet/tools (default location for 'dotnet tool install --global')
        3. Platform-specific locations
        """
        # Try standard PATH first
        for cmd_name in ["ilspycmd", "ilspycmd.exe"]:
            path = shutil.which(cmd_name)
            if path:
                return path

        # Check common dotnet tools locations (not always in PATH)
        home = os.path.expanduser("~")
        dotnet_tools_paths = [
            os.path.join(home, ".dotnet", "tools", "ilspycmd"),
            os.path.join(home, ".dotnet", "tools", "ilspycmd.exe"),
        ]

        # Windows-specific paths
        if os.name == "nt":
            userprofile = os.environ.get("USERPROFILE", "")
            if userprofile:
                dotnet_tools_paths.extend([
                    os.path.join(userprofile, ".dotnet", "tools", "ilspycmd.exe"),
                ])

        for tool_path in dotnet_tools_paths:
            if os.path.isfile(tool_path) and os.access(tool_path, os.X_OK):
                logger.info(f"Found ilspycmd at {tool_path} (not in PATH)")
                return tool_path

        return None

    async def _run_command(
        self, args: list[str], input_data: str | None = None
    ) -> tuple[int, str, str]:
        """Run ilspycmd with given arguments.

        Args:
            args: Command line arguments
            input_data: Optional input data to pass to stdin

        Returns:
            Tuple of (return_code, stdout, stderr)
        """
        cmd = [self.ilspycmd_path] + args
        logger.debug(f"Running command: {' '.join(cmd)}")

        try:
            process = await asyncio.create_subprocess_exec(
                *cmd,
                stdin=asyncio.subprocess.PIPE if input_data else None,
                stdout=asyncio.subprocess.PIPE,
                stderr=asyncio.subprocess.PIPE,
            )

            input_bytes = input_data.encode("utf-8") if input_data else None

            # Timeout after 5 minutes to prevent hanging on malicious/corrupted assemblies
            try:
                stdout_bytes, stderr_bytes = await asyncio.wait_for(
                    process.communicate(input=input_bytes),
                    timeout=300.0  # 5 minutes
                )
            except asyncio.TimeoutError:
                logger.warning("Command timed out after 5 minutes, killing process")
                process.kill()
                await process.wait()  # Ensure process is cleaned up
                return -1, "", "Command timed out after 5 minutes. The assembly may be corrupted or too complex."

            stdout = stdout_bytes.decode("utf-8", errors="replace") if stdout_bytes else ""
            stderr = stderr_bytes.decode("utf-8", errors="replace") if stderr_bytes else ""

            return process.returncode, stdout, stderr

        except Exception as e:
            logger.error(f"Error running command: {e}")
            return -1, "", str(e)

    async def decompile(self, request: DecompileRequest) -> DecompileResponse:
        """Decompile a .NET assembly.

        Args:
            request: Decompilation request

        Returns:
            Decompilation response
        """
        if not os.path.exists(request.assembly_path):
            return DecompileResponse(
                success=False,
                error_message=f"Assembly file not found: {request.assembly_path}",
                assembly_name=os.path.basename(request.assembly_path),
            )

        args = [request.assembly_path]

        # Add language version
        args.extend(["-lv", request.language_version.value])

        # Add type filter if specified
        if request.type_name:
            args.extend(["-t", request.type_name])

        # Add output directory if specified
        temp_dir = None
        output_dir = request.output_dir
        if not output_dir:
            temp_dir = tempfile.mkdtemp()
            output_dir = temp_dir

        args.extend(["-o", output_dir])

        # Add project creation flag
        if request.create_project:
            args.append("-p")

        # Add IL code flag
        if request.show_il_code:
            args.append("-il")

        # Add reference paths
        for ref_path in request.reference_paths:
            args.extend(["-r", ref_path])

        # Add optimization flags
        if request.remove_dead_code:
            args.append("--no-dead-code")

        if request.remove_dead_stores:
            args.append("--no-dead-stores")

        # Add IL sequence points flag
        if request.show_il_sequence_points:
            args.append("--il-sequence-points")

        # Add directory structure flag
        if request.nested_directories:
            args.append("--nested-directories")

        # Disable update check for automation
        args.append("--disable-updatecheck")

        try:
            return_code, stdout, stderr = await self._run_command(args)

            assembly_name = os.path.splitext(os.path.basename(request.assembly_path))[0]

            if return_code == 0:
                # If no output directory was specified, return stdout as source code
                source_code = None
                output_path = None

                if request.output_dir is None:
                    source_code = stdout
                else:
                    output_path = output_dir
                    # Try to read the main generated file if it exists
                    if request.type_name:
                        # Single type decompilation
                        type_file = os.path.join(
                            output_dir, f"{request.type_name.split('.')[-1]}.cs"
                        )
                        if os.path.exists(type_file):
                            with open(type_file, encoding="utf-8") as f:
                                source_code = f.read()
                    elif not request.create_project:
                        # Single file decompilation
                        cs_file = os.path.join(output_dir, f"{assembly_name}.cs")
                        if os.path.exists(cs_file):
                            with open(cs_file, encoding="utf-8") as f:
                                source_code = f.read()

                return DecompileResponse(
                    success=True,
                    source_code=source_code,
                    output_path=output_path,
                    assembly_name=assembly_name,
                    type_name=request.type_name,
                )
            else:
                error_msg = stderr or stdout or "Unknown error occurred"
                return DecompileResponse(
                    success=False,
                    error_message=error_msg,
                    assembly_name=assembly_name,
                    type_name=request.type_name,
                )

        except Exception as e:
            return DecompileResponse(
                success=False,
                error_message=str(e),
                assembly_name=os.path.basename(request.assembly_path),
                type_name=request.type_name,
            )
        finally:
            # Clean up temporary directory
            if temp_dir and os.path.exists(temp_dir):
                shutil.rmtree(temp_dir, ignore_errors=True)

    async def list_types(self, request: ListTypesRequest) -> ListTypesResponse:
        """List types in a .NET assembly.

        Args:
            request: List types request

        Returns:
            List types response
        """
        if not os.path.exists(request.assembly_path):
            return ListTypesResponse(
                success=False, error_message=f"Assembly file not found: {request.assembly_path}"
            )

        args = [request.assembly_path]

        # Add entity types to list
        entity_types_str = "".join([et.value for et in request.entity_types])
        args.extend(["-l", entity_types_str])

        # Add reference paths
        for ref_path in request.reference_paths:
            args.extend(["-r", ref_path])

        # Disable update check
        args.append("--disable-updatecheck")

        try:
            return_code, stdout, stderr = await self._run_command(args)

            if return_code == 0:
                types = self._parse_types_output(stdout)
                return ListTypesResponse(success=True, types=types, total_count=len(types))
            else:
                error_msg = stderr or stdout or "Unknown error occurred"
                return ListTypesResponse(success=False, error_message=error_msg)

        except Exception as e:
            return ListTypesResponse(success=False, error_message=str(e))

    # Compiled regex for parsing ilspycmd list output
    # Format: "TypeKind: FullTypeName" (e.g., "Class: MyNamespace.MyClass")
    _TYPE_LINE_PATTERN = re.compile(r"^(\w+):\s*(.+)$")

    def _parse_types_output(self, output: str) -> list[TypeInfo]:
        """Parse the output from list types command.

        Args:
            output: Raw output from ilspycmd

        Returns:
            List of TypeInfo objects

        Note:
            ilspycmd outputs types in format "TypeKind: FullTypeName"
            Examples:
                Class: MyNamespace.MyClass
                Interface: MyNamespace.IService
                Struct: MyNamespace.MyStruct
                Class: MyNamespace.Outer+Nested  (nested types)
        """
        types = []
        lines = output.strip().split("\n")
        unparsed_count = 0

        for line in lines:
            line = line.strip()
            if not line:
                continue

            match = self._TYPE_LINE_PATTERN.match(line)
            if match:
                kind = match.group(1)
                full_name = match.group(2).strip()

                # Extract namespace and name, handling nested types (+ separator)
                name, namespace = self._split_type_name(full_name)

                types.append(
                    TypeInfo(name=name, full_name=full_name, kind=kind, namespace=namespace)
                )
            else:
                # Log unexpected lines (but don't fail - ilspycmd may output warnings/info)
                unparsed_count += 1
                if unparsed_count <= 3:  # Avoid log spam
                    logger.debug(f"Skipping unparsed line from ilspycmd: {line[:100]}")

        if unparsed_count > 3:
            logger.debug(f"Skipped {unparsed_count} unparsed lines total")

        return types

    @staticmethod
    def _split_type_name(full_name: str) -> tuple[str, str | None]:
        """Split a full type name into (name, namespace).

        Handles:
            - Simple types: "MyClass" -> ("MyClass", None)
            - Namespaced types: "MyNamespace.MyClass" -> ("MyClass", "MyNamespace")
            - Nested types: "MyNamespace.Outer+Nested" -> ("Outer+Nested", "MyNamespace")
            - Deep nesting: "NS.Outer+Mid+Inner" -> ("Outer+Mid+Inner", "NS")

        Args:
            full_name: Fully qualified type name

        Returns:
            Tuple of (type_name, namespace_or_none)
        """
        # Find the last dot that's not inside a nested type (before any +)
        # For "NS.Outer+Nested", we want to split at the dot, not after +
        plus_idx = full_name.find("+")
        if plus_idx != -1:
            # Has nested types - only look for namespace separator before the +
            prefix = full_name[:plus_idx]
            suffix = full_name[plus_idx:]  # includes the +
            dot_idx = prefix.rfind(".")
            if dot_idx != -1:
                namespace = prefix[:dot_idx]
                name = prefix[dot_idx + 1:] + suffix
                return name, namespace
            else:
                # No namespace, just nested types
                return full_name, None
        else:
            # No nested types - simple split on last dot
            dot_idx = full_name.rfind(".")
            if dot_idx != -1:
                return full_name[dot_idx + 1:], full_name[:dot_idx]
            else:
                return full_name, None

    async def generate_diagrammer(self, request: GenerateDiagrammerRequest) -> dict[str, Any]:
        """Generate HTML diagrammer for an assembly.

        Args:
            request: Generate diagrammer request

        Returns:
            Dictionary with success status and details
        """
        if not os.path.exists(request.assembly_path):
            return {
                "success": False,
                "error_message": f"Assembly file not found: {request.assembly_path}",
            }

        args = [request.assembly_path, "--generate-diagrammer"]

        # Add output directory
        output_dir = request.output_dir
        if not output_dir:
            # Generate next to assembly
            assembly_dir = os.path.dirname(request.assembly_path)
            output_dir = os.path.join(assembly_dir, "diagrammer")

        args.extend(["-o", output_dir])

        # Add include/exclude patterns
        if request.include_pattern:
            args.extend(["--generate-diagrammer-include", request.include_pattern])
        if request.exclude_pattern:
            args.extend(["--generate-diagrammer-exclude", request.exclude_pattern])

        # Add documentation file
        if request.docs_path:
            args.extend(["--generate-diagrammer-docs", request.docs_path])

        # Add namespace stripping
        if request.strip_namespaces:
            args.extend(["--generate-diagrammer-strip-namespaces"] + request.strip_namespaces)

        # Add report excluded flag
        if request.report_excluded:
            args.append("--generate-diagrammer-report-excluded")

        # Disable update check
        args.append("--disable-updatecheck")

        try:
            return_code, stdout, stderr = await self._run_command(args)

            if return_code == 0:
                return {
                    "success": True,
                    "output_directory": output_dir,
                    "message": "HTML diagrammer generated successfully",
                }
            else:
                error_msg = stderr or stdout or "Unknown error occurred"
                return {"success": False, "error_message": error_msg}

        except Exception as e:
            return {"success": False, "error_message": str(e)}

    async def get_assembly_info(self, request: AssemblyInfoRequest) -> AssemblyInfo:
        """Get detailed information about an assembly by decompiling assembly attributes.

        Args:
            request: Assembly info request

        Returns:
            Assembly information including version, target framework, etc.
        """
        if not os.path.exists(request.assembly_path):
            raise FileNotFoundError(f"Assembly file not found: {request.assembly_path}")

        assembly_path = Path(request.assembly_path)

        # Use ilspycmd to list types and extract assembly info from output
        args = [request.assembly_path, "-l", "c", "--disable-updatecheck"]
        return_code, stdout, stderr = await self._run_command(args)

        # Initialize with defaults
        name = assembly_path.stem
        version = "Unknown"
        full_name = assembly_path.name
        target_framework = None
        runtime_version = None
        is_signed = False

        # Try to extract more info by decompiling assembly attributes
        # Decompile with minimal output to get assembly-level attributes
        temp_dir = tempfile.mkdtemp()
        try:
            args = [
                request.assembly_path,
                "-o",
                temp_dir,
                "-lv",
                "Latest",
                "--disable-updatecheck",
            ]
            return_code, stdout, stderr = await self._run_command(args)

            if return_code == 0:
                # Look for AssemblyInfo or assembly attributes in output
                # Parse common patterns from decompiled code
                output_text = stdout

                # Try to read main decompiled file for assembly attributes
                main_file = os.path.join(temp_dir, f"{name}.cs")
                if os.path.exists(main_file):
                    with open(main_file, encoding="utf-8") as f:
                        output_text = f.read()

                # Extract version from AssemblyVersion attribute
                version_match = re.search(
                    r'\[assembly:\s*AssemblyVersion\s*\(\s*"([^"]+)"\s*\)\s*\]', output_text
                )
                if version_match:
                    version = version_match.group(1)

                # Extract file version as fallback
                if version == "Unknown":
                    file_version_match = re.search(
                        r'\[assembly:\s*AssemblyFileVersion\s*\(\s*"([^"]+)"\s*\)\s*\]', output_text
                    )
                    if file_version_match:
                        version = file_version_match.group(1)

                # Extract target framework
                framework_match = re.search(
                    r'\[assembly:\s*TargetFramework\s*\(\s*"([^"]+)"', output_text
                )
                if framework_match:
                    target_framework = framework_match.group(1)

                # Check for signing
                is_signed = (
                    "[assembly: AssemblyKeyFile" in output_text
                    or "[assembly: AssemblyDelaySign" in output_text
                    or "PublicKeyToken=" in output_text
                )

                # Extract full name from assembly title or product
                title_match = re.search(
                    r'\[assembly:\s*AssemblyTitle\s*\(\s*"([^"]+)"\s*\)\s*\]', output_text
                )
                if title_match:
                    full_name = f"{title_match.group(1)}, Version={version}"

        finally:
            # Clean up temp directory
            shutil.rmtree(temp_dir, ignore_errors=True)

        return AssemblyInfo(
            name=name,
            version=version,
            full_name=full_name,
            location=str(assembly_path.absolute()),
            target_framework=target_framework,
            runtime_version=runtime_version,
            is_signed=is_signed,
            has_debug_info=os.path.exists(assembly_path.with_suffix(".pdb")),
        )
