#!/usr/bin/env python3
"""
Build settings search index from QML source files.

Parses settings tab QML files to extract searchable metadata
(i18n keys, widget types, tab/sub-tab locations, visibility conditions).

Output: Assets/settings-search-index.json

Usage:
  python Scripts/dev/build-settings-search-index.py
"""

import json
import re
import sys
from pathlib import Path

ROOT = Path(__file__).resolve().parent.parent.parent
SETTINGS_DIR = ROOT / "Modules" / "Panels" / "Settings"
TABS_DIR = SETTINGS_DIR / "Tabs"
OUTPUT = ROOT / "Assets" / "settings-search-index.json"

# Widget types that have searchable label/description
WIDGET_TYPES = (
    "NToggle",
    "NComboBox",
    "NValueSlider",
    "NSpinBox",
    "NSearchableComboBox",
    "NTextInputButton",
    "NTextInput",
    "NCheckbox",
    "NLabel",
    "NColorChoice",
    "HookRow",
)

# Regex patterns
RE_WIDGET_OPEN = re.compile(
    r"^\s*(" + "|".join(WIDGET_TYPES) + r")\s*\{", re.MULTILINE
)
RE_LABEL = re.compile(r'label:\s*I18n\.tr\("([^"]+)"')
RE_DESCRIPTION = re.compile(r'description:\s*I18n\.tr\("([^"]+)"')
RE_VISIBLE = re.compile(r'^\s*visible:\s*(.+?)(?:\s*;)?\s*$')

# Prefixes that indicate externally-resolvable conditions (singleton services or globals).
# Conditions referencing local variables (root., parent., model, index, etc.) are skipped.
RESOLVABLE_PREFIXES = (
    "CompositorService.",
    "Settings.data.",
    "Quickshell.",
    "IdleService.",
    "SystemStatService.",
    "SoundService.",
    "BluetoothService.",
    "LocationService.",
    "false",
)


def parse_component_declarations(content: str) -> dict[str, str]:
    """
    Parse Component declarations from SettingsContent.qml.

    Returns: component_id -> QML type name (e.g. "generalTab" -> "GeneralTab")
    """
    components = {}
    # Match patterns like:
    #   Component {
    #     id: generalTab
    #     GeneralTab {}
    #   }
    pattern = re.compile(
        r"Component\s*\{\s*\n\s*id:\s*(\w+)\s*\n\s*(\w+)\s*\{",
        re.MULTILINE,
    )
    for m in pattern.finditer(content):
        comp_id = m.group(1)
        type_name = m.group(2)
        components[comp_id] = type_name

    return components


def parse_tabs_model_order(content: str) -> list[tuple[str, str]]:
    """
    Parse updateTabsModel() to get the ordered list of (source_id, label_key) pairs.

    Returns: list of (component_id, i18n_label_key) in display order.
    """
    # Find the updateTabsModel function body
    match = re.search(r"function updateTabsModel\(\)\s*\{", content)
    if not match:
        return []

    func_body = content[match.end():]

    # Extract tab entries: each has "label" and "source" fields
    entries = []
    for m in re.finditer(
        r'"label":\s*"([^"]+)"[^}]*?"source":\s*(\w+)',
        func_body,
        re.DOTALL,
    ):
        entries.append((m.group(2), m.group(1)))

    return entries


def build_tab_mappings(content: str) -> tuple[dict[str, int], dict[str, str]]:
    """
    Build mappings from QML type name to tabsModel index and label key.

    Parses Component declarations and updateTabsModel() order.
    Returns: (type_to_index, type_to_label)
      - type_to_index: e.g. {"GeneralTab": 0, ...}
      - type_to_label: e.g. {"GeneralTab": "common.general", ...}
    """
    components = parse_component_declarations(content)
    entries = parse_tabs_model_order(content)

    type_to_index = {}
    type_to_label = {}
    for idx, (source_id, label_key) in enumerate(entries):
        type_name = components.get(source_id)
        if type_name:
            type_to_index[type_name] = idx
            type_to_label[type_name] = label_key

    return type_to_index, type_to_label


