import os
import re
from pathlib import Path
from nicegui import ui
import json
try:
    import tomllib  # py311+
except ModuleNotFoundError:
    import tomli as tomllib  # type: ignore
import time

from v0.relationalai.debugging import DEBUG_LOG_FILE  # type: ignore[import-not-found]

# --------------------------------------------------
# Get version from pyproject.toml
# --------------------------------------------------


def get_version():
    """Read version from pyproject.toml"""
    try:
        pyproject_path = os.path.join(os.path.dirname(__file__), "..", "..", "..", "pyproject.toml")
        with open(pyproject_path, "rb") as f:
            data = tomllib.load(f)
            return data.get("project", {}).get("version", "unknown")
    except Exception:
        return "unknown"


VERSION = get_version()

# --------------------------------------------------
# Theme & Design System
# --------------------------------------------------

THEME = {
    # Backgrounds
    "bg_main": "#1a1b26",
    "bg_elevated": "#24283b",
    "bg_surface": "#414868",
    "bg_hover": "#545c7e",
    "bg_code": "#1f2335",
    # Text
    "text_primary": "#c0caf5",
    "text_secondary": "#9aa5ce",
    "text_tertiary": "#565f89",
    "text_muted": "#787c99",
    # Accents
    "accent_primary": "#7aa2f7",
    "accent_secondary": "#bb9af7",
    "accent_rule": "#9ece6a",
    "accent_query": "#7dcfff",
    "accent_compile": "#ff9e64",
    "accent_passes": "#bb9af7",
    # Status
    "success": "#9ece6a",
    "warning": "#e0af68",
    "error": "#f7768e",
    # Borders
    "border": "#3b4261",
    "border_light": "#545c7e",
}

THEME_LIGHT = {
    # Backgrounds - Light and airy
    "bg_main": "#f9fafb",           # Very light gray (almost white)
    "bg_elevated": "#ffffff",        # Pure white for elevated surfaces
    "bg_surface": "#e5e7eb",         # Light gray for surfaces
    "bg_hover": "#d1d5db",           # Medium-light gray for hover
    "bg_code": "#f3f4f6",            # Slightly off-white for code blocks

    # Text - Dark for readability
    "text_primary": "#1f2937",       # Near black
    "text_secondary": "#4b5563",     # Dark gray
    "text_tertiary": "#9ca3af",      # Medium gray
    "text_muted": "#6b7280",         # Muted gray

    # Accents - Vibrant colors
    "accent_primary": "#2563eb",     # Bright blue
    "accent_secondary": "#7c3aed",   # Purple
    "accent_rule": "#16a34a",        # Green
    "accent_query": "#0891b2",       # Cyan
    "accent_compile": "#ea580c",     # Orange
    "accent_passes": "#9333ea",      # Violet

    # Status
    "success": "#16a34a",            # Green
    "warning": "#d97706",            # Amber
    "error": "#dc2626",              # Red

    # Borders
    "border": "#d1d5db",             # Light gray
    "border_light": "#e5e7eb",       # Very light gray
}


def get_timing_color(elapsed):
    """Return color based on timing thresholds"""
    if elapsed is None:
        return THEME["text_muted"]
    if elapsed < 0.01:  # < 10ms
        return THEME["success"]
    elif elapsed < 0.1:  # < 100ms
        return THEME["warning"]
    else:
        return THEME["error"]


def get_span_icon(span_type):
    """Return emoji icon for span type"""
    icons = {
        "rule": "📜",
        "query": "🔍",
        "compile": "⚙️",
        "passes": "🔄",
        "v1": "1️⃣",
        "v1_v0": "🔀",
        "create_v2": "🚀",
    }
    return icons.get(span_type, "")


def get_span_color(span_type):
    """Return accent color for span type"""
    colors = {
        "rule": THEME["accent_rule"],
        "query": THEME["accent_query"],
        "compile": THEME["accent_compile"],
        "passes": THEME["accent_passes"],
        "v1": THEME["accent_secondary"],
        "v1_v0": THEME["accent_primary"],
    }
    return colors.get(span_type, THEME["accent_primary"])


# --------------------------------------------------
# Debug log helpers
# --------------------------------------------------


class SpanNode:
    def __init__(self, id, type, parent_id, start_timestamp, attrs=None):
        self.id = id
        self.type = type
        self.parent_id = parent_id
        self.start_timestamp = start_timestamp
        self.end_timestamp = None
        self.elapsed = None
        self.start_attrs = attrs or {}
        self.end_attrs = {}
        self.children = []

    def add_end_data(self, end_timestamp, elapsed, end_attrs=None):
        self.end_timestamp = end_timestamp
        self.elapsed = elapsed
        if end_attrs:
            # Merge end_attrs into attrs
            self.end_attrs.update(end_attrs)

    def add_child(self, child):
        self.children.append(child)

    def __str__(self, level=0):
        indent = "  " * level
        result = f"{indent}{self.type} ({self.id}):\n"
        result += f"{indent}  elapsed: {self.elapsed:.6f}s\n"

        if self.start_attrs or self.end_attrs:
            result += f"{indent}  start attributes:\n"
            for key, value in self.start_attrs.items():
                # Truncate long values for display
                if isinstance(value, str) and len(value) > 100:
                    value = value[:97] + "..."
                result += f"{indent}    {key}: {value}\n"
            result += f"{indent}  end attributes:\n"
            for key, value in self.end_attrs.items():
                # Truncate long values for display
                if isinstance(value, str) and len(value) > 100:
                    value = value[:97] + "..."
                result += f"{indent}    {key}: {value}\n"

        if self.children:
            result += f"{indent}  children:\n"
            for child in self.children:
                result += child.__str__(level + 2)

        return result


def find_span_by_id(spans, target_id):
    """Recursively search for a span by ID in a tree of spans"""
    for span in spans:
        if span.id == target_id:
            return span
        # Search in children
        if span.children:
            result = find_span_by_id(span.children, target_id)
            if result:
                return result
    return None


def parse_jsonl_to_tree(jsonl_content):
    """Parse JSONL content into a tree structure. Optimized for performance."""
    if not jsonl_content:
        return []

    lines = jsonl_content.strip().split("\n")
    nodes_by_id = {}
    root_nodes = []

    # Parse in two passes for better performance
    # Pass 1: Create all nodes
    for line in lines:
        if not line:
            continue
        data = json.loads(line)
        event_type = data["event"]

        if event_type == "span_start":
            span = data["span"]
            node = SpanNode(
                id=span["id"],
                type=span["type"],
                parent_id=span["parent_id"],
                start_timestamp=span["start_timestamp"],
                attrs=span.get("attrs", {}),
            )
            nodes_by_id[node.id] = node

            # Track potential roots
            if node.parent_id is None:
                root_nodes.append(node)

        elif event_type == "span_end":
            node_id = data["id"]
            node = nodes_by_id.get(node_id)
            if node:
                node.add_end_data(
                    end_timestamp=data["end_timestamp"],
                    elapsed=data.get("elapsed", 0),
                    end_attrs=data.get("end_attrs", {}),
                )

    # Pass 2: Build parent-child relationships
    for node in nodes_by_id.values():
        if node.parent_id and node.parent_id in nodes_by_id:
            nodes_by_id[node.parent_id].add_child(node)

    return root_nodes


# --------------------------------------------------
# UI
# --------------------------------------------------

last_mod_time = None
last_file_content = None  # Cache file content
current_json_objects = []
is_loading = False  # Track loading state

# Second file support for comparison
last_mod_time_2 = None
last_file_content_2 = None
current_json_objects_2 = []
debug_file_2 = None
is_loading_2 = False

