#!/usr/bin/env python3
"""
Lint check: Enforce include path style in FastLED.

Within src/fl/** and src/platforms/**, all #include "..." statements must use
paths relative to src/. This ensures consistent include paths across the codebase
and proper IDE support.

Valid includes:
  - #include "fl/foo.h"              - starts with fl/
  - #include "fl/math/foo.h"         - starts with fl/
  - #include "platforms/bar.h"       - starts with platforms/
  - #include <system_header>         - system headers are fine
  - #include "FastLED.h"             - top-level src files are fine
  - #include "fx/foo.h"              - starts with fx/
  - #include "sensors/foo.h"         - starts with sensors/
  - #include "third_party/foo.h"     - starts with third_party/
  - #include "hardware/irq.h"        - external SDK headers are allowed
  - #include "freertos/FreeRTOS.h"   - external SDK headers are allowed

Invalid includes:
  - #include "../foo.h"              - parent-relative paths
  - #include "./foo.h"               - current-dir relative
  - #include "fl/math/lib8tion/foo.h" - use "fl/math/foo.h" instead
  - #include "avr/foo.h"             - inside platforms/, should be "platforms/avr/foo.h"
  - #include "esp/foo.h"             - inside platforms/, should be "platforms/esp/foo.h"
  - #include "arm/foo.h"             - inside platforms/, should be "platforms/arm/foo.h"
  - #include "stub/foo.h"            - inside platforms/, should be "platforms/stub/foo.h"
  - #include "wasm/foo.h"            - inside platforms/, should be "platforms/wasm/foo.h"

The key distinction is between:
  - External SDK headers (e.g., hardware/, driver/, freertos/) - ALLOWED
  - FastLED platform subdirectories (e.g., avr/, esp/, arm/) - MUST use platforms/ prefix

Exception mechanism:
  Add "// ok include path" comment to suppress the check on a specific line.
"""

# pyright: reportUnknownMemberType=false
import re

from ci.util.check_files import EXCLUDED_FILES, FileContent, FileContentChecker
from ci.util.paths import PROJECT_ROOT


# Valid top-level prefixes for include paths (relative to src/)
VALID_PREFIXES = (
    "fl/",
    "platforms/",
    "fx/",
    "sensors/",
    "third_party/",
)

# External SDK header prefixes - these are not FastLED code, so they're allowed
# These headers come from platform SDKs (ESP-IDF, Pico SDK, FreeRTOS, Arduino, etc.)
# Note: Some prefixes like "avr/" and "arm/" are ambiguous - they could be SDK headers
# or FastLED platform subdirs. We handle this by checking file extensions.
EXTERNAL_SDK_PREFIXES = (
    # ESP-IDF SDK headers
    "driver/",
    "esp_",
    "esp32/",
    "freertos/",
    "hal/",
    "soc/",
    "rom/",
    "sdkconfig",
    "xtensa/",
    "riscv/",
    # Raspberry Pi Pico SDK headers
    "hardware/",
    "pico/",
    "boards/",
    "cmsis/",
    "class/",
    "tusb_",
    # Arduino headers
    "Arduino.h",
    "Wire.h",
    "SPI.h",
    "pins_arduino.h",
    # GCC system headers (but NOT avr/ - handled separately due to ambiguity)
    "util/",
    # ARM/Cortex system headers (but NOT arm/ - handled separately due to ambiguity)
    "core_",
    "cmsis_",
    # NRF SDK headers
    "nrf_",
    "nrf52",
    "nrfx",
    # STM32 HAL headers
    "stm32",
    # Teensy SDK headers
    "core_pins.h",
    "usb_",
    "kinetis.h",
    "imxrt.h",
    # RP2040/RP2350 SDK headers
    "RP2040.h",
    "RP2350.h",
    # Renesas SDK headers
    "bsp_",
    "r_",
    "fsp/",
)

# Ambiguous prefixes that could be either SDK headers or FastLED platform dirs
# For these, we check file extensions to disambiguate:
# - SDK headers: typically .h only (e.g., <avr/io.h>, <arm/math.h>)
# - FastLED code: may use .hpp, .cpp or have FastLED-specific filenames
AMBIGUOUS_PREFIXES = (
    "avr/",  # Could be <avr/io.h> (SDK) or "platforms/avr/clockless.h" (FastLED)
    "arm/",  # Could be <arm/math.h> (SDK) or "platforms/arm/fastpin.h" (FastLED)
)

