from __future__ import annotations

import json
import re
import sys
from collections import defaultdict
from functools import lru_cache
from pathlib import Path


@lru_cache(maxsize=None)
def read_lines(path: Path) -> tuple[str, ...]:
    return tuple(path.read_text().splitlines())


ROW_RE = re.compile(
    r'^\s*(ToggleRow|SliderRow|SelectRow|SpinRow|NavRow|InfoRow|PopupRow|OverlayRow|DefaultRow)\s*\{')
LABEL_RE = re.compile(r'^\s*(?:label|text):\s*qsTr\("([^"]+)"\)')
ANCHOR_RE = re.compile(r'^\s*settingAnchor:\s*"([^"]+)"')
CHECKED_RE = re.compile(r'^\s*checked:\s*(?:Config)\.([\w.]+)\s*$')
ONTOGGLED_RE = re.compile(
    r'^\s*onToggled:\s*(?:Config)\.([\w.]+)\s*=\s*checked\s*$')
ICON_RE = re.compile(r'^\s*icon:\s*"([^"]+)"')
SKIP_LABELS = {"Muted", "None"}
FIELD_WEIGHT = {"title": 1.0, "keywords": 0.4}
STOPWORDS = {"the", "a", "an", "of", "and", "or", "to", "on", "in", "for"}


def find_pages_dir(settings: Path) -> Path:
    return settings / "Pages"


def discover_files(settings: Path) -> dict[str, Path]:
    files: dict[str, Path] = {}
    for p in find_pages_dir(settings).rglob("*.qml"):
        files[p.stem] = p
    return files


PAGE_NAME_RE = re.compile(r'^\s*name:\s*qsTr\("([^"]+)"\)')
PAGE_ICON_RE = re.compile(r'^\s*icon:\s*"([^"]+)"')


def parse_page_registry(settings: Path) -> list[tuple[str, str]]:
    text = (settings / "PageRegistry.qml").read_text().splitlines()

    start = next(
        i for i, line in enumerate(text)
        if re.search(r'\bpages\s*:\s*\[', line)
    )

    out: list[tuple[str, str]] = []
    i = start + 1

    while i < len(text):
        line = text[i].strip()

        if line.startswith("]"):
            break

        if line.startswith("//") or not line:
            i += 1
            continue

        if line.startswith("{"):
            name = None
            icon = None
            i += 1

            while i < len(text):
                s = text[i].strip()

                if s.startswith("}"):
                    if name is not None:
                        out.append((icon or "tune", name))
                    break

                if name is None:
                    m = PAGE_NAME_RE.match(text[i])
                    if m:
                        name = m.group(1)

                if icon is None:
                    mi = PAGE_ICON_RE.match(text[i])
                    if mi:
                        icon = mi.group(1)

                i += 1

        i += 1

    return out


BLOCK_RE = re.compile(r'^\s*([A-Za-z_][A-Za-z0-9_]*)\s*\{\s*$')


def _strip_comment(line: str) -> str:
    return line.split("//", 1)[0].rstrip()


def parse_block(lines: list[str], i: int) -> tuple[str, list[tuple[str, list]], int]:
    line = _strip_comment(lines[i]).strip()
    m = BLOCK_RE.match(line)
    if not m:
        raise ValueError(f"Expected block start at line {i + 1}: {lines[i]!r}")

    name = m.group(1)
    i += 1
    children: list[tuple[str, list]] = []

    while i < len(lines):
        s = _strip_comment(lines[i]).strip()
        if not s:
            i += 1
            continue

        if s.startswith("}"):
            return name, children, i + 1

        if BLOCK_RE.match(s):
            child_name, child_children, i = parse_block(lines, i)
            children.append((child_name, child_children))
            continue

        i += 1

    raise ValueError(f"Unterminated block: {name}")


def collect_page_names(block: tuple[str, list[tuple[str, list]]]) -> list[str]:
    name, children = block

    if name != "Component":
        return [name]

    for child_name, child_children in children:
        if child_name == "StackPage":
            out: list[str] = []
            for grand_name, grand_children in child_children:
                if grand_name == "Component":
                    out.extend(collect_page_names((grand_name, grand_children)))
            return out

        if child_name != "Component":
            return [child_name]

    return []