# this is more than toggles now that we accept regex for passes
toggles = {
    "v1": False,
    "v1_v0": False,
    "metamodel": False,
    "backend": False,
    "passes": False,
    "dsl": False,
    "type_graph": False,
    "show_stats": False,
    "pass_diff": False,
    "collapse_sidebar": False,
}

checkboxes = {}

# Custom file upload state
custom_file_name = None
use_custom_file = False

# Query selection state
selected_query_id = None
selected_query_index = None

# Pass expansion state (all passes start expanded)
pass_expanded = {}  # pass_id -> boolean


def select_query(query_id, query_index):
    """Select a query to display in the main pane"""
    global selected_query_id, selected_query_index
    selected_query_id = query_id
    selected_query_index = query_index
    query_list_sidebar.refresh()  # type: ignore[attr-defined]
    details.refresh()  # type: ignore[attr-defined]
    sidebar.refresh()  # type: ignore[attr-defined]


def to_ms(t):
    if not t:
        return "..."
    return f"{t * 1000:,.0f}"


def format_time(t):
    if not t:
        return "..."
    if t > 1:
        return f"{t:.1f}s"
    elif t > 0.001:
        return f"{t * 1000:.1f}ms"
    elif t > 0.0005:
        return f"{t * 1000:.2f}ms"
    else:
        return f"{t * 1000000:.0f}μs"


def replace_long_brace_contents(code_str):
    def replacement(match):
        string = match.group(0)
        if len(string) > 300:
            # Extract the first 50 and last 30 characters from the string within brackets
            return "{" + string[1:51] + "..." + string[-31:-1] + "}"
        else:
            # If the string is not longer than 300 characters, return it unchanged
            return string

    # This regex matches sequences of characters wrapped in { }
    brace_content_regex = r"\{[\s\S]*?\}"

    # Use the sub method to replace the matched strings with the result of the replacement function
    return re.sub(brace_content_regex, replacement, code_str)


def compute_line_diff(text1: str, text2: str):
    """
    Compute line-by-line diff with character-level highlighting.
    Returns list of tuples: (line_num, line_type, content, char_highlights)

    line_type can be: 'equal', 'insert', 'replace', or 'delete'
    """
    import difflib

    # Edge cases: empty inputs
    if not text1 and not text2:
        return []
    if not text1:
        return [(i, 'insert', line, []) for i, line in enumerate(text2.splitlines())]
    if not text2:
        return [(i, 'delete', line, []) for i, line in enumerate(text1.splitlines())]

    lines1 = text1.splitlines()
    lines2 = text2.splitlines()

    sm = difflib.SequenceMatcher(None, lines1, lines2)
    diff_result = []
    result_position = 0  # Track position in rendered output (not line numbers from either file)

    for tag, i1, i2, j1, j2 in sm.get_opcodes():
        if tag == 'equal':
            for idx, line in enumerate(lines2[j1:j2]):
                diff_result.append((result_position + idx, 'equal', line, []))
            result_position += (j2 - j1)

        elif tag == 'replace':
            # Handle replacements - may have unequal line counts
            num_lines1 = i2 - i1  # Lines in File 1
            num_lines2 = j2 - j1  # Lines in File 2
            max_lines = max(num_lines1, num_lines2)

            for idx in range(max_lines):
                if idx < num_lines1 and idx < num_lines2:
                    # Both files have this line - show as replace with char-level diff
                    line1 = lines1[i1 + idx]
                    line2 = lines2[j1 + idx]
                    char_highlights = compute_char_diff(line1, line2)
                    diff_result.append((result_position + idx, 'replace', line2, char_highlights))
                elif idx < num_lines1:
                    # Extra line in File 1 (deleted in File 2)
                    diff_result.append((result_position + idx, 'delete', lines1[i1 + idx], []))
                else:
                    # Extra line in File 2 (added from File 1)
                    diff_result.append((result_position + idx, 'insert', lines2[j1 + idx], []))
            result_position += max_lines

        elif tag == 'insert':
            # Lines only in File 2 (additions)
            for idx in range(j2 - j1):
                diff_result.append((result_position + idx, 'insert', lines2[j1 + idx], []))
            result_position += (j2 - j1)

        elif tag == 'delete':
            # Lines only in File 1 (deletions) - THIS WAS MISSING!
            for idx in range(i2 - i1):
                diff_result.append((result_position + idx, 'delete', lines1[i1 + idx], []))
            result_position += (i2 - i1)

    return diff_result


def compute_char_diff(str1: str, str2: str):
    """
    Compute character-level diff within a line.
    Returns list of (start, end, type) tuples for highlighting in str2.
    """
    import difflib

    sm = difflib.SequenceMatcher(None, str1, str2)
    highlights = []

    for tag, i1, i2, j1, j2 in sm.get_opcodes():
        if tag in ('replace', 'insert'):
            highlights.append((j1, j2, tag))

    return highlights


def apply_char_highlights(raw_text: str, highlights):
    """Apply character-level highlight spans to raw text, HTML-escaping each segment."""
    import html as _html

    if not highlights:
        return _html.escape(raw_text)

    result = []
    last_pos = 0

    for start, end, tag in sorted(highlights):
        result.append(_html.escape(raw_text[last_pos:start]))
        span_class = "char-insert" if tag == "insert" else "char-replace"
        result.append(f'<span class="{span_class}">{_html.escape(raw_text[start:end])}</span>')
        last_pos = end

    result.append(_html.escape(raw_text[last_pos:]))
    return "".join(result)


def render_diff_html(code: str, diff_data, language: str = "python"):
    """
    Render code with diff highlighting as HTML.
    """
    import html

    lines_html = []
    for line_num, line_type, content, char_highlights in diff_data:
        # Apply character-level highlights (skip for deleted lines)
        if char_highlights and line_type != 'delete':
            content_html = apply_char_highlights(content, char_highlights)
        else:
            content_html = html.escape(content)

        # Apply line-level styling
        line_class = f"diff-line diff-{line_type}"

        # Determine prefix based on line type
        if line_type in ('insert', 'replace'):
            prefix = "+"
        elif line_type == 'delete':
            prefix = "-"
        else:
            prefix = " "

        lines_html.append(
            f'<div class="{line_class}">'
            f'<span class="line-prefix">{prefix}</span>'
            f'<span class="line-content">{content_html}</span>'
            f'</div>'
        )

    # Wrap in code block
    html_output = f'''
    <div class="diff-code-block">
        <pre><code class="language-{language}">{"".join(lines_html)}</code></pre>
    </div>
    '''

    return html_output


def code(c, language="python"):
    # do not wrap text when outputting Rel
    if language != "rel":
        c = replace_long_brace_contents(c)
    # use python's syntax highlighting for Rel as well
    if language == "rel":
        language = "ruby"
    if language == "lqp":
        language = "python"
    c = re.sub(r"→", "->", c)
    c = re.sub(r"⇑", "^", c)
    c = re.sub(r"…", "...", c)
    c = re.sub(r"\?", "<optional>", c)

    code_element = (
        ui.code(c, language=language)
        .style(
            "border-radius: 6px; "
            "margin: 8px 0; "
            "padding: 12px 16px; "
            "font-family: 'JetBrains Mono', 'Fira Code', 'Consolas', monospace; "
            "font-size: 0.9em; "
            "line-height: 1.6; "
            "box-shadow: 0 1px 3px rgba(0, 0, 0, 0.2); "
            "overflow-x: auto; "
            "max-width: 100%;"
        )
        .classes("w-full code-block")
    )

    return code_element


