#!/usr/bin/env python3
"""Automate source list maintenance and self-header includes for YAZE."""

from __future__ import annotations

import argparse
from dataclasses import dataclass, field
import re
from pathlib import Path
from typing import Dict, Iterable, List, Optional, Sequence, Set

PROJECT_ROOT = Path(__file__).resolve().parent.parent
SOURCE_ROOT = PROJECT_ROOT / "src"

SUPPORTED_EXTENSIONS = (".cc", ".c", ".cpp", ".cxx", ".mm")
HEADER_EXTENSIONS = (".h", ".hh", ".hpp", ".hxx")
BUILD_CLEANER_IGNORE_TOKEN = "build_cleaner:ignore"


@dataclass(frozen=True)
class DirectorySpec:
    path: Path
    recursive: bool = True
    extensions: Sequence[str] = SUPPORTED_EXTENSIONS

    def iter_files(self) -> Iterable[Path]:
        if not self.path.exists():
            return []
        if self.recursive:
            iterator = self.path.rglob("*")
        else:
            iterator = self.path.glob("*")
        for candidate in iterator:
            if candidate.is_file() and candidate.suffix in self.extensions:
                yield candidate


@dataclass
class CMakeSourceBlock:
    variable: str
    cmake_path: Path
    directories: Sequence[DirectorySpec]
    exclude: Set[Path] = field(default_factory=set)


CONFIG: Sequence[CMakeSourceBlock] = (
    CMakeSourceBlock(
        variable="YAZE_APP_EMU_SRC",
        cmake_path=SOURCE_ROOT / "CMakeLists.txt",
        directories=(DirectorySpec(SOURCE_ROOT / "app/emu"),),
    ),
    CMakeSourceBlock(
        variable="YAZE_APP_CORE_SRC",
        cmake_path=SOURCE_ROOT / "app/core/core_library.cmake",
        directories=(DirectorySpec(SOURCE_ROOT / "app/core", recursive=False),),
    ),
    CMakeSourceBlock(
        variable="YAZE_APP_GFX_SRC",
        cmake_path=SOURCE_ROOT / "app/gfx/gfx_library.cmake",
        directories=(DirectorySpec(SOURCE_ROOT / "app/gfx"),),
    ),
    CMakeSourceBlock(
        variable="YAZE_GUI_SRC",
        cmake_path=SOURCE_ROOT / "app/gui/gui_library.cmake",
        directories=(DirectorySpec(SOURCE_ROOT / "app/gui"),),
    ),
    CMakeSourceBlock(
        variable="YAZE_APP_EDITOR_SRC",
        cmake_path=SOURCE_ROOT / "app/editor/editor_library.cmake",
        directories=(DirectorySpec(SOURCE_ROOT / "app/editor"),),
    ),
    CMakeSourceBlock(
        variable="YAZE_APP_ZELDA3_SRC",
        cmake_path=SOURCE_ROOT / "app/zelda3/zelda3_library.cmake",
        directories=(DirectorySpec(SOURCE_ROOT / "app/zelda3"),),
    ),
    CMakeSourceBlock(
        variable="YAZE_NET_SRC",
        cmake_path=SOURCE_ROOT / "app/net/net_library.cmake",
        directories=(DirectorySpec(SOURCE_ROOT / "app/net"),),
        exclude={Path("app/net/rom_service_impl.cc")},
    ),
    CMakeSourceBlock(
        variable="YAZE_UTIL_SRC",
        cmake_path=SOURCE_ROOT / "util/util.cmake",
        directories=(DirectorySpec(SOURCE_ROOT / "util"),),
    ),
)


def relative_to_source(path: Path) -> Path:
    return path.relative_to(SOURCE_ROOT)


def parse_block(lines: List[str], start_idx: int) -> int:
    """Return index of the closing ')' line for a set/list block."""
    for idx in range(start_idx + 1, len(lines)):
        if lines[idx].strip().startswith(")"):
            return idx
    raise ValueError(f"Unterminated set/list block starting at line {start_idx}")