# FastLED platform subdirectories - these MUST use the "platforms/" prefix
# when included from within src/fl/ or src/platforms/
FASTLED_PLATFORM_SUBDIRS = (
    "adafruit/",
    "apollo3/",
    "arm/",
    "avr/",
    "esp/",
    "shared/",
    "stub/",
    "wasm/",
    "posix/",
)

# Internal subpaths within fl/ that should NOT be used directly in includes.
# These subdirectories have been folded into their parent - use the parent path instead.
# Maps banned prefix -> replacement prefix
BANNED_INTERNAL_SUBPATHS: dict[str, str] = {
    "fl/math/lib8tion/": "fl/math/",
}

# Likely typos of valid FastLED prefixes that should be flagged
# These are misspellings or variations that look like they're trying to
# include FastLED code but got the prefix wrong
TYPO_PREFIXES = {
    "fL/": "fl/",  # Case typo
    "FL/": "fl/",  # Case typo
    "Fl/": "fl/",  # Case typo
    "platform/": "platforms/",  # Singular instead of plural
    "Platform/": "platforms/",  # Case + singular
    "PLATFORMS/": "platforms/",  # All caps
}

# Pattern to extract include path from #include "..." statements
INCLUDE_PATTERN = re.compile(r'#include\s+"([^"]+)"')

# Maximum number of validation errors to report (prevents context overflow)
# Note: This is bypassed when --json flag is used to export all violations
MAX_VALIDATION_ERRORS = 10


def is_top_level_include(include_path: str) -> bool:
    """Check if include path is a top-level file (no directory separator).

    Top-level includes like "FastLED.h", "pixeltypes.h" are valid since
    they refer to files directly in src/.
    """
    return "/" not in include_path and "\\" not in include_path


def looks_like_fastled_code(include_path: str) -> bool:
    """Check if the include path looks like FastLED code (not SDK code).

    FastLED code characteristics:
    - Uses .hpp extension (SDK headers almost never use this)
    - Uses .cpp extension (should never be included)
    - Has FastLED-specific naming patterns
    """
    # .hpp is almost exclusively used by FastLED, not SDK headers
    if include_path.endswith(".hpp"):
        return True

    # .cpp files should never be included as headers
    if include_path.endswith(".cpp"):
        return True

    # Common FastLED naming patterns
    fastled_patterns = (
        "clockless",
        "fastpin",
        "fastspi",
        "led_sysdefs",
        "compile_test",
        "spi_output",
        "is_",  # Platform detection headers like is_avr.h, is_arm.h
    )
    filename = include_path.split("/")[-1]
    for pattern in fastled_patterns:
        if pattern in filename.lower():
            return True

    return False


def is_external_sdk_header(include_path: str) -> bool:
    """Check if the include path is from an external SDK.

    External SDK headers (like ESP-IDF, Pico SDK, FreeRTOS, Arduino)
    are allowed to be included with their original paths.
    """
    # First check if it's definitely an SDK header
    for prefix in EXTERNAL_SDK_PREFIXES:
        if include_path.startswith(prefix):
            return True

    # For ambiguous prefixes (avr/, arm/), check if it looks like FastLED code
    for prefix in AMBIGUOUS_PREFIXES:
        if include_path.startswith(prefix):
            # If it looks like FastLED code, it's NOT an SDK header
            if looks_like_fastled_code(include_path):
                return False
            # Otherwise, assume it's an SDK header (e.g., <avr/io.h>)
            return True

    return False


def is_fastled_platform_relative(include_path: str) -> bool:
    """Check if the include path looks like a relative include to FastLED platform code.

    These are paths that should use "platforms/" prefix but don't.
    For example: "esp/foo.h" should be "platforms/esp/foo.h"

    For ambiguous prefixes like avr/ and arm/, we only flag them as FastLED
    platform code if they look like FastLED code (e.g., .hpp files, specific
    FastLED naming patterns).
    """
    # Check unambiguous FastLED platform subdirs
    for subdir in FASTLED_PLATFORM_SUBDIRS:
        if include_path.startswith(subdir):
            return True

    # For ambiguous prefixes, check if it looks like FastLED code
    for prefix in AMBIGUOUS_PREFIXES:
        if include_path.startswith(prefix):
            if looks_like_fastled_code(include_path):
                return True

    return False