def code_with_diff(c1, c2, language="python"):
    """
    Display code with git diff-style highlighting showing differences between c1 and c2.
    Only c2 is shown with highlighting indicating what changed from c1.
    """
    if not c2 or not c2.strip():
        return

    # Apply same transformations as code() function
    if language != "rel":
        c2 = replace_long_brace_contents(c2)
    if language == "rel":
        language = "ruby"
    if language == "lqp":
        language = "python"

    c1_stripped = c1.strip() if c1 else ""
    c2_stripped = c2.strip()

    # Apply character replacements
    c2_stripped = re.sub(r"→", "->", c2_stripped)
    c2_stripped = re.sub(r"⇑", "^", c2_stripped)
    c2_stripped = re.sub(r"…", "...", c2_stripped)
    c2_stripped = re.sub(r"\?", "<optional>", c2_stripped)

    if c1:
        c1_stripped = re.sub(r"→", "->", c1_stripped)
        c1_stripped = re.sub(r"⇑", "^", c1_stripped)
        c1_stripped = re.sub(r"…", "...", c1_stripped)
        c1_stripped = re.sub(r"\?", "<optional>", c1_stripped)

    # If identical, show regular code without diff highlighting
    if c1_stripped == c2_stripped:
        return code(c2_stripped, language=language)

    # Compute diff
    diff_data = compute_line_diff(c1_stripped, c2_stripped)

    # Render as HTML with diff highlighting
    html_content = render_diff_html(c2_stripped, diff_data, language=language)

    # Use ui.html() to display with same styling as code()
    ui.html(html_content, sanitize=False).style(
        "border-radius: 6px; "
        "margin: 8px 0; "
        "padding: 12px 16px; "
        "font-family: 'JetBrains Mono', 'Fira Code', 'Consolas', monospace; "
        "font-size: 0.9em; "
        "line-height: 1.6; "
        "box-shadow: 0 1px 3px rgba(0, 0, 0, 0.2); "
        "overflow-x: auto; "
        "max-width: 100%;"
    ).classes("w-full code-block")


def code_with_header(header_text, c, language="python", c2=None):
    """Display code with header. If c2 is provided, show two columns side by side."""
    if c2 is not None:
        # Check if both codes are identical
        c_stripped = c.strip() if c else ""
        c2_stripped = c2.strip() if c2 else ""
        
        if c_stripped and c2_stripped and c_stripped == c2_stripped:
            # Identical code - show single column with special header
            with ui.column().style(
                f"gap: 0; margin: 16px 0; border-left: 3px solid {THEME['success']}; padding-left: 12px;"
            ):
                ui.label(f"{header_text} (identical)").style(
                    f"font-size: 0.85em; "
                    f"font-weight: 600; "
                    f"letter-spacing: 0.05em; "
                    f"color: {THEME['text_secondary']}; "
                    f"margin-bottom: 8px;"
                )
                code(c_stripped, language=language)
        else:
            # Different code - show two columns
            with ui.row().style(
                "gap: 16px; margin: 16px 0; width: 100%;"
            ):
                # Left column
                with ui.column().style(
                    f"gap: 0; flex: 1; border-left: 3px solid {THEME['accent_primary']}; padding-left: 12px; min-width: 0;"
                ):
                    ui.label(f"{header_text} (File 1)").style(
                        f"font-size: 0.85em; "
                        f"font-weight: 600; "
                        f"letter-spacing: 0.05em; "
                        f"color: {THEME['text_secondary']}; "
                        f"margin-bottom: 8px;"
                    )
                    if c and c.strip():
                        code(c.strip(), language=language)
                # Right column
                with ui.column().style(
                    f"gap: 0; flex: 1; border-left: 3px solid {THEME['accent_secondary']}; padding-left: 12px; min-width: 0;"
                ):
                    ui.label(f"{header_text} (File 2)").style(
                        f"font-size: 0.85em; "
                        f"font-weight: 600; "
                        f"letter-spacing: 0.05em; "
                        f"color: {THEME['text_secondary']}; "
                        f"margin-bottom: 8px;"
                    )
                    if c2 and c2.strip():
                        if c and c.strip():
                            code_with_diff(c.strip(), c2.strip(), language=language)
                        else:
                            code(c2.strip(), language=language)
    else:
        # Single column (original behavior)
        with ui.column().style(
            f"gap: 0; margin: 16px 0; border-left: 3px solid {THEME['accent_primary']}; padding-left: 12px;"
        ):
            ui.label(header_text).style(
                f"font-size: 0.85em; "
                f"font-weight: 600; "
                f"letter-spacing: 0.05em; "
                f"color: {THEME['text_secondary']}; "
                f"margin-bottom: 8px;"
            )
            # Only show code block if there's content
            if c and c.strip():
                code(c.strip(), language=language)