def parse_entry(line: str) -> Optional[str]:
    stripped = line.strip()
    if not stripped or stripped.startswith("#"):
        return None
    # Remove trailing inline comment
    stripped = stripped.split("#", 1)[0].strip()
    if not stripped:
        return None
    if stripped.startswith("$"):
        return None
    return stripped


def gather_expected_sources(block: CMakeSourceBlock) -> List[str]:
    entries: Set[str] = set()
    for directory in block.directories:
        for source_file in directory.iter_files():
            if should_ignore_path(source_file):
                continue
            rel_path = relative_to_source(source_file)
            if rel_path in block.exclude:
                continue
            entries.add(str(rel_path).replace("\\", "/"))
    return sorted(entries)


def should_ignore_path(path: Path) -> bool:
    try:
        with path.open("r", encoding="utf-8") as handle:
            head = handle.read(256)
    except (OSError, UnicodeDecodeError):
        return False
    return BUILD_CLEANER_IGNORE_TOKEN in head


def update_cmake_block(block: CMakeSourceBlock, dry_run: bool) -> bool:
    cmake_lines = (block.cmake_path.read_text(encoding="utf-8")).splitlines()
    pattern = re.compile(rf"\s*set\(\s*{re.escape(block.variable)}\b")

    start_idx: Optional[int] = None
    for idx, line in enumerate(cmake_lines):
        if pattern.match(line):
            start_idx = idx
            break

    if start_idx is None:
        for idx, line in enumerate(cmake_lines):
            stripped = line.strip()
            if not stripped.startswith("set("):
                continue
            remainder = stripped[4:].strip()
            if remainder:
                if remainder.startswith(block.variable):
                    start_idx = idx
                    break
                continue
            lookahead = idx + 1
            while lookahead < len(cmake_lines):
                next_line = cmake_lines[lookahead].strip()
                if not next_line or next_line.startswith("#"):
                    lookahead += 1
                    continue
                if next_line == block.variable:
                    start_idx = idx
                break
            if start_idx is not None:
                break

    if start_idx is None:
        raise ValueError(f"Could not locate set({block.variable}) in {block.cmake_path}")

    end_idx = parse_block(cmake_lines, start_idx)
    block_slice = cmake_lines[start_idx + 1 : end_idx]

    prelude: List[str] = []
    postlude: List[str] = []
    existing_entries: List[str] = []

    first_entry_idx: Optional[int] = None

    for idx, line in enumerate(block_slice):
        entry = parse_entry(line)
        if entry:
            if entry == block.variable and not existing_entries:
                prelude.append(line)
                continue
            existing_entries.append(entry)
            if first_entry_idx is None:
                first_entry_idx = idx
        else:
            if first_entry_idx is None:
                prelude.append(line)
            else:
                postlude.append(line)

    expected_entries = gather_expected_sources(block)
    expected_set = set(expected_entries)

    if set(existing_entries) == expected_set:
        return False

    indent = "  "
    if first_entry_idx is not None:
        sample_line = block_slice[first_entry_idx]
        indent = sample_line[: len(sample_line) - len(sample_line.lstrip())]

    rebuilt_block = prelude + [f"{indent}{entry}" for entry in expected_entries] + postlude

    if dry_run:
        print(f"[DRY-RUN] Would update {block.cmake_path.relative_to(PROJECT_ROOT)}")
        return True

    cmake_lines[start_idx + 1 : end_idx] = rebuilt_block
    block.cmake_path.write_text("\n".join(cmake_lines) + "\n", encoding="utf-8")
    print(f"Updated {block.cmake_path.relative_to(PROJECT_ROOT)}")
    missing = sorted(expected_set - set(existing_entries))
    removed = sorted(set(existing_entries) - expected_set)
    if missing:
        print(f"  Added: {', '.join(missing)}")
    if removed:
        print(f"  Removed: {', '.join(removed)}")
    return True