def get_banned_subpath_replacement(include_path: str) -> str | None:
    """Check if the include uses a banned internal subpath.

    Returns the corrected path if banned, None otherwise.
    """
    for banned_prefix, replacement_prefix in BANNED_INTERNAL_SUBPATHS.items():
        if include_path.startswith(banned_prefix):
            return replacement_prefix + include_path[len(banned_prefix) :]
    return None


def is_valid_include_path(include_path: str) -> bool:
    """Check if include path follows the required style.

    Args:
        include_path: The path from #include "path"

    Returns:
        True if the path is valid (starts with valid prefix, is top-level,
        or is an external SDK header)
    """
    # Top-level includes are fine (e.g., "FastLED.h")
    if is_top_level_include(include_path):
        return True

    # External SDK headers are allowed (e.g., "hardware/irq.h", "freertos/FreeRTOS.h")
    if is_external_sdk_header(include_path):
        return True

    # Reject banned internal subpaths (e.g., fl/math/lib8tion/ -> fl/math/)
    if get_banned_subpath_replacement(include_path) is not None:
        return False

    # Check if path starts with a valid FastLED prefix
    for prefix in VALID_PREFIXES:
        if include_path.startswith(prefix):
            return True

    return False


def is_relative_path(include_path: str) -> bool:
    """Check if the path is a relative path (starts with ./ or ../)."""
    return include_path.startswith("./") or include_path.startswith("../")


def header_exists(include_path: str) -> bool:
    """Check if a quoted header file exists in src/.

    Only checks headers that use full paths relative to src/ (not relative
    paths like "./file.h" and not external SDK headers).
    """
    # Skip relative paths and external SDK headers
    if is_relative_path(include_path) or is_external_sdk_header(include_path):
        return True

    # Check if the header exists in src/
    header_file = PROJECT_ROOT / "src" / include_path
    return header_file.exists()


def get_typo_suggestion(include_path: str) -> str | None:
    """Check if the include path starts with a likely typo of a valid prefix.

    Returns the correct prefix if a typo is detected, None otherwise.
    """
    for typo, correct in TYPO_PREFIXES.items():
        if include_path.startswith(typo):
            return correct
    return None