def span_code(span: SpanNode, span2: SpanNode | None = None):
    if span.type == "rule":
        if span.start_attrs.get("source"):
            with ui.column().style("width: fit-content; max-width: 100%;"):
                code(span.start_attrs["source"]).style(
                    f"transition: all 0.2s ease; border-color: {THEME['accent_rule']};"
                )
        # Always show content when query is selected (no expand/collapse)
        with ui.column().style(
            "gap: 8px; width: 100%; max-width: 100%;"
        ):
            if toggles["dsl"]:
                dsl2 = span2.start_attrs.get("dsl") if span2 else None
                code_with_header("dsl", span.start_attrs["dsl"], c2=dsl2)
            if toggles["metamodel"] and "metamodel" in span.end_attrs:
                metamodel2 = span2.end_attrs.get("metamodel") if span2 else None
                code_with_header("metamodel", span.end_attrs["metamodel"], c2=metamodel2)
    elif span.type == "query":
        if span.start_attrs.get("source"):
            with ui.column().style("width: fit-content; max-width: 100%;"):
                code(span.start_attrs["source"]).style(
                    f"transition: all 0.2s ease; border-color: {THEME['accent_query']};"
                )
        # Always show content when query is selected (no expand/collapse)
        with ui.column().style(
            "gap: 8px; width: 100%; max-width: 100%;"
        ):
            if toggles["dsl"]:
                dsl2 = span2.start_attrs.get("dsl") if span2 else None
                code_with_header("dsl", span.start_attrs["dsl"], c2=dsl2)
            for i, child in enumerate(span.children):
                child2 = span2.children[i] if span2 and i < len(span2.children) else None
                span_code(child, child2)
    elif span.type == "compile" and span.end_attrs.get("compile_type") == "model":
        if toggles["metamodel"]:
            metamodel2 = span2.start_attrs.get("metamodel") if span2 else None
            code_with_header("model metamodel", span.start_attrs["metamodel"], c2=metamodel2)
        for i, child in enumerate(span.children):
            child2 = span2.children[i] if span2 and i < len(span2.children) else None
            span_code(child, child2)
        if toggles["backend"]:
            if "rel" in span.end_attrs:
                rel2 = span2.end_attrs.get("rel") if span2 else None
                code_with_header("rel model", span.end_attrs["rel"], language="rel", c2=rel2)
            elif "lqp" in span.end_attrs:
                lqp2 = span2.end_attrs.get("lqp") if span2 else None
                code_with_header("lqp model", span.end_attrs["lqp"], language="lqp", c2=lqp2)
    elif span.type == "compile" and span.end_attrs.get("compile_type") == "query":
        if toggles["metamodel"]:
            metamodel2 = span2.start_attrs.get("metamodel") if span2 else None
            code_with_header("metamodel", span.start_attrs["metamodel"], c2=metamodel2)
        for i, child in enumerate(span.children):
            child2 = span2.children[i] if span2 and i < len(span2.children) else None
            span_code(child, child2)
        if toggles["backend"]:
            if "rel" in span.end_attrs:
                rel2 = span2.end_attrs.get("rel") if span2 else None
                code_with_header("rel query", span.end_attrs["rel"], language="rel", c2=rel2)
            elif "lqp" in span.end_attrs:
                lqp2 = span2.end_attrs.get("lqp") if span2 else None
                code_with_header("lqp query", span.end_attrs["lqp"], language="lqp", c2=lqp2)
    elif span.type == "passes":
        type_graph_shown = False
        if toggles["passes"]:
            previous_code = None
            previous_code_2 = None
            last_displayed_code = None
            for i, child in enumerate(span.children):
                current_code = ""
                if "metamodel" in child.end_attrs:
                    current_code = child.end_attrs["metamodel"]
                    if current_code == previous_code:
                        current_code = ""
                    else:
                        last_displayed_code = previous_code
                        previous_code = current_code

                # Find matching child in span2
                current_code_2 = None
                child2 = None
                if span2 and i < len(span2.children):
                    child2 = span2.children[i]
                    if "metamodel" in child2.end_attrs:
                        current_code_2 = child2.end_attrs["metamodel"]
                        if current_code_2 == previous_code_2:
                            current_code_2 = None
                        else:
                            previous_code_2 = current_code_2

                # Only make pass collapsible if it has content
                has_content = (current_code and current_code.strip()) or (current_code_2 and current_code_2.strip())
                pass_header = f"{get_span_icon(child.type)} {child.type} • {format_time(child.elapsed)}"
                if child2 and child2.elapsed is not None:
                    pass_header += f" ({format_time(child2.elapsed)})"

                # "AnnotateConstraints" passes use a distinct accent color to mark group boundaries
                is_marker = child.type == "AnnotateConstraints"
                pass_color = THEME["warning"] if is_marker else THEME["accent_primary"]

                if has_content:
                    # Initialize expansion state for this pass (default: expanded)
                    if child.id not in pass_expanded:
                        pass_expanded[child.id] = True

                    # Use ui.expansion for collapsible pass
                    header_style = "background: rgba(224, 175, 104, 0.2); border-radius: 4px;" if is_marker else ""
                    with ui.expansion(pass_header, value=pass_expanded[child.id]).props(
                        f'id="pass-{child.id}" header-style="{header_style}"'
                    ).style(
                        f"margin: 16px 0; "
                        f"border-left: 3px solid {pass_color};"
                    ).classes("pass-expansion") as expansion:
                        expansion.on('update:model-value', lambda e, pass_id=child.id: pass_expanded.update({pass_id: e.args}))

                        # Render code content inside expansion
                        if current_code_2 is not None:
                            # Two-column comparison
                            c_stripped = current_code.strip() if current_code else ""
                            c2_stripped = current_code_2.strip() if current_code_2 else ""

                            if c_stripped and c2_stripped and c_stripped == c2_stripped:
                                # Identical
                                if c_stripped:
                                    code(c_stripped)
                            else:
                                # Different - show two columns
                                with ui.row().style("gap: 16px; width: 100%;"):
                                    with ui.column().style("flex: 1; min-width: 0;"):
                                        if c_stripped:
                                            code(c_stripped)
                                    with ui.column().style("flex: 1; min-width: 0;"):
                                        if c2_stripped:
                                            if c_stripped:
                                                code_with_diff(c_stripped, c2_stripped)
                                            else:
                                                code(c2_stripped)
                        else:
                            # Single column
                            if current_code and current_code.strip():
                                if toggles["pass_diff"] and not span2 and last_displayed_code:
                                    code_with_diff(last_displayed_code, current_code.strip())
                                else:
                                    code(current_code.strip())
                else:
                    # No content - just show header (non-collapsible) with "no changes" indicator
                    ui.label(f"{pass_header} • no changes").style(
                        f"font-size: 0.85em; "
                        f"font-weight: 600; "
                        f"color: {THEME['text_muted']}; "
                        f"margin: 16px 0; "
                        f"padding-left: 12px; "
                        f"border-left: 3px solid {pass_color};"
                    ).props(f'id="pass-{child.id}"')

                if child.type == "InferTypes" and toggles["type_graph"]:
                    type_graph_shown = True
                    for child2 in child.children:
                        if child2.end_attrs.get("type_graph"):
                            ui.mermaid(child2.end_attrs["type_graph"], {"maxEdges": 10000}).style(
                                "display: flex; "
                                "padding: 16px; "
                                "border-radius: 8px; "
                                "margin: 12px 0; "
                                "gap: 30px; "
                                "overflow: auto;"
                            ).classes("mermaid-diagram")
        if toggles["type_graph"] and not type_graph_shown:
            for child in span.children:
                if child.type == "InferTypes":
                    for child2 in child.children:
                        if child2.end_attrs.get("type_graph"):
                            ui.mermaid(child2.end_attrs["type_graph"], {"maxEdges": 10000}).style(
                                "display: flex; "
                                "padding: 16px; "
                                "border-radius: 8px; "
                                "margin: 12px 0; "
                                "gap: 30px; "
                                "overflow: auto;"
                            ).classes("mermaid-diagram")
    elif span.type == "v1":
        if toggles["v1"]:
            previous_code = None
            previous_code_2 = None
            last_displayed_code = None
            for i, child in enumerate(span.children):
                current_code = ""
                if "metamodel" in child.end_attrs:
                    current_code = child.end_attrs["metamodel"]
                    if current_code == previous_code:
                        current_code = ""
                    else:
                        last_displayed_code = previous_code
                        previous_code = current_code

                # Find matching child in span2
                current_code_2 = None
                child2 = None
                if span2 and i < len(span2.children):
                    child2 = span2.children[i]
                    if "metamodel" in child2.end_attrs:
                        current_code_2 = child2.end_attrs["metamodel"]
                        if current_code_2 == previous_code_2:
                            current_code_2 = None
                        else:
                            previous_code_2 = current_code_2

                # Only make pass collapsible if it has content
                has_content = (current_code and current_code.strip()) or (current_code_2 and current_code_2.strip())
                pass_header = f"{get_span_icon(child.type)} {child.type} • {format_time(child.elapsed)}"
                if child2 and child2.elapsed is not None:
                    pass_header += f" ({format_time(child2.elapsed)})"

                # "Original" passes use a distinct accent color to mark group boundaries
                is_original = child.type == "Original"
                pass_color = THEME["warning"] if is_original else THEME["accent_primary"]

                if has_content:
                    # Initialize expansion state for this pass (default: expanded)
                    if child.id not in pass_expanded:
                        pass_expanded[child.id] = True

                    # Use ui.expansion for collapsible pass
                    header_style = "background: rgba(224, 175, 104, 0.2); border-radius: 4px;" if is_original else ""
                    with ui.expansion(pass_header, value=pass_expanded[child.id]).props(
                        f'id="pass-{child.id}" header-style="{header_style}"'
                    ).style(
                        f"margin: 16px 0; "
                        f"border-left: 3px solid {pass_color};"
                    ).classes("pass-expansion") as expansion:
                        expansion.on('update:model-value', lambda e, pass_id=child.id: pass_expanded.update({pass_id: e.args}))

                        # Render code content inside expansion
                        if current_code_2 is not None:
                            # Two-column comparison
                            c_stripped = current_code.strip() if current_code else ""
                            c2_stripped = current_code_2.strip() if current_code_2 else ""

                            if c_stripped and c2_stripped and c_stripped == c2_stripped:
                                # Identical
                                if c_stripped:
                                    code(c_stripped)
                            else:
                                # Different - show two columns
                                with ui.row().style("gap: 16px; width: 100%;"):
                                    with ui.column().style("flex: 1; min-width: 0;"):
                                        if c_stripped:
                                            code(c_stripped)
                                    with ui.column().style("flex: 1; min-width: 0;"):
                                        if c2_stripped:
                                            if c_stripped:
                                                code_with_diff(c_stripped, c2_stripped)
                                            else:
                                                code(c2_stripped)
                        else:
                            # Single column
                            if current_code and current_code.strip():
                                if toggles["pass_diff"] and not span2 and last_displayed_code:
                                    code_with_diff(last_displayed_code, current_code.strip())
                                else:
                                    code(current_code.strip())
                else:
                    # No content - just show header (non-collapsible) with "no changes" indicator
                    ui.label(f"{pass_header} • no changes").style(
                        f"font-size: 0.85em; "
                        f"font-weight: 600; "
                        f"color: {THEME['text_muted']}; "
                        f"margin: 16px 0; "
                        f"padding-left: 12px; "
                        f"border-left: 3px solid {pass_color};"
                    ).props(f'id="pass-{child.id}"')
    elif span.type == "v1_v0":
        if toggles["v1_v0"]:
            previous_code = None
            previous_code_2 = None
            last_displayed_code = None
            for i, child in enumerate(span.children):
                current_code = ""
                if "metamodel" in child.end_attrs:
                    current_code = child.end_attrs["metamodel"]
                    if current_code == previous_code:
                        current_code = ""
                    else:
                        last_displayed_code = previous_code
                        previous_code = current_code

                # Find matching child in span2
                current_code_2 = None
                child2 = None
                if span2 and i < len(span2.children):
                    child2 = span2.children[i]
                    if "metamodel" in child2.end_attrs:
                        current_code_2 = child2.end_attrs["metamodel"]
                        if current_code_2 == previous_code_2:
                            current_code_2 = None
                        else:
                            previous_code_2 = current_code_2

                # Only make pass collapsible if it has content
                has_content = (current_code and current_code.strip()) or (current_code_2 and current_code_2.strip())
                pass_header = f"{get_span_icon(child.type)} {child.type} • {format_time(child.elapsed)}"
                if child2 and child2.elapsed is not None:
                    pass_header += f" ({format_time(child2.elapsed)})"

                if has_content:
                    # Initialize expansion state for this pass (default: expanded)
                    if child.id not in pass_expanded:
                        pass_expanded[child.id] = True

                    # Use ui.expansion for collapsible pass
                    with ui.expansion(pass_header, value=pass_expanded[child.id]).props(f'id="pass-{child.id}"').style(
                        f"margin: 16px 0; "
                        f"border-left: 3px solid {THEME['accent_primary']};"
                    ).classes("pass-expansion") as expansion:
                        expansion.on('update:model-value', lambda e, pass_id=child.id: pass_expanded.update({pass_id: e.args}))

                        # Render code content inside expansion
                        if current_code_2 is not None:
                            # Two-column comparison
                            c_stripped = current_code.strip() if current_code else ""
                            c2_stripped = current_code_2.strip() if current_code_2 else ""

                            if c_stripped and c2_stripped and c_stripped == c2_stripped:
                                # Identical
                                if c_stripped:
                                    code(c_stripped)
                            else:
                                # Different - show two columns
                                with ui.row().style("gap: 16px; width: 100%;"):
                                    with ui.column().style("flex: 1; min-width: 0;"):
                                        if c_stripped:
                                            code(c_stripped)
                                    with ui.column().style("flex: 1; min-width: 0;"):
                                        if c2_stripped:
                                            if c_stripped:
                                                code_with_diff(c_stripped, c2_stripped)
                                            else:
                                                code(c2_stripped)
                        else:
                            # Single column
                            if current_code and current_code.strip():
                                if toggles["pass_diff"] and not span2 and last_displayed_code:
                                    code_with_diff(last_displayed_code, current_code.strip())
                                else:
                                    code(current_code.strip())
                else:
                    # No content - just show header (non-collapsible) with "no changes" indicator
                    ui.label(f"{pass_header} • no changes").style(
                        f"font-size: 0.85em; "
                        f"font-weight: 600; "
                        f"color: {THEME['text_muted']}; "
                        f"margin: 16px 0; "
                        f"padding-left: 12px; "
                        f"border-left: 3px solid {THEME['border']};"
                    ).props(f'id="pass-{child.id}"')
    else:
        for i, child in enumerate(span.children):
            child2 = span2.children[i] if span2 and i < len(span2.children) else None
            span_code(child, child2)


