"""Analyzer for RAM-stored strings in ESP8266/ESP32 firmware ELF files.

This module identifies strings that are stored in RAM sections (.data, .bss, .rodata)
rather than in flash sections (.irom0.text, .irom.text), which is important for
memory-constrained platforms like ESP8266.
"""

from __future__ import annotations

from collections import defaultdict
from dataclasses import dataclass, field
import logging
from pathlib import Path
import re
import subprocess

from .demangle import batch_demangle
from .toolchain import find_tool

_LOGGER = logging.getLogger(__name__)

# ESP8266: .rodata is in RAM (DRAM), not flash
# ESP32: .rodata is in flash, mapped to data bus
ESP8266_RAM_SECTIONS = frozenset([".data", ".rodata", ".bss"])
ESP8266_FLASH_SECTIONS = frozenset([".irom0.text", ".irom.text", ".text"])

# ESP32: .rodata is memory-mapped from flash
ESP32_RAM_SECTIONS = frozenset([".data", ".bss", ".dram0.data", ".dram0.bss"])
ESP32_FLASH_SECTIONS = frozenset([".text", ".rodata", ".flash.text", ".flash.rodata"])

# nm symbol types for data symbols (D=global data, d=local data, R=rodata, B=bss)
DATA_SYMBOL_TYPES = frozenset(["D", "d", "R", "r", "B", "b"])


@dataclass
class SectionInfo:
    """Information about an ELF section."""

    name: str
    address: int
    size: int


@dataclass
class RamString:
    """A string found in RAM."""

    section: str
    address: int
    content: str

    @property
    def size(self) -> int:
        """Size in bytes including null terminator."""
        return len(self.content) + 1


@dataclass
class RamSymbol:
    """A symbol found in RAM."""

    name: str
    sym_type: str
    address: int
    size: int
    section: str
    demangled: str = ""  # Demangled name, set after batch demangling
    aliases: list[str] = field(default_factory=list)  # Other names at same address