def get_subtab_info(parent_tab_file: Path) -> tuple[list[str], list[str | None]]:
    """
    Parse a parent tab file to get subtab order and labels.

    Returns: (subtab_type_names, subtab_label_keys)
      - subtab_type_names: list of component names like ["VolumesSubTab", ...]
      - subtab_label_keys: list of i18n keys like ["common.volumes", ...] (same order)
    """
    content = parent_tab_file.read_text()

    # Extract NTabButton labels in order from NTabBar
    labels = []
    in_tabbar = False
    tabbar_depth = 0

    for line in content.splitlines():
        stripped = line.strip()

        if not in_tabbar:
            if re.match(r"NTabBar\s*\{", stripped):
                in_tabbar = True
                tabbar_depth = 1
                continue
            continue

        tabbar_depth += stripped.count("{") - stripped.count("}")

        if tabbar_depth <= 0:
            break

        # Match text: I18n.tr("...") inside NTabButton
        m = re.search(r'text:\s*I18n\.tr\("([^"]+)"', stripped)
        if m:
            labels.append(m.group(1))

    # Extract subtab component names from NTabView
    subtabs = []
    in_tabview = False
    tabview_depth = 0

    for line in content.splitlines():
        stripped = line.strip()

        if not in_tabview:
            if re.match(r"NTabView\s*\{", stripped):
                in_tabview = True
                tabview_depth = 1
                continue
            continue

        tabview_depth += stripped.count("{") - stripped.count("}")

        if tabview_depth <= 0:
            break

        # Match component instantiations like "VolumesSubTab {}" or "VolumesSubTab {"
        m = re.match(r"(\w+SubTab)\s*\{", stripped)
        if m:
            subtabs.append(m.group(1))

    # Pad labels list if shorter than subtabs (shouldn't happen, but safety)
    while len(labels) < len(subtabs):
        labels.append(None)

    return subtabs, labels[:len(subtabs)]


def resolve_tab_info(
    qml_file: Path,
    type_to_index: dict[str, int],
    type_to_label: dict[str, str],
) -> tuple[int | None, str | None, int | None, str | None]:
    """
    Determine the tab index, tab label, sub-tab index, and sub-tab label for a QML file.

    Returns (tab_index, tab_label_key, sub_tab_index, sub_tab_label_key)
    """
    parent = qml_file.parent
    stem = qml_file.stem

    # Top-level tab files (directly in Tabs/)
    if parent == TABS_DIR:
        tab_index = type_to_index.get(stem)
        tab_label = type_to_label.get(stem)
        return tab_index, tab_label, None, None

    # Sub-directory files
    dir_name = parent.name  # e.g. "Audio", "Bar"
    parent_type = f"{dir_name}Tab"  # e.g. "AudioTab"
    tab_index = type_to_index.get(parent_type)
    tab_label = type_to_label.get(parent_type)

    if tab_index is None:
        return None, None, None, None

    # Skip the parent tab file itself (e.g. AudioTab.qml) — still scan for widgets
    if stem.endswith("Tab") and not stem.endswith("SubTab"):
        return tab_index, tab_label, None, None

    # Determine sub-tab index and label from parent tab's NTabBar/NTabView
    parent_tab_file = parent / f"{dir_name}Tab.qml"
    if not parent_tab_file.exists():
        return tab_index, tab_label, None, None

    subtab_names, subtab_labels = get_subtab_info(parent_tab_file)
    try:
        idx = subtab_names.index(stem)
        sub_label = subtab_labels[idx] if idx < len(subtab_labels) else None
        return tab_index, tab_label, idx, sub_label
    except ValueError:
        # File doesn't map to any subtab (e.g. a dialog). If the parent tab
        # has subtabs, the focus ring can't reach widgets inside dialogs, so
        # exclude them from the index.
        if subtab_names:
            return None, None, None, None
        return tab_index, tab_label, None, None


def is_resolvable_condition(cond: str) -> bool:
    """Check if a visibility condition can be resolved at runtime by the shell."""
    # Strip negation for prefix checking
    check = cond.lstrip("!").lstrip(" ").lstrip("(").lstrip(" ")
    return any(check.startswith(p) for p in RESOLVABLE_PREFIXES)


def build_scope_visibility(content: str) -> dict[int, list[str]]:
    """
    For each line number, compute the list of inherited visibility conditions
    from all enclosing QML scopes.

    Tracks brace-depth to maintain a scope stack. When a ``visible:`` property
    is found, it is associated with the innermost scope. The conditions are
    inherited by all lines within that scope.

    Returns: line_number -> list of condition strings
    """
    lines = content.splitlines()
    # Each entry: visibility condition string or None
    scope_stack: list[str | None] = []
    result: dict[int, list[str]] = {}

    for line_num, raw_line in enumerate(lines):
        stripped = raw_line.strip()

        # Record inherited conditions BEFORE processing this line's braces.
        # This means a widget opening on this line inherits from parent scopes,
        # not from its own scope (which hasn't been populated yet).
        result[line_num] = [c for c in scope_stack if c is not None]

        # Process opening braces — push new scopes
        n_opens = stripped.count("{")
        for _ in range(n_opens):
            scope_stack.append(None)

        # Check for visible: property — assign to the innermost scope
        vis_match = RE_VISIBLE.match(stripped)
        if vis_match and scope_stack:
            cond = vis_match.group(1).strip()
            if cond != "true":
                scope_stack[-1] = cond

        # Process closing braces — pop scopes
        n_closes = stripped.count("}")
        for _ in range(n_closes):
            if scope_stack:
                scope_stack.pop()

    return result