@ui.refreshable
def details():
    if is_loading or is_loading_2:
        with ui.column().style("gap: 20px; padding: 16px; align-items: center;"):
            ui.spinner(size="xl", color=THEME["accent_primary"])
            ui.label("Loading debug data...").style(f"color: {THEME['text_secondary']};")
    elif selected_query_id is None:
        # Show nothing when no query is selected
        pass
    else:
        # Show only the selected query
        with ui.column().style("gap: 20px; padding: 16px; width: 100%; max-width: 100%; box-sizing: border-box;"):
            # Find the selected query
            selected_query = None
            for span in current_json_objects:
                if span.id == selected_query_id:
                    selected_query = span
                    break
            
            if selected_query:
                # Find matching query in second file if comparison mode
                selected_query_2 = None
                if selected_query_index is not None and selected_query_index < len(current_json_objects_2):
                    selected_query_2 = current_json_objects_2[selected_query_index]
                
                # Always expand the selected query (no toggle needed)
                span_code(selected_query, selected_query_2)
            else:
                ui.label("Selected query not found").style(
                    f"color: {THEME['error']}; "
                    f"padding: 16px;"
                )


def handle_body(span: SpanNode):
    if span.children and span.type not in ["rule_batch"]:
        for child in span.children:
            span_ui(child, True)


def span_ui(span: SpanNode, is_pass: bool = False):
    def jump_to_pass():
        global toggles
        checkboxes["passes"].set_value(True)
        toggles["passes"] = True
        sidebar.refresh()  # type: ignore[attr-defined]
        details.refresh()  # type: ignore[attr-defined]
        ui.navigate.to(f"#pass-{span.id}")

    with ui.column().classes("w-full").style("padding: 0 0 0 15px; gap: 4px;"):
        timing_color = get_timing_color(span.elapsed)
        span_color = get_span_color(span.type)
        icon = get_span_icon(span.type)

        with (
            ui.row()
            .classes("w-full span-card")
            .style(
                f"padding: 8px 12px; "
                f"flex-wrap: nowrap; "
                f"margin: 0; "
                f"border-left: 3px solid {span_color}; "
                f"border-radius: 4px; "
                f"transition: all 0.2s ease; "
                f"box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);"
            ) as me
        ):
            name = span.type
            if name == "create_v2":
                name += " ➚"
            ui.label(f"{icon} {name}").style(
                "padding: 0; margin: 0; font-weight: 500; font-size: 0.9em;"
            ).classes("span-card-text")
            ui.space()
            ui.label(f"{to_ms(span.elapsed)}ms").style(
                f"padding: 4px 8px; "
                f"color: {timing_color}; "
                f"border-radius: 4px; "
                f"font-family: 'JetBrains Mono', monospace; "
                f"font-size: 0.85em; "
                f"font-weight: 600;"
            ).classes("timing-badge")
            if span.type == "create_v2":
                txn_id = span.end_attrs.get("txn_id")
                me.on(
                    "click",
                    lambda: ui.navigate.to(
                        f"https://171608476159.observeinc.com/workspace/41759331/log-explorer?datasetId=41832558&filter-rai_transaction_id={txn_id}",
                        new_tab=True,
                    ),
                )
                me.style("cursor: pointer;")
            elif is_pass:
                me.on("click", lambda: jump_to_pass())
                me.style("cursor: pointer;")

            # Add hover effect
            if span.type == "create_v2" or is_pass:
                me.style("add cursor: pointer;")
                me.classes("hover:brightness-110")

        handle_body(span)