class RamStringsAnalyzer:
    """Analyzes ELF files to find strings stored in RAM."""

    def __init__(
        self,
        elf_path: str,
        objdump_path: str | None = None,
        min_length: int = 8,
        platform: str = "esp32",
    ) -> None:
        """Initialize the RAM strings analyzer.

        Args:
            elf_path: Path to the ELF file to analyze
            objdump_path: Path to objdump binary (used to find other tools)
            min_length: Minimum string length to report (default: 8)
            platform: Platform name ("esp8266", "esp32", etc.) for section mapping
        """
        self.elf_path = Path(elf_path)
        if not self.elf_path.exists():
            raise FileNotFoundError(f"ELF file not found: {elf_path}")

        self.objdump_path = objdump_path
        self.min_length = min_length
        self.platform = platform

        # Set RAM/flash sections based on platform
        if self.platform == "esp8266":
            self.ram_sections = ESP8266_RAM_SECTIONS
            self.flash_sections = ESP8266_FLASH_SECTIONS
        else:
            # ESP32 and other platforms
            self.ram_sections = ESP32_RAM_SECTIONS
            self.flash_sections = ESP32_FLASH_SECTIONS

        self.sections: dict[str, SectionInfo] = {}
        self.ram_strings: list[RamString] = []
        self.ram_symbols: list[RamSymbol] = []

    def _run_command(self, cmd: list[str]) -> str:
        """Run a command and return its output."""
        try:
            result = subprocess.run(cmd, capture_output=True, text=True, check=True)
            return result.stdout
        except subprocess.CalledProcessError as e:
            _LOGGER.debug("Command failed: %s - %s", " ".join(cmd), e.stderr)
            raise
        except FileNotFoundError:
            _LOGGER.warning("Command not found: %s", cmd[0])
            raise

    def analyze(self) -> None:
        """Perform the full RAM analysis."""
        self._parse_sections()
        self._extract_strings()
        self._analyze_symbols()
        self._demangle_symbols()

    def _parse_sections(self) -> None:
        """Parse section headers from ELF file."""
        objdump = find_tool("objdump", self.objdump_path)
        if not objdump:
            _LOGGER.error("Could not find objdump command")
            return

        try:
            output = self._run_command([objdump, "-h", str(self.elf_path)])
        except (subprocess.CalledProcessError, FileNotFoundError):
            return

        # Parse section headers
        # Format: Idx Name          Size      VMA       LMA       File off  Algn
        section_pattern = re.compile(
            r"^\s*\d+\s+(\S+)\s+([0-9a-fA-F]+)\s+([0-9a-fA-F]+)"
        )

        for line in output.split("\n"):
            if match := section_pattern.match(line):
                name = match.group(1)
                size = int(match.group(2), 16)
                vma = int(match.group(3), 16)
                self.sections[name] = SectionInfo(name, vma, size)

    def _extract_strings(self) -> None:
        """Extract strings from RAM sections."""
        objdump = find_tool("objdump", self.objdump_path)
        if not objdump:
            return

        for section_name in self.ram_sections:
            if section_name not in self.sections:
                continue

            try:
                output = self._run_command(
                    [objdump, "-s", "-j", section_name, str(self.elf_path)]
                )
            except subprocess.CalledProcessError:
                # Section may exist but have no content (e.g., .bss)
                continue
            except FileNotFoundError:
                continue

            strings = self._parse_hex_dump(output, section_name)
            self.ram_strings.extend(strings)

    def _parse_hex_dump(self, output: str, section_name: str) -> list[RamString]:
        """Parse hex dump output to extract strings.

        Args:
            output: Output from objdump -s
            section_name: Name of the section being parsed

        Returns:
            List of RamString objects
        """
        strings: list[RamString] = []
        current_string = bytearray()
        string_start_addr = 0

        for line in output.split("\n"):
            # Lines look like: " 3ffef8a0 00000000 00000000 00000000 00000000  ................"
            match = re.match(r"^\s+([0-9a-fA-F]+)\s+((?:[0-9a-fA-F]{2,8}\s*)+)", line)
            if not match:
                continue

            addr = int(match.group(1), 16)
            hex_data = match.group(2).strip()

            # Convert hex to bytes
            hex_bytes = hex_data.split()
            byte_offset = 0
            for hex_chunk in hex_bytes:
                # Handle both byte-by-byte and word formats
                for i in range(0, len(hex_chunk), 2):
                    byte_val = int(hex_chunk[i : i + 2], 16)
                    if 0x20 <= byte_val <= 0x7E:  # Printable ASCII
                        if not current_string:
                            string_start_addr = addr + byte_offset
                        current_string.append(byte_val)
                    else:
                        if byte_val == 0 and len(current_string) >= self.min_length:
                            # Found null terminator
                            strings.append(
                                RamString(
                                    section=section_name,
                                    address=string_start_addr,
                                    content=current_string.decode(
                                        "ascii", errors="ignore"
                                    ),
                                )
                            )
                        current_string = bytearray()
                    byte_offset += 1

        return strings

    def _analyze_symbols(self) -> None:
        """Analyze symbols in RAM sections."""
        nm = find_tool("nm", self.objdump_path)
        if not nm:
            return

        try:
            output = self._run_command([nm, "-S", "--size-sort", str(self.elf_path)])
        except (subprocess.CalledProcessError, FileNotFoundError):
            return

        # Track symbols by address so aliases (multiple names for the same
        # object, e.g. the newlib __lock___* mutexes that all alias one
        # StaticSemaphore_t) are reported once instead of once per name.
        symbols_by_addr: dict[int, RamSymbol] = {}

        for line in output.split("\n"):
            parts = line.split()
            if len(parts) < 4:
                continue

            try:
                addr = int(parts[0], 16)
                size = int(parts[1], 16) if parts[1] != "?" else 0
            except ValueError:
                continue

            sym_type = parts[2]
            name = " ".join(parts[3:])

            # Filter for data symbols
            if sym_type not in DATA_SYMBOL_TYPES:
                continue

            if (existing := symbols_by_addr.get(addr)) is not None:
                # Prefer a global (uppercase type) name as the primary so
                # nm output order can't hide it behind a local alias.
                if sym_type.isupper() and existing.sym_type.islower():
                    existing.aliases.append(existing.name)
                    existing.name = name
                    existing.sym_type = sym_type
                else:
                    existing.aliases.append(name)
                existing.size = max(existing.size, size)
                continue

            # Check if symbol is in a RAM section
            for section_name in self.ram_sections:
                if section_name not in self.sections:
                    continue

                section = self.sections[section_name]
                if section.address <= addr < section.address + section.size:
                    symbol = RamSymbol(
                        name=name,
                        sym_type=sym_type,
                        address=addr,
                        size=size,
                        section=section_name,
                    )
                    symbols_by_addr[addr] = symbol
                    self.ram_symbols.append(symbol)
                    break

    def _demangle_symbols(self) -> None:
        """Batch demangle all RAM symbol names."""
        if not self.ram_symbols:
            return

        # Collect all symbol names and demangle them
        symbol_names = [s.name for s in self.ram_symbols]
        demangle_cache = batch_demangle(symbol_names, objdump_path=self.objdump_path)

        # Assign demangled names to symbols
        for symbol in self.ram_symbols:
            symbol.demangled = demangle_cache.get(symbol.name, symbol.name)

    def _get_sections_size(self, section_names: frozenset[str]) -> int:
        """Get total size of specified sections."""
        return sum(
            section.size
            for name, section in self.sections.items()
            if name in section_names
        )

    def get_total_ram_usage(self) -> int:
        """Get total RAM usage from RAM sections."""
        return self._get_sections_size(self.ram_sections)

    def get_total_flash_usage(self) -> int:
        """Get total flash usage from flash sections."""
        return self._get_sections_size(self.flash_sections)

    def get_total_string_bytes(self) -> int:
        """Get total bytes used by strings in RAM."""
        return sum(s.size for s in self.ram_strings)

    def get_repeated_strings(self) -> list[tuple[str, int]]:
        """Find strings that appear multiple times.

        Returns:
            List of (string, count) tuples sorted by potential savings
        """
        string_counts: dict[str, int] = defaultdict(int)
        for ram_string in self.ram_strings:
            string_counts[ram_string.content] += 1

        return sorted(
            [(s, c) for s, c in string_counts.items() if c > 1],
            key=lambda x: x[1] * (len(x[0]) + 1),
            reverse=True,
        )

    def get_long_strings(self, min_len: int = 20) -> list[RamString]:
        """Get strings longer than the specified length.

        Args:
            min_len: Minimum string length

        Returns:
            List of RamString objects sorted by length
        """
        return sorted(
            [s for s in self.ram_strings if len(s.content) >= min_len],
            key=lambda x: len(x.content),
            reverse=True,
        )

    def get_largest_symbols(self, min_size: int = 100) -> list[RamSymbol]:
        """Get RAM symbols larger than the specified size.

        Args:
            min_size: Minimum symbol size in bytes

        Returns:
            List of RamSymbol objects sorted by size
        """
        return sorted(
            [s for s in self.ram_symbols if s.size >= min_size],
            key=lambda x: x.size,
            reverse=True,
        )

    def generate_report(self, show_all_sections: bool = False) -> str:
        """Generate a formatted RAM strings analysis report.

        Args:
            show_all_sections: If True, show all sections, not just RAM

        Returns:
            Formatted report string
        """
        lines: list[str] = []
        table_width = 80

        lines.append("=" * table_width)
        lines.append(
            f"RAM Strings Analysis ({self.platform.upper()})".center(table_width)
        )
        lines.append("=" * table_width)
        lines.append("")

        # Section Analysis
        lines.append("SECTION ANALYSIS")
        lines.append("-" * table_width)
        lines.append(f"{'Section':<20} {'Address':<12} {'Size':<12} {'Location'}")
        lines.append("-" * table_width)

        total_ram_usage = 0
        total_flash_usage = 0

        for name, section in sorted(self.sections.items(), key=lambda x: x[1].address):
            if name in self.ram_sections:
                location = "RAM"
                total_ram_usage += section.size
            elif name in self.flash_sections:
                location = "FLASH"
                total_flash_usage += section.size
            else:
                location = "OTHER"

            if show_all_sections or name in self.ram_sections:
                lines.append(
                    f"{name:<20} 0x{section.address:08x}   {section.size:>8} B   {location}"
                )

        lines.append("-" * table_width)
        lines.append(f"Total RAM sections size: {total_ram_usage:,} bytes")
        lines.append(f"Total Flash sections size: {total_flash_usage:,} bytes")

        # Strings in RAM
        lines.append("")
        lines.append("=" * table_width)
        lines.append("STRINGS IN RAM SECTIONS")
        lines.append("=" * table_width)
        lines.append(
            "Note: .bss sections contain uninitialized data (no strings to extract)"
        )

        # Group strings by section
        strings_by_section: dict[str, list[RamString]] = defaultdict(list)
        for ram_string in self.ram_strings:
            strings_by_section[ram_string.section].append(ram_string)

        for section_name in sorted(strings_by_section.keys()):
            section_strings = strings_by_section[section_name]
            lines.append(f"\nSection: {section_name}")
            lines.append("-" * 40)
            for ram_string in sorted(section_strings, key=lambda x: x.address):
                clean_string = ram_string.content[:100] + (
                    "..." if len(ram_string.content) > 100 else ""
                )
                lines.append(
                    f'  0x{ram_string.address:08x}: "{clean_string}" (len={len(ram_string.content)})'
                )

        # Large RAM symbols
        lines.append("")
        lines.append("=" * table_width)
        lines.append("LARGE DATA SYMBOLS IN RAM (>= 50 bytes)")
        lines.append("=" * table_width)

        largest_symbols = self.get_largest_symbols(50)
        lines.append(f"\n{'Symbol':<50} {'Type':<6} {'Size':<10} {'Section'}")
        lines.append("-" * table_width)

        for symbol in largest_symbols:
            # Use demangled name if available, otherwise raw name
            display_name = symbol.demangled or symbol.name
            # Truncate the name, not the alias note, so merged aliases stay
            # visible even for long demangled C++ names.
            alias_note = f" (+{len(symbol.aliases)} aliases)" if symbol.aliases else ""
            max_name_len = 49 - len(alias_note)
            if len(display_name) > max_name_len:
                display_name = display_name[:max_name_len]
            name_display = display_name + alias_note
            lines.append(
                f"{name_display:<50} {symbol.sym_type:<6} {symbol.size:>8} B  {symbol.section}"
            )

        # Summary
        lines.append("")
        lines.append("=" * table_width)
        lines.append("SUMMARY")
        lines.append("=" * table_width)
        lines.append(f"Total strings found in RAM: {len(self.ram_strings)}")
        total_string_bytes = self.get_total_string_bytes()
        lines.append(f"Total bytes used by strings: {total_string_bytes:,}")

        # Optimization targets
        lines.append("")
        lines.append("=" * table_width)
        lines.append("POTENTIAL OPTIMIZATION TARGETS")
        lines.append("=" * table_width)

        # Repeated strings
        repeated = self.get_repeated_strings()[:10]
        if repeated:
            lines.append("\nRepeated strings (could be deduplicated):")
            for string, count in repeated:
                savings = (count - 1) * (len(string) + 1)
                clean_string = string[:50] + ("..." if len(string) > 50 else "")
                lines.append(
                    f'  "{clean_string}" - appears {count} times (potential savings: {savings} bytes)'
                )

        # Long strings - platform-specific advice
        long_strings = self.get_long_strings(20)[:10]
        if long_strings:
            if self.platform == "esp8266":
                lines.append(
                    "\nLong strings that could be moved to PROGMEM (>= 20 chars):"
                )
            else:
                # ESP32: strings in DRAM are typically there for a reason
                # (interrupt handlers, pre-flash-init code, etc.)
                lines.append("\nLong strings in DRAM (>= 20 chars):")
                lines.append(
                    "Note: ESP32 DRAM strings may be required for interrupt/early-boot contexts"
                )
            for ram_string in long_strings:
                clean_string = ram_string.content[:60] + (
                    "..." if len(ram_string.content) > 60 else ""
                )
                lines.append(
                    f'  {ram_string.section} @ 0x{ram_string.address:08x}: "{clean_string}" ({len(ram_string.content)} bytes)'
                )

        lines.append("")
        return "\n".join(lines)