class IncludePathsChecker(FileContentChecker):
    """Checker for include path style enforcement."""

    def __init__(self, max_errors: int | None = None):
        self.violations: dict[str, list[tuple[int, str]]] = {}
        self.total_error_count = 0  # Track total errors to enforce limit
        self.max_errors = max_errors  # None means unlimited, otherwise enforce limit

    def should_process_file(self, file_path: str) -> bool:
        """Only process files in src/fl/** and src/platforms/**."""
        # Normalize path separators for cross-platform compatibility
        normalized_path = file_path.replace("\\", "/")

        # Check file extension
        if not file_path.endswith((".cpp", ".h", ".hpp", ".c")):
            return False

        # Check if file is in excluded list
        if any(file_path.endswith(excluded) for excluded in EXCLUDED_FILES):
            return False

        # Only check files in src/fl/ and src/platforms/
        if "/src/fl/" in normalized_path or "/src/platforms/" in normalized_path:
            return True

        return False

    def check_file_content(self, file_content: FileContent) -> list[str]:
        """Check file content for invalid include paths."""
        violations: list[tuple[int, str]] = []
        in_multiline_comment = False

        for line_number, line in enumerate(file_content.lines, 1):
            # Stop collecting errors if we've hit the limit
            if (
                self.max_errors is not None
                and self.total_error_count >= self.max_errors
            ):
                break

            stripped = line.strip()

            # Track multi-line comment state
            if "/*" in line:
                in_multiline_comment = True
            if "*/" in line:
                in_multiline_comment = False
                continue

            # Skip if in multi-line comment
            if in_multiline_comment:
                continue

            # Skip single-line comments
            if stripped.startswith("//"):
                continue

            # Split line to separate code from inline comments
            code_part = line.split("//")[0]
            comment_part = line.split("//", 1)[1] if "//" in line else ""

            # Check if exception comment is present
            has_exception = "ok include path" in comment_part.lower()

            # Find #include "..." statements
            match = INCLUDE_PATTERN.search(code_part)
            if match and not has_exception:
                include_path = match.group(1)

                # Skip system-style includes (should use <> not "" but handle anyway)
                if include_path.startswith("<"):
                    continue

                # First check if the header file exists (for paths that should be in src/)
                if is_valid_include_path(include_path) and not header_exists(
                    include_path
                ):
                    msg = (
                        f'Header not found: #include "{include_path}"\n'
                        f"  Expected: src/{include_path}\n"
                        f""
                    )
                    violations.append((line_number, msg))
                    self.total_error_count += 1
                    continue

                # Check if the include path is valid
                if not is_valid_include_path(include_path):
                    banned_replacement = get_banned_subpath_replacement(include_path)
                    if banned_replacement is not None:
                        msg = (
                            f'Invalid include path: #include "{include_path}" - '
                            f'Use #include "{banned_replacement}" instead. '
                            f"Add '// ok include path' comment to suppress."
                        )
                        violations.append((line_number, msg))
                        self.total_error_count += 1
                    elif is_relative_path(include_path):
                        msg = (
                            f'Invalid include path: #include "{include_path}" - '
                            f"Relative paths (../ or ./) are not allowed. "
                            f"Use paths relative to src/ instead "
                            f'(e.g., "fl/foo.h" or "platforms/bar.h"). '
                            f"Add '// ok include path' comment to suppress."
                        )
                        violations.append((line_number, msg))
                        self.total_error_count += 1
                    elif is_fastled_platform_relative(include_path):
                        # This is a FastLED platform include without "platforms/" prefix
                        msg = (
                            f'Invalid include path: #include "{include_path}" - '
                            f'FastLED platform includes must use "platforms/" prefix. '
                            f'Use #include "platforms/{include_path}" instead. '
                            f"Add '// ok include path' comment to suppress."
                        )
                        violations.append((line_number, msg))
                        self.total_error_count += 1
                    else:
                        # Check for likely typos of valid prefixes
                        typo_correction = get_typo_suggestion(include_path)
                        if typo_correction:
                            typo_prefix = include_path.split("/")[0] + "/"
                            rest_of_path = include_path[len(typo_prefix) :]
                            corrected_path = typo_correction + rest_of_path
                            msg = (
                                f'Invalid include path: #include "{include_path}" - '
                                f'"{typo_prefix}" looks like a typo. '
                                f'Use #include "{corrected_path}" instead. '
                                f"Add '// ok include path' comment to suppress."
                            )
                            violations.append((line_number, msg))
                            self.total_error_count += 1
                        # Note: If it's not a relative path, not a known FastLED platform dir,
                        # and not a known typo, we assume it's an external SDK header we don't
                        # know about, so we skip it

        # Store violations if any found (normalize path to forward slashes)
        if violations:
            normalized_path = file_content.path.replace("\\", "/")
            self.violations[normalized_path] = violations

        return []  # MUST return empty list


def is_known_sdk_header(header_name: str) -> bool:
    """Check if a header is a known external SDK header that should use angle brackets."""
    from pathlib import Path

    # Windows SDK headers
    windows_headers = {
        "errhandlingapi.h",
        "minwindef.h",
        "winbase.h",
        "processthreadsapi.h",
    }
    # ESP8266 SDK headers
    esp8266_headers = {"esp8266_peri.h", "osapi.h", "user_interface.h"}
    # External libraries
    external_libs = {"NeoPixelBus.h"}

    basename = Path(header_name).name
    return basename in (windows_headers | esp8266_headers | external_libs)