@ui.refreshable
def query_list_sidebar():
    """Left sidebar showing list of queries/rules for selection"""
    # Filter for queries and rules only
    queries = [(i, span) for i, span in enumerate(current_json_objects) 
               if span.type in ["query", "rule"]]
    
    if not queries:
        # Check if we're in initial loading state
        if is_loading:
            with ui.column().style("gap: 12px; padding: 16px; align-items: center;"):
                ui.spinner(size="lg", color=THEME["accent_primary"])
                ui.label("Processing debug file...").style(
                    f"color: {THEME['text_secondary']}; "
                    f"font-size: 0.9em; "
                    f"text-align: center;"
                )
        else:
            ui.label("No queries or rules found").style(
                f"color: {THEME['text_muted']}; "
                f"padding: 16px; "
                f"text-align: center;"
            )
        return
    
    for i, query in queries:
        is_selected = query.id == selected_query_id
        span_color = get_span_color(query.type)
        icon = get_span_icon(query.type)

        # Get query name from source if available
        source = query.start_attrs.get("source", "")
        # Extract first line as name
        query_name = source.split('\n')[0][:50] if source else f"{query.type} {i}"

        border_color = span_color if is_selected else 'transparent'
        item_class = "query-item-selected" if is_selected else "query-item"

        with (
            ui.row()
            .classes(f"w-full {item_class}")
            .style(
                f"padding: 10px 12px; "
                f"margin: 2px 0; "
                f"border-left: 3px solid {border_color}; "
                f"border-radius: 4px; "
                f"cursor: pointer; "
                f"transition: all 0.2s ease; "
                f"gap: 8px; "
                f"align-items: center;"
            )
            .classes("hover:brightness-110")
            as item
        ):
            ui.label(icon).style(
                "font-size: 1.2em; "
                "margin: 0;"
            )
            ui.label(query_name).style(
                "flex: 1; "
                "font-size: 0.85em; "
                "overflow: hidden; "
                "text-overflow: ellipsis; "
                "white-space: nowrap;"
            ).classes("query-item-text")
            if is_selected:
                ui.label("✓").style(
                    f"color: {span_color}; "
                    f"font-weight: bold;"
                )
            
            # Make clickable
            item.on("click", lambda idx=i, qid=query.id: select_query(qid, idx))


@ui.refreshable
def sidebar():
    with ui.column().style(
        "min-width: 300px; "
        "max-width: 300px; "
        "margin-top: 60px; "
        "height: calc(100vh - 70px); "
        "overflow-x: hidden; "
        "overflow-y: auto; "
        "position: fixed; "
        "top: 0; "
        "right: 0; "
        "z-index: 1000; "
        "padding: 16px 12px; "
        "gap: 8px;"
    ).classes("right-sidebar"):
        if selected_query_id is None:
            ui.label("No query selected").style(
                f"color: {THEME['text_muted']}; "
                f"padding: 16px; "
                f"text-align: center;"
            )
        else:
            # Find and show only the selected query's stats
            selected_span = find_span_by_id(current_json_objects, selected_query_id)
            if selected_span:
                span_ui(selected_span)
            else:
                ui.label("Stats not available").style(
                    f"color: {THEME['text_muted']}; "
                    f"padding: 16px; "
                    f"text-align: center;"
                )


def poll():
    global last_mod_time, last_file_content, is_loading
    global current_json_objects
    global last_mod_time_2, last_file_content_2, is_loading_2, current_json_objects_2
    global selected_query_id, selected_query_index

    # Skip polling when a custom uploaded file is active
    if use_custom_file:
        return

    # Check the last modification time of the first file
    try:
        mod_time = os.path.getmtime(DEBUG_LOG_FILE)
        if last_mod_time is None or mod_time > last_mod_time:
            # File has changed, read and parse the new content
            with open(DEBUG_LOG_FILE, "r") as file:
                content = file.read()
                # Only parse if content actually changed
                if content and content != last_file_content:
                    # Update mod time only when we actually process the content
                    last_mod_time = mod_time
                    
                    # Show loading indicator for large files
                    if len(content) > 100000:  # 100KB threshold
                        is_loading = True
                        details.refresh()  # type: ignore[attr-defined]

                    start_time = time.time()
                    last_file_content = content
                    new_tree = parse_jsonl_to_tree(content)
                    parse_time = time.time() - start_time

                    # Log parsing time if it's slow
                    if parse_time > 0.1:
                        print(f"⏱️  Parsed file 1: {len(content)} bytes in {parse_time:.3f}s")

                    # Update the current JSON objects
                    current_json_objects = new_tree
                    is_loading = False
                    
                    # Auto-select first query if none selected OR if selected query no longer exists
                    if selected_query_id is None:
                        queries = [(i, span) for i, span in enumerate(current_json_objects) 
                                   if span.type in ["query", "rule"]]
                        if queries:
                            first_idx, first_query = queries[0]
                            selected_query_id = first_query.id
                            selected_query_index = first_idx
                    else:
                        # Check if the currently selected query still exists
                        query_exists = any(span.id == selected_query_id for span in current_json_objects)
                        if not query_exists:
                            # Selected query no longer exists, select first available query
                            queries = [(i, span) for i, span in enumerate(current_json_objects) 
                                       if span.type in ["query", "rule"]]
                            if queries:
                                first_idx, first_query = queries[0]
                                selected_query_id = first_query.id
                                selected_query_index = first_idx

                    # Refresh the UI with the new objects
                    query_list_sidebar.refresh()  # type: ignore[attr-defined]
                    sidebar.refresh()  # type: ignore[attr-defined]
                    details.refresh()  # type: ignore[attr-defined]
    except FileNotFoundError:
        pass
    
    # Check the second file if it exists
    if debug_file_2:
        try:
            mod_time_2 = os.path.getmtime(debug_file_2)
            if last_mod_time_2 is None or mod_time_2 > last_mod_time_2:
                last_mod_time_2 = mod_time_2
                with open(debug_file_2, "r") as file:
                    content = file.read()
                    if content and content != last_file_content_2:
                        if len(content) > 100000:
                            is_loading_2 = True
                            details.refresh()  # type: ignore[attr-defined]

                        start_time = time.time()
                        last_file_content_2 = content
                        new_tree_2 = parse_jsonl_to_tree(content)
                        parse_time = time.time() - start_time

                        if parse_time > 0.1:
                            print(f"⏱️  Parsed file 2: {len(content)} bytes in {parse_time:.3f}s")

                        current_json_objects_2 = new_tree_2
                        is_loading_2 = False

                        query_list_sidebar.refresh()  # type: ignore[attr-defined]
                        sidebar.refresh()  # type: ignore[attr-defined]
                        details.refresh()  # type: ignore[attr-defined]
        except FileNotFoundError:
            pass


def toggle(key):
    global toggles
    toggles[key] = not toggles[key]
    if key in ("show_stats", "collapse_sidebar"):
        main_layout.refresh()  # type: ignore[attr-defined]
    else:
        sidebar.refresh()  # type: ignore[attr-defined]
        details.refresh()  # type: ignore[attr-defined]


def collect_backend_code(spans):
    """Collect all backend (rel/lqp) code from compile spans across all queries.
    Returns (content, ext) where ext is 'rel' or 'lqp'."""
    sections = []
    ext = "rel"

    def _collect_from_span(span, query_label):
        nonlocal ext
        if span.type == "compile":
            compile_type = span.end_attrs.get("compile_type", "")
            if "rel" in span.end_attrs:
                sections.append(f"// {compile_type} - {query_label}\n{span.end_attrs['rel']}")
            elif "lqp" in span.end_attrs:
                ext = "lqp"
                sections.append(f"// {compile_type} - {query_label}\n{span.end_attrs['lqp']}")
        for child in span.children:
            _collect_from_span(child, query_label)

    for span in spans:
        source = span.start_attrs.get("source", "")
        label = source.split("\n")[0][:60] if source else span.type
        _collect_from_span(span, label)

    return "\n\n".join(sections), ext