def find_self_header(source: Path) -> Optional[Path]:
    for ext in HEADER_EXTENSIONS:
        candidate = source.with_suffix(ext)
        if candidate.exists():
            return candidate
    return None


def has_include(lines: Sequence[str], header_variants: Iterable[str]) -> bool:
    include_patterns = {f'#include "{variant}"' for variant in header_variants}
    return any(line.strip() in include_patterns for line in lines)


def find_insert_index(lines: List[str]) -> int:
    include_block_start = None
    for idx, line in enumerate(lines):
        if line.startswith("#include"):
            include_block_start = idx
            break

    if include_block_start is not None:
        return include_block_start

    # No includes yet; skip leading comments/blank lines
    index = 0
    in_block_comment = False
    while index < len(lines):
        stripped = lines[index].strip()
        if not stripped:
            index += 1
            continue
        if stripped.startswith("/*") and not stripped.endswith("*/"):
            in_block_comment = True
            index += 1
            continue
        if in_block_comment:
            if "*/" in stripped:
                in_block_comment = False
            index += 1
            continue
        if stripped.startswith("//"):
            index += 1
            continue
        break
    return index


def ensure_self_header_include(source: Path, dry_run: bool) -> bool:
    if should_ignore_path(source):
        return False

    header = find_self_header(source)
    if header is None:
        return False

    try:
        lines = source.read_text(encoding="utf-8").splitlines()
    except UnicodeDecodeError:
        return False

    header_variants = {
        header.name,
        str(header.relative_to(SOURCE_ROOT)).replace("\\", "/"),
    }

    if has_include(lines, header_variants):
        return False

    include_line = f'#include "{header.name}"'

    insert_idx = find_insert_index(lines)
    lines.insert(insert_idx, include_line)

    if dry_run:
        rel = source.relative_to(PROJECT_ROOT)
        print(f"[DRY-RUN] Would insert self-header include into {rel}")
        return True

    source.write_text("\n".join(lines) + "\n", encoding="utf-8")
    print(f"Inserted self-header include into {source.relative_to(PROJECT_ROOT)}")
    return True


def collect_source_files() -> Set[Path]:
    managed_dirs: Set[Path] = set()
    for block in CONFIG:
        for directory in block.directories:
            managed_dirs.add(directory.path)

    result: Set[Path] = set()
    for directory in managed_dirs:
        if not directory.exists():
            continue
        for file_path in directory.rglob("*"):
            if file_path.is_file() and file_path.suffix in SUPPORTED_EXTENSIONS:
                result.add(file_path)
    return result


def run(dry_run: bool, cmake_only: bool, includes_only: bool) -> int:
    if cmake_only and includes_only:
        raise ValueError("Cannot use --cmake-only and --includes-only together")

    changed = False

    if not includes_only:
        for block in CONFIG:
            changed |= update_cmake_block(block, dry_run)

    if not cmake_only:
        for source in collect_source_files():
            changed |= ensure_self_header_include(source, dry_run)

    if dry_run and not changed:
        print("No changes required (dry-run)")
    if not dry_run and not changed:
        print("No changes required")
    return 0


def main() -> int:
    parser = argparse.ArgumentParser(description="Maintain CMake source lists and self-header includes.")
    parser.add_argument("--dry-run", action="store_true", help="Report prospective changes without editing files")
    parser.add_argument("--cmake-only", action="store_true", help="Only update CMake source lists")
    parser.add_argument("--includes-only", action="store_true", help="Only ensure self-header includes")
    args = parser.parse_args()

    try:
        return run(args.dry_run, args.cmake_only, args.includes_only)
    except Exception as exc:  # pylint: disable=broad-except
        print(f"build_cleaner failed: {exc}")
        return 1


if __name__ == "__main__":
    raise SystemExit(main())