def apply_fixes(violations_dict: dict, file_contents: dict) -> tuple[int, int]:
    """Apply safe fixes to files with violations.

    Args:
        violations_dict: Dictionary of violations from checker
        file_contents: Dictionary mapping file paths to their contents

    Returns:
        Tuple of (files_fixed, violations_fixed)
    """
    from pathlib import Path

    files_fixed = 0
    violations_fixed = 0

    for file_path, violations in violations_dict.items():
        if file_path not in file_contents:
            continue

        lines = file_contents[file_path].lines.copy()
        fixed_any = False

        # Process violations in reverse line order to avoid line number shifts
        for line_num, msg in sorted(violations, reverse=True, key=lambda x: x[0]):
            # Extract the offending include path
            match_include = re.search(r'#include "([^"]+)"', msg)
            if not match_include:
                continue

            include_path = match_include.group(1)
            corrected_path = None
            use_angle_brackets = False

            # Try to infer the correct path
            banned_replacement = get_banned_subpath_replacement(include_path)
            if banned_replacement is not None:
                corrected_path = banned_replacement
            elif is_known_sdk_header(include_path):
                # Convert to angle bracket syntax - check this first!
                corrected_path = Path(include_path).name
                use_angle_brackets = True
            elif is_fastled_platform_relative(include_path):
                # Bare platform name needs "platforms/" prefix
                corrected_path = f"platforms/{include_path}"
            elif is_relative_path(include_path):
                # Relative paths are always wrong
                continue  # Skip, need manual review
            elif typo_suggestion := get_typo_suggestion(include_path):
                # Apply typo correction
                typo_prefix = include_path.split("/")[0] + "/"
                rest_of_path = include_path[len(typo_prefix) :]
                corrected_path = typo_suggestion + rest_of_path
            elif "Header not found" in msg:
                # Try to find the file in src/ - but only fix if exactly one match
                from ci.util.paths import PROJECT_ROOT

                basename = Path(include_path).name
                # Search for the file in src/
                matching_files = []
                for potential_path in (PROJECT_ROOT / "src").rglob(basename):
                    if potential_path.is_file():
                        matching_files.append(potential_path)

                # Only fix if there's exactly one matching file
                if len(matching_files) == 1:
                    rel_path = matching_files[0].relative_to(PROJECT_ROOT / "src")
                    corrected_path = str(rel_path).replace("\\", "/")

            if corrected_path and corrected_path != include_path:
                # Apply the fix
                old_include = f'#include "{include_path}"'
                if use_angle_brackets:
                    new_include = f"#include <{corrected_path}>"
                else:
                    new_include = f'#include "{corrected_path}"'
                line_idx = line_num - 1  # Convert to 0-based index

                if line_idx < len(lines):
                    lines[line_idx] = lines[line_idx].replace(old_include, new_include)
                    fixed_any = True
                    violations_fixed += 1

        if fixed_any:
            # Write the fixed file back
            with open(file_path, "w", encoding="utf-8") as f:
                f.writelines(lines)
            files_fixed += 1

    return files_fixed, violations_fixed


def export_violations_json(checker: IncludePathsChecker, output_path: str) -> None:
    """Export violations to a JSON file.

    Args:
        checker: The IncludePathsChecker instance with violations
        output_path: Path to write the JSON file to
    """
    import json

    violations_list = []
    for file_path, violations in checker.violations.items():
        for line_num, msg in violations:
            # Extract the include path from the message
            import re

            match = re.search(r'#include "([^"]+)"', msg)
            include_path = match.group(1) if match else "unknown"

            # Extract if it's a header not found or other issue
            is_not_found = "Header not found" in msg

            violations_list.append(
                {
                    "file": file_path,
                    "line": line_num,
                    "offending_header": include_path,
                    "type": "header_not_found" if is_not_found else "invalid_path",
                    "message": msg,
                    "note": "This might be a system header - consider using #include <header> instead"
                    if is_not_found
                    else None,
                }
            )

    # Write to JSON file
    with open(output_path, "w") as f:
        json.dump(
            {
                "total_violations": len(violations_list),
                "files_affected": len(checker.violations),
                "violations": violations_list,
            },
            f,
            indent=2,
        )

    print(f"✅ Violations exported to {output_path}")