def parse_page_comps(settings: Path) -> list[list[str]]:
    text = (settings / "PageCompRegistry.qml").read_text().splitlines()

    start = next(
        i for i, line in enumerate(text)
        if re.search(r'\bpageComps\s*:\s*\[', _strip_comment(line))
    )

    comps: list[list[str]] = []
    i = start + 1

    while i < len(text):
        s = _strip_comment(text[i]).strip()
        if not s:
            i += 1
            continue
        if s.startswith("]"):
            break

        if BLOCK_RE.match(s) and BLOCK_RE.match(s).group(1) == "Component":
            block = parse_block(text, i)
            names = collect_page_names((block[0], block[1]))
            if names:
                comps.append(names)
            i = block[2]
            continue

        i += 1

    return comps


def dedup_crumbs(labels: list[str], icons: list[str]) -> tuple[list[str], list[str]]:
    out_labels: list[str] = []
    out_icons: list[str] = []
    for lbl, ico in zip(labels, icons):
        if out_labels and out_labels[-1] == lbl:
            continue
        out_labels.append(lbl)
        out_icons.append(ico)
    return out_labels, out_icons


def build_nav_map(settings: Path, files: dict[str, Path]) -> dict[str, dict]:
    comps = parse_page_comps(settings)
    registry = parse_page_registry(settings)

    top_meta: dict[int, tuple[str, str]] = {}
    for i, (icon, label) in enumerate(registry):
        top_meta[i] = (icon, label)

    nav_children: dict[str, dict[int, tuple[str, str, str]]] = {}
    for names in comps:
        for name in names:
            pf = files.get(name)
            if not pf:
                continue
            pending_icon = pending_label = None
            section = ""
            expect_section = False
            for ln in read_lines(pf):
                if SECTION_RE.match(ln):
                    expect_section = True
                    continue
                ml = LABEL_RE.match(ln)
                if ml:
                    if expect_section:
                        section = ml.group(1)
                        expect_section = False
                    else:
                        pending_label = ml.group(1)
                    continue
                mi = ICON_RE.match(ln)
                if mi:
                    pending_icon = mi.group(1)
                mo = re.search(r"openSubPage\((\d+)\)", ln)
                if mo:
                    pos = int(mo.group(1))
                    nav_children.setdefault(name, {})[pos] = (
                        pending_icon or "tune", pending_label or "", section)
                    pending_icon = pending_label = None

    nav: dict[str, dict] = {}
    for top_idx, names in enumerate(comps):
        if not names:
            continue
        main = names[0]
        main_icon, main_label = top_meta.get(top_idx, ("tune", main))
        nav[main] = {"pageIdx": top_idx, "subPath": [],
                     "crumbIcons": [main_icon], "crumbLabels": [main_label]}
        children = dict(nav_children.get(main, {}))
        opened_via_subpage = set()
        for owner, kids in nav_children.items():
            owner_group = next((ns for ns in comps if owner in ns), None)
            if not owner_group:
                continue
            for kpos in kids:
                if kpos < len(owner_group):
                    opened_via_subpage.add(owner_group[kpos])
        for pos in range(1, len(names)):
            if pos not in children and names[pos] not in opened_via_subpage:
                label = re.sub(r"(Detail)?Page$", "", names[pos])
                label = re.sub(r"(?<!^)(?=[A-Z])", " ", label)
                children[pos] = (main_icon, label, "")
        for pos, (icon, label, section) in children.items():
            if pos >= len(names):
                continue
            child = names[pos]
            labels = [main_label] + ([section] if section else []) + [label]
            icons = [main_icon] + ([icon] if section else []) + [icon]
            labels, icons = dedup_crumbs(labels, icons)
            nav[child] = {"pageIdx": top_idx, "subPath": [pos],
                          "crumbIcons": icons,
                          "crumbLabels": labels}
            for gpos, (gicon, glabel, gsection) in nav_children.get(child, {}).items():
                if gpos >= len(names):
                    continue
                glabels = labels + ([gsection] if gsection else []) + [glabel]
                gicons = icons + ([gicon] if gsection else []) + [gicon]
                glabels, gicons = dedup_crumbs(glabels, gicons)
                nav[names[gpos]] = {
                    "pageIdx": top_idx, "subPath": [pos, gpos],
                    "crumbIcons": gicons,
                    "crumbLabels": glabels}
    return nav


def tokenize(text: str) -> list[str]:
    toks: list[str] = []
    for word in text.lower().split():
        parts = [p for p in re.split(r"[^a-z0-9]+", word) if p]
        for p in parts:
            if p not in STOPWORDS and p not in toks:
                toks.append(p)
        if len(parts) > 1:
            joined = "".join(parts)
            if joined not in toks:
                toks.append(joined)
    return toks