def export_backend_code():
    content, ext = collect_backend_code(current_json_objects)
    if content:
        ui.download(content.encode(), f"backend_code.{ext}")
    else:
        ui.notify("No backend code found in current debug data.", color="warning")


@ui.refreshable
def main_layout():
    with ui.row().style(
        "margin-top: 70px; "
        "height: calc(100vh - 70px); "
        "width: 100%; "
        "flex-wrap: nowrap; "
        "gap: 0; "
        "overflow: hidden;"
    ):
        # Left sidebar: Query list
        if not toggles["collapse_sidebar"]:
            with ui.column().style(
                "width: 350px; "
                "min-width: 350px; "
                "max-width: 350px; "
                "flex-shrink: 0; "
                "height: 100%; "
                "overflow-x: hidden; "
                "overflow-y: auto; "
                "padding: 16px 12px; "
                "gap: 8px;"
            ).classes("left-sidebar"):
                query_list_sidebar()
        
        # Center: Main details pane
        with ui.column().style(
            "flex: 1 1 0; "
            "min-width: 0; "
            "width: 100%; "
            "padding: 0 2em; "
            "overflow-y: auto; "
            "overflow-x: hidden; "
            "height: 100%; "
            "box-sizing: border-box; "
            "scrollbar-width: none; "  # Firefox
            "-ms-overflow-style: none;"  # IE/Edge
        ).classes("hide-scrollbar main-content"):  # For webkit browsers
            details()
        
        # Right sidebar: Stats (optional, fixed position - always render outside the flex container)
    
    # Render stats outside the main flex row so it doesn't affect layout
    if toggles["show_stats"]:
        sidebar()


@ui.refreshable
def file_indicator():
    if custom_file_name:
        with ui.row().style("align-items: center; gap: 4px;"):
            ui.label(f"📄 {custom_file_name}").style(
                f"color: {THEME['accent_query']}; "
                f"font-size: 0.85em; "
                f"max-width: 200px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;"
            )
            ui.button(icon="close", on_click=clear_custom_file).props("flat round dense").style(
                f"color: {THEME['text_muted']};"
            )


async def handle_file_upload(e):
    """Handle an uploaded .jsonl debug file."""
    global custom_file_name, use_custom_file
    global current_json_objects, selected_query_id, selected_query_index
    try:
        content = await e.file.text()
        custom_file_name = e.file.name
        use_custom_file = True

        new_tree = parse_jsonl_to_tree(content)
        current_json_objects = new_tree

        queries = [(i, span) for i, span in enumerate(current_json_objects)
                   if span.type in ["query", "rule"]]
        if queries:
            first_idx, first_query = queries[0]
            selected_query_id = first_query.id
            selected_query_index = first_idx
        else:
            selected_query_id = None
            selected_query_index = None

        file_indicator.refresh()  # type: ignore[attr-defined]
        query_list_sidebar.refresh()  # type: ignore[attr-defined]
        sidebar.refresh()  # type: ignore[attr-defined]
        details.refresh()  # type: ignore[attr-defined]
    except Exception as ex:
        print(f"Error processing uploaded file: {ex}")


def clear_custom_file():
    """Clear the custom file and resume polling the default debug file."""
    global custom_file_name, use_custom_file, last_mod_time, last_file_content
    custom_file_name = None
    use_custom_file = False
    last_mod_time = None   # force re-read of default file
    last_file_content = None
    file_indicator.refresh()  # type: ignore[attr-defined]