def main() -> None:
    """Run include paths checker standalone."""
    import sys

    from ci.util.check_files import (
        FileContent,
        MultiCheckerFileProcessor,
        collect_files_to_check,
    )
    from ci.util.paths import PROJECT_ROOT

    # Check for --json and --fix flags
    json_output = None
    apply_fix = False

    if "--fix" in sys.argv:
        apply_fix = True
        sys.argv.remove("--fix")

    if "--json" in sys.argv:
        json_idx = sys.argv.index("--json")
        if json_idx + 1 < len(sys.argv):
            json_output = sys.argv[json_idx + 1]
        else:
            json_output = "include_path_violations.json"
        # Remove flags from argv before running checker
        sys.argv.pop(json_idx)
        if json_output != "include_path_violations.json":
            sys.argv.pop(json_idx)

    # Run checker - with unlimited errors if JSON export or fix is requested
    checker = IncludePathsChecker(
        max_errors=None if (json_output or apply_fix) else MAX_VALIDATION_ERRORS
    )
    directories = [
        str(PROJECT_ROOT / "src" / "fl"),
        str(PROJECT_ROOT / "src" / "platforms"),
    ]

    files_to_check = collect_files_to_check(
        directories, extensions=[".cpp", ".h", ".hpp", ".c"]
    )

    # Load file contents for fixing
    file_contents: dict[str, FileContent] = {}
    for file_path in files_to_check:
        with open(file_path, "r", encoding="utf-8", errors="replace") as f:
            content = f.read()
            file_contents[file_path] = FileContent(
                path=file_path, content=content, lines=content.splitlines(keepends=True)
            )

    processor = MultiCheckerFileProcessor()
    processor.process_files_with_checkers(files_to_check, [checker])

    # Apply fixes if requested
    if apply_fix and checker.violations:
        # Map normalized paths back to actual file paths for fixing
        violations_by_actual_path = {}
        for norm_path, violations in checker.violations.items():
            # Find the actual file path that corresponds to this normalized path
            for actual_path in file_contents.keys():
                if actual_path.replace("\\", "/").endswith(
                    norm_path.lstrip("C:/Users/niteris/dev/fastled7/")
                ):
                    violations_by_actual_path[actual_path] = violations
                    break
                # Also try direct match
                if actual_path.replace(
                    "\\", "/"
                ) == norm_path or norm_path in actual_path.replace("\\", "/"):
                    violations_by_actual_path[actual_path] = violations
                    break

        files_fixed, violations_fixed = apply_fixes(
            violations_by_actual_path, file_contents
        )
        print(f"✅ Fixed {violations_fixed} violations in {files_fixed} files")
        print("Re-running checker to verify...")

        # Re-run checker to verify
        checker = IncludePathsChecker(max_errors=MAX_VALIDATION_ERRORS)
        file_contents.clear()
        for file_path in files_to_check:
            with open(file_path, "r", encoding="utf-8", errors="replace") as f:
                content = f.read()
                file_contents[file_path] = FileContent(
                    path=file_path,
                    content=content,
                    lines=content.splitlines(keepends=True),
                )
        processor.process_files_with_checkers(files_to_check, [checker])

        if not checker.violations:
            print("✅ All violations fixed!")
        else:
            print(f"⚠️  {len(checker.violations)} files still have violations after fix")
        sys.exit(0)

    # Export to JSON if requested
    if json_output:
        export_violations_json(checker, json_output)
        sys.exit(0)

    # Print violations (only when NOT using JSON export)
    if checker.violations:
        total_violations = sum(len(v) for v in checker.violations.values())
        file_count = len(checker.violations)

        print(
            f"❌ Found invalid include paths ({file_count} files, {total_violations} violations):"
        )
        print()

        # Sort files by relative path
        for file_path in sorted(checker.violations.keys()):
            rel_path = file_path.replace(str(PROJECT_ROOT), "").lstrip("\\/")
            # Normalize to forward slashes for consistent output
            rel_path = rel_path.replace("\\", "/")
            violations = checker.violations[file_path]

            print(f"{rel_path}:")
            for line_num, msg in violations:
                print(f"  Line {line_num}: {msg}")
            print()

        sys.exit(1)
    else:
        print("✅ Found invalid include paths: No violations found.")
        sys.exit(0)


if __name__ == "__main__":
    main()