SUBTEXT_RE = re.compile(r'^\s*(?:subtext|status):\s*qsTr\("([^"]+)"\)')
SECTION_RE = re.compile(r'^\s*SectionHeader\s*\{')


def extract_settings(files: dict[str, Path], nav: dict[str, dict]) -> list[dict]:
    entries: list[dict] = []
    for comp, meta in nav.items():
        pf = files.get(comp)
        if not pf:
            continue
        lines = read_lines(pf)
        section = ""
        i = 0
        while i < len(lines):
            if SECTION_RE.match(lines[i]):
                for j in range(i + 1, min(i + 4, len(lines))):
                    m = LABEL_RE.match(lines[j])
                    if m:
                        section = m.group(1)
                        break
            row_match = ROW_RE.match(lines[i])
            if row_match:
                row_type = row_match.group(1)
                label = anchor = subtext = None
                checked_path = toggled_path = None
                for j in range(i + 1, min(i + 12, len(lines))):
                    if label is None:
                        m = LABEL_RE.match(lines[j])
                        if m:
                            label = m.group(1)
                    if anchor is None:
                        a = ANCHOR_RE.match(lines[j])
                        if a:
                            anchor = a.group(1)
                    if subtext is None:
                        st = SUBTEXT_RE.match(lines[j])
                        if st:
                            subtext = st.group(1)
                    if checked_path is None:
                        ch = CHECKED_RE.match(lines[j])
                        if ch:
                            checked_path = ch.group(1)
                    if toggled_path is None:
                        tg = ONTOGGLED_RE.match(lines[j])
                        if tg:
                            toggled_path = tg.group(1)
                toggle_path = (
                    checked_path
                    if row_type == "ToggleRow" and checked_path and checked_path == toggled_path
                    else ""
                )
                if label and label not in SKIP_LABELS and anchor:
                    extra = " ".join(meta["crumbLabels"]) + \
                        " " + section + " " + (subtext or "")
                    entries.append({
                        "pageIdx": meta["pageIdx"], "subPath": meta["subPath"],
                        "crumbIcons": meta["crumbIcons"],
                        "crumbLabels": meta["crumbLabels"],
                        "title": label, "anchor": anchor,
                        "section": section,
                        "subtext": subtext or "",
                        "togglePath": toggle_path,
                        "keywords": " ".join(sorted(set(tokenize(label + " " + extra)))),
                    })
            i += 1
    return entries


def build_inverted_and_ranking(entries: list[dict]):
    inverted: dict[str, list[int]] = defaultdict(list)
    ranking: dict[str, dict[int, float]] = defaultdict(dict)
    for idx, e in enumerate(entries):
        fields = {"title": e["title"], "keywords": e["keywords"]}
        seen: set[str] = set()
        for field, text in fields.items():
            weight = FIELD_WEIGHT.get(field, 0.2)
            for tok in tokenize(text):
                if idx not in inverted[tok]:
                    inverted[tok].append(idx)
                ranking[tok][idx] = max(ranking[tok].get(idx, 0.0), weight)
                seen.add(tok)
    for tok, ids in inverted.items():
        ids.sort(key=lambda i: ranking[tok][i], reverse=True)
    return inverted, {t: {str(k): v for k, v in d.items()} for t, d in ranking.items()}


def main() -> int:
    if len(sys.argv) != 3:
        print(__doc__)
        return 1
    settings = Path(sys.argv[1])
    out = Path(sys.argv[2])
    files = discover_files(settings)
    nav = build_nav_map(settings, files)
    entries = extract_settings(files, nav)
    inverted, ranking = build_inverted_and_ranking(entries)
    for e in entries:
        e.pop("keywords", None)
    out.write_text(json.dumps({
        "version": 2,
        "entries": entries,
        "inverted": inverted,
        "ranking": ranking,
    }, ensure_ascii=False, indent=2))
    print(f"settings index: {len(entries)} entries, "
          f"{len(inverted)} tokens -> {out}")
    print("files:", len(files))
    print("comps:", len(parse_page_comps(settings)))
    print("registry:", len(parse_page_registry(settings)))
    print("nav:", len(nav))
    print("entries:", len(entries))
    return 0


if __name__ == "__main__":
    sys.exit(main())