def main(host="0.0.0.0", port=8080, file2=None):
    global checkboxes
    global toggles
    global debug_file_2
    
    debug_file_2 = file2
    
    # Turn off stats and enable v1 passes by default in comparison mode
    if debug_file_2:
        toggles["show_stats"] = False
        toggles["v1"] = True

    ui.dark_mode(None)

    # Add custom CSS for better styling with theme support
    ui.add_head_html("""
        <style>
            /* CSS Variables - Dark Theme (Default) */
            :root {
                /* Backgrounds */
                --bg-main: #1a1b26;
                --bg-elevated: #24283b;
                --bg-surface: #414868;
                --bg-hover: #545c7e;
                --bg-code: #1f2335;

                /* Text */
                --text-primary: #c0caf5;
                --text-secondary: #9aa5ce;
                --text-tertiary: #565f89;
                --text-muted: #787c99;

                /* Accents */
                --accent-primary: #7aa2f7;
                --accent-secondary: #bb9af7;
                --accent-rule: #9ece6a;
                --accent-query: #7dcfff;
                --accent-compile: #ff9e64;
                --accent-passes: #bb9af7;

                /* Status */
                --success: #9ece6a;
                --warning: #e0af68;
                --error: #f7768e;

                /* Borders */
                --border: #3b4261;
                --border-light: #545c7e;

                /* Diff colors - Dark theme */
                --diff-insert-bg: rgba(46, 160, 67, 0.15);
                --diff-replace-bg: rgba(229, 192, 123, 0.15);
                --diff-delete-bg: rgba(247, 118, 142, 0.15);
                --char-insert-bg: rgba(46, 160, 67, 0.35);
                --char-replace-bg: rgba(229, 192, 123, 0.35);
            }

            /* Light Theme Override */
            @media (prefers-color-scheme: light) {
                :root {
                    /* Backgrounds */
                    --bg-main: #f9fafb;
                    --bg-elevated: #ffffff;
                    --bg-surface: #e5e7eb;
                    --bg-hover: #d1d5db;
                    --bg-code: #f3f4f6;

                    /* Text */
                    --text-primary: #1f2937;
                    --text-secondary: #4b5563;
                    --text-tertiary: #9ca3af;
                    --text-muted: #6b7280;

                    /* Accents */
                    --accent-primary: #2563eb;
                    --accent-secondary: #7c3aed;
                    --accent-rule: #16a34a;
                    --accent-query: #0891b2;
                    --accent-compile: #ea580c;
                    --accent-passes: #9333ea;

                    /* Status */
                    --success: #16a34a;
                    --warning: #d97706;
                    --error: #dc2626;

                    /* Borders */
                    --border: #d1d5db;
                    --border-light: #e5e7eb;

                    /* Diff colors - Light theme */
                    --diff-insert-bg: rgba(22, 163, 74, 0.15);
                    --diff-replace-bg: rgba(217, 119, 6, 0.15);
                    --diff-delete-bg: rgba(220, 38, 38, 0.15);
                    --char-insert-bg: rgba(22, 163, 74, 0.35);
                    --char-replace-bg: rgba(217, 119, 6, 0.35);
                }
            }

            /* Apply theme colors to base elements */
            body {
                background: var(--bg-main) !important;
                color: var(--text-primary) !important;
            }
            .nicegui-content {
                background: var(--bg-main) !important;
            }

            /* Layout components */
            .app-header {
                background: var(--bg-elevated) !important;
                color: var(--text-primary) !important;
                border-bottom: 1px solid var(--border) !important;
            }
            .left-sidebar {
                background: var(--bg-main) !important;
                border-right: 1px solid var(--border) !important;
            }
            .right-sidebar {
                background: var(--bg-main) !important;
                border-left: 1px solid var(--border) !important;
            }
            .main-content {
                background: var(--bg-main) !important;
            }

            /* Query list items */
            .query-item {
                background: var(--bg-elevated) !important;
            }
            .query-item-selected {
                background: var(--bg-surface) !important;
            }
            .query-item-text {
                color: var(--text-primary) !important;
            }

            /* Span cards */
            .span-card {
                background: var(--bg-elevated) !important;
            }
            .span-card-text {
                color: var(--text-primary) !important;
            }
            .timing-badge {
                background: var(--bg-code) !important;
            }

            /* Pass expansion */
            .pass-expansion {
                background: var(--bg-elevated) !important;
            }

            /* Code blocks */
            .code-block {
                background: var(--bg-code) !important;
                border: 1px solid var(--border) !important;
                color: var(--text-primary) !important;
            }
            .code-block pre,
            .code-block code {
                background: transparent !important;
                color: inherit !important;
            }

            /* Mermaid diagrams */
            .mermaid-diagram {
                background: var(--bg-elevated) !important;
                border: 1px solid var(--border) !important;
            }

            /* Prism.js syntax highlighting - adapt to theme */
            @media (prefers-color-scheme: light) {
                /* Light theme syntax colors */
                .token.comment,
                .token.prolog,
                .token.doctype,
                .token.cdata {
                    color: #6b7280 !important; /* gray */
                }
                .token.punctuation {
                    color: #4b5563 !important; /* dark gray */
                }
                .token.property,
                .token.tag,
                .token.boolean,
                .token.number,
                .token.constant,
                .token.symbol,
                .token.deleted {
                    color: #dc2626 !important; /* red */
                }
                .token.selector,
                .token.attr-name,
                .token.string,
                .token.char,
                .token.builtin,
                .token.inserted {
                    color: #16a34a !important; /* green */
                }
                .token.operator,
                .token.entity,
                .token.url,
                .language-css .token.string,
                .style .token.string {
                    color: #d97706 !important; /* amber */
                }
                .token.atrule,
                .token.attr-value,
                .token.keyword {
                    color: #2563eb !important; /* blue */
                }
                .token.function,
                .token.class-name {
                    color: #7c3aed !important; /* purple */
                }
                .token.regex,
                .token.important,
                .token.variable {
                    color: #ea580c !important; /* orange */
                }
            }

            /* Dark theme keeps Prism's default dark theme colors */

            /* Improve scrollbar styling */
            ::-webkit-scrollbar {
                width: 10px;
                height: 10px;
            }
            ::-webkit-scrollbar-track {
                background: var(--bg-main);
            }
            ::-webkit-scrollbar-thumb {
                background: var(--bg-surface);
                border-radius: 5px;
            }
            ::-webkit-scrollbar-thumb:hover {
                background: var(--bg-hover);
            }

            /* Hide scrollbar for main content pane */
            .hide-scrollbar::-webkit-scrollbar {
                display: none;
            }

            /* Improve checkbox styling */
            .q-checkbox__bg {
                border-color: var(--border-light) !important;
            }
            .q-checkbox__svg {
                color: var(--accent-primary) !important;
            }

            /* Improve input styling */
            .q-field__control {
                background: var(--bg-code) !important;
                border-radius: 6px !important;
            }
            .q-field__native {
                color: var(--text-primary) !important;
            }

            /* Diff highlighting styles */
            .diff-code-block {
                width: 100%;
            }
            .diff-code-block pre {
                margin: 0;
                padding: 0;
                background: transparent !important;
            }
            .diff-code-block code {
                background: transparent !important;
            }
            .diff-line {
                display: flex;
                padding: 2px 0;
            }
            .diff-insert {
                background-color: var(--diff-insert-bg);
            }
            .diff-replace {
                background-color: var(--diff-replace-bg);
            }
            .diff-delete {
                background-color: var(--diff-delete-bg);
            }
            .diff-equal {
                background-color: transparent;
            }
            .line-prefix {
                width: 20px;
                text-align: center;
                margin-right: 8px;
                color: var(--text-tertiary);
                user-select: none;
                flex-shrink: 0;
            }
            .char-insert {
                background-color: var(--char-insert-bg);
                border-radius: 2px;
                padding: 0 2px;
            }
            .char-replace {
                background-color: var(--char-replace-bg);
                border-radius: 2px;
                padding: 0 2px;
            }
            .line-content {
                flex: 1;
                white-space: pre;
            }
        </style>
        <script>
        (function() {
            const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)');

            function handleThemeChange(e) {
                const isDark = e.matches;
                // Sync with Quasar's dark mode
                if (window.$q && window.$q.dark) {
                    window.$q.dark.set(isDark);
                }
            }

            mediaQuery.addEventListener('change', handleThemeChange);
            handleThemeChange(mediaQuery);  // Initial sync
        })();
        </script>
    """)

    with ui.column():
        with ui.row().style(
            "flex-wrap: wrap; "
            "position: fixed; "
            "top: 0; "
            "left: 0; "
            "right: 0; "
            "z-index: 1000; "
            "padding: 12px 16px; "
            "gap: 16px; "
            "box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3); "
            "align-items: center;"
        ).classes("app-header"):
            # Sidebar toggle
            ui.button(
                icon="chevron_right" if toggles["collapse_sidebar"] else "chevron_left",
                on_click=lambda: toggle("collapse_sidebar")
            ).props("flat round dense").style("color: var(--text-secondary);")

            # Title
            title_text = f"PyRel (v{VERSION})"
            if debug_file_2:
                title_text += " • Comparison Mode"
            ui.label(title_text).style(
                "font-size: 1.3em; font-weight: 700; color: #ff9e64; margin-right: 8px;"
            )

            # Compiler passes toggles
            checkboxes["v1"] = ui.switch("passes", value=toggles["v1"], on_change=lambda: toggle("v1")).style("font-size: 0.9em;")
            if not debug_file_2:
                checkboxes["pass_diff"] = ui.switch(
                    "∆ passes", value=toggles["pass_diff"], on_change=lambda: toggle("pass_diff")
                ).style("font-size: 0.9em;")
            checkboxes["v1_v0"] = ui.switch("v1→v0", value=toggles["v1_v0"], on_change=lambda: toggle("v1_v0")).style("font-size: 0.9em;")
            checkboxes["passes"] = (
                ui.switch("v0 passes", on_change=lambda: toggle("passes"))
                .props("id=passes_box")
                .style("font-size: 0.9em;")
            )
            checkboxes["backend"] = ui.switch("backend", value=toggles["backend"], on_change=lambda: toggle("backend")).style(
                "font-size: 0.9em;"
            )
            # DSL button hidden but functionality preserved
            # checkboxes["dsl"] = ui.switch("dsl", value=toggles["dsl"], on_change=lambda: toggle("dsl")).style("font-size: 0.9em;")

            # Stats toggle
            checkboxes["show_stats"] = ui.switch(
                "runtime", value=toggles["show_stats"], on_change=lambda: toggle("show_stats")
            ).style("font-size: 0.9em;")

            # Upload custom file — button opens a dialog
            with ui.dialog() as upload_dialog, ui.card().style(
                f"background: {THEME['bg_elevated']}; min-width: 400px;"
            ):
                ui.label("Upload debug file (.jsonl)").style(
                    f"font-weight: 600; color: {THEME['text_primary']}; margin-bottom: 8px;"
                )

                async def on_upload(e):
                    await handle_file_upload(e)
                    upload_dialog.close()

                ui.upload(
                    on_upload=on_upload,
                    auto_upload=True,
                    max_files=1,
                ).props('accept=".jsonl" flat').classes("w-full")

            ui.button(
                "Upload .jsonl", icon="upload_file", on_click=upload_dialog.open
            ).props("flat dense").style("font-size: 0.85em; color: var(--text-secondary);")
            file_indicator()

            ui.button(
                "Export backend", icon="download", on_click=export_backend_code
            ).props("flat dense").style("font-size: 0.85em; color: var(--text-secondary);")

        main_layout()

    ui.timer(1, poll)
    favicon_path = Path(__file__).parent / "favicon.png"
    ui.run(reload=False, host=host, port=port, title="PyRel Debugger",
           favicon=favicon_path if favicon_path.exists() else None)


if __name__ in {"__main__", "__mp_main__"}:
    import sys
    file2 = sys.argv[1] if len(sys.argv) > 1 else None
    main(file2=file2)
else:
    import sys