def extract_widget_blocks(content: str) -> list[tuple[str, str, int]]:
    """
    Extract (widget_type, block_text, start_line) tuples from QML content.

    Uses brace-depth tracking to capture the full widget block.
    """
    results = []
    lines = content.splitlines()
    i = 0

    while i < len(lines):
        m = RE_WIDGET_OPEN.match(lines[i])
        if m:
            widget_type = m.group(1)
            depth = 0
            block_lines = []
            start_line = i
            j = i

            while j < len(lines):
                line = lines[j]
                block_lines.append(line)
                depth += line.count("{") - line.count("}")
                if depth <= 0:
                    break
                j += 1

            block_text = "\n".join(block_lines)
            results.append((widget_type, block_text, start_line))
            i = j + 1
        else:
            i += 1

    return results


def extract_entries(
    qml_file: Path,
    type_to_index: dict[str, int],
    type_to_label: dict[str, str],
) -> list[dict]:
    """Extract all searchable settings entries from a QML file."""
    tab_index, tab_label, sub_tab, sub_tab_label = resolve_tab_info(
        qml_file, type_to_index, type_to_label
    )
    if tab_index is None:
        return []

    content = qml_file.read_text()
    scope_vis = build_scope_visibility(content)
    entries = []

    for widget_type, block, start_line in extract_widget_blocks(content):
        label_match = RE_LABEL.search(block)
        if not label_match:
            continue

        label_key = label_match.group(1)
        desc_match = RE_DESCRIPTION.search(block)
        desc_key = desc_match.group(1) if desc_match else None

        # Collect visibility conditions: ancestor scopes + widget's own visible:
        conditions = list(scope_vis.get(start_line, []))
        widget_vis = re.search(
            r'^\s*visible:\s*(.+?)(?:\s*;)?\s*$', block, re.MULTILINE
        )
        if widget_vis:
            cond = widget_vis.group(1).strip()
            if cond != "true" and cond not in conditions:
                conditions.append(cond)

        # Keep only externally-resolvable conditions
        conditions = [c for c in conditions if is_resolvable_condition(c)]

        entry = {
            "labelKey": label_key,
            "descriptionKey": desc_key,
            "widget": widget_type,
            "tab": tab_index,
            "tabLabel": tab_label,
            "subTab": sub_tab,
        }
        if sub_tab_label is not None:
            entry["subTabLabel"] = sub_tab_label
        if conditions:
            entry["visibleWhen"] = conditions

        entries.append(entry)

    return entries


def main():
    if not TABS_DIR.exists():
        print(f"Error: Tabs directory not found: {TABS_DIR}", file=sys.stderr)
        sys.exit(1)

    settings_content = SETTINGS_DIR / "SettingsContent.qml"
    if not settings_content.exists():
        print(f"Error: SettingsContent.qml not found: {settings_content}", file=sys.stderr)
        sys.exit(1)

    # Build type -> tabsModel index/label mappings from SettingsContent.qml
    content = settings_content.read_text()
    type_to_index, type_to_label = build_tab_mappings(content)

    if not type_to_index:
        print("Error: Could not parse tab model from SettingsContent.qml", file=sys.stderr)
        sys.exit(1)

    print(f"Parsed {len(type_to_index)} tab types from SettingsContent.qml")

    all_entries = []
    seen_labels = set()

    # Scan all QML files in Tabs/ (recursive)
    for qml_file in sorted(TABS_DIR.rglob("*.qml")):
        entries = extract_entries(qml_file, type_to_index, type_to_label)
        for entry in entries:
            if entry["labelKey"] not in seen_labels:
                seen_labels.add(entry["labelKey"])
                all_entries.append(entry)

    # Write output
    OUTPUT.parent.mkdir(parents=True, exist_ok=True)
    with open(OUTPUT, "w") as f:
        json.dump(all_entries, f, indent=2)

    print(f"Generated {len(all_entries)} entries -> {OUTPUT.relative_to(ROOT)}")


if __name__ == "__main__":
    main()
