"""PySide6 desktop GUI for Red Hat Oracle."""

from __future__ import annotations

import asyncio
import json
import logging
import queue
import sys
from typing import Any

from redhat_oracle.core.config import OracleConfig, load_config
from redhat_oracle.core.session import (
    ChatSession,
    list_sessions,
    load_latest_session,
    load_session,
    new_session,
    save_session,
)

logger = logging.getLogger(__name__)

# -- Red Hat brand palette --
_RH_RED = "#EE0000"
_RH_RED_HOVER = "#CC0000"
_RH_RED_DARK = "#A30000"

# Red Hat logo SVG (on-dark variant — white fedora + white wordmark)
_RH_LOGO_SVG = (
    '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 613 145" height="28" width="120">'
    '<path d="M127.47 83.49c12.51 0 30.61-2.58 30.61-17.46a14 14 0 0 0-.31-3.42l-7.45-32.36'
    "c-1.72-7.12-3.23-10.35-15.73-16.6C124.89 8.69 103.76.5 97.51.5 91.69.5 90 8 83.06 8"
    "c-6.68 0-11.64-5.6-17.89-5.6-6 0-9.91 4.09-12.93 12.5 0 0-8.41 23.72-9.49 27.16a6.43"
    " 6.43 0 0 0-.22 1.94c0 9.22 36.3 39.45 84.94 39.45M160 72.07c1.73 8.19 1.73 9.05 1.73"
    " 10.13 0 14-15.74 21.77-36.43 21.77-46.76.03-87.72-27.37-87.72-45.48a18.45 18.45 0 0 1"
    ' 1.51-7.33C22.27 52 .5 55 .5 74.22c0 31.48 74.59 70.28 133.65 70.28 45.28 0 56.7-20.48'
    ' 56.7-36.65 0-12.72-11-27.16-30.83-35.78" fill="#e00"/>'
    '<path d="M160 72.07c1.73 8.19 1.73 9.05 1.73 10.13 0 14-15.74 21.77-36.43 21.77-46.76'
    ".03-87.72-27.37-87.72-45.48a18.45 18.45 0 0 1 1.51-7.33l3.66-9.06a6.43 6.43 0 0 0-.22"
    " 1.9c0 9.22 36.3 39.45 84.94 39.45 12.51 0 30.61-2.58 30.61-17.46a14 14 0 0 0-.31-3.42"
    'Z"/>'
    '<path d="M579.74 92.8c0 11.89 7.15 17.67 20.19 17.67a52.11 52.11 0 0 0 11.89-1.68V95'
    "a24.84 24.84 0 0 1-7.68 1.16c-5.37 0-7.36-1.68-7.36-6.73V68.3h15.56V54.1h-15.56v-18"
    "l-17 3.68V54.1h-11.29v14.2h11.25Zm-53 .32c0-3.68 3.69-5.47 9.26-5.47a43.12 43.12 0 0"
    " 1 10.1 1.26v7.15a21.51 21.51 0 0 1-10.63 2.63c-5.46 0-8.73-2.1-8.73-5.57m5.2 17.56"
    "c6 0 10.84-1.26 15.36-4.31v3.37h16.82V74.08c0-13.56-9.14-21-24.39-21-8.52 0-16.94 2-26"
    " 6.1l6.1 12.52c6.52-2.74 12-4.42 16.83-4.42 7 0 10.62 2.73 10.62 8.31v2.73a49.53 49.53"
    " 0 0 0-12.62-1.58c-14.31 0-22.93 6-22.93 16.73 0 9.78 7.78 17.24 20.19 17.24m-92.44-.94"
    "h18.09V80.92h30.29v28.82H506V36.12h-18.07v28.29h-30.29V36.12h-18.09Zm-68.86-27.9c0-8"
    " 6.31-14.1 14.62-14.1A17.22 17.22 0 0 1 397 72.09v19.45A16.36 16.36 0 0 1 385.24 96"
    "c-8.2 0-14.62-6.1-14.62-14.09m26.61 27.87h16.83V32.44l-17 3.68v20.93a28.3 28.3 0 0"
    " 0-14.2-3.68c-16.19 0-28.92 12.51-28.92 28.5a28.25 28.25 0 0 0 28.4 28.6 25.12 25.12"
    " 0 0 0 14.93-4.83ZM320 67c5.36 0 9.88 3.47 11.67 8.83h-23.2C310.15 70.3 314.36 67 320"
    " 67m-28.67 15c0 16.2 13.25 28.82 30.28 28.82 9.36 0 16.2-2.53 23.25-8.42l-11.26-10"
    "c-2.63 2.74-6.52 4.21-11.14 4.21a14.39 14.39 0 0 1-13.68-8.83h39.65v-4.23c0-17.67"
    "-11.88-30.39-28.08-30.39a28.57 28.57 0 0 0-29 28.81M262 51.58c6 0 9.36 3.78 9.36 8.31"
    "S268 68.2 262 68.2h-17.89V51.58Zm-36 58.16h18.09V82.92h13.77l13.89 26.82H292l-16.2"
    '-29.45a22.27 22.27 0 0 0 13.88-20.72c0-13.25-10.41-23.45-26-23.45H226Z"'
    ' fill="#fff"/></svg>'
)

_FONT_STACK = (
    '"Red Hat Text", "Red Hat Display", "Liberation Sans", '
    '"Helvetica Neue", Arial, sans-serif'
)
_MONO_STACK = '"Red Hat Mono", "Liberation Mono", "Consolas", monospace'


def _build_stylesheet(
    bg: str, bg_card: str, bg_input: str, fg: str, fg_dim: str,
    border: str, accent: str, accent_hover: str, accent_dark: str,
) -> str:
    """Generate a Qt stylesheet from theme colours."""
    return f"""
    QMainWindow {{ background-color: {bg}; }}
    QWidget {{
        font-family: {_FONT_STACK};
        font-size: 13px;
        color: {fg};
    }}
    QMenuBar {{
        background-color: {bg};
        border-bottom: 3px solid {accent};
        padding: 4px 0;
        font-size: 13px;
    }}
    QMenuBar::item {{
        padding: 6px 14px;
        background: transparent;
        color: {fg};
    }}
    QMenuBar::item:selected {{ color: {accent}; }}
    QMenu {{
        background-color: {bg_card};
        border: 1px solid {border};
        color: {fg};
    }}
    QMenu::item {{ padding: 6px 24px; }}
    QMenu::item:selected {{
        background-color: {accent};
        color: #FFFFFF;
    }}
    QPushButton {{
        background-color: {accent};
        color: #FFFFFF;
        border: none;
        border-radius: 3px;
        padding: 7px 18px;
        font-weight: bold;
        font-size: 12px;
    }}
    QPushButton:hover {{ background-color: {accent_hover}; }}
    QPushButton:pressed {{ background-color: {accent_dark}; }}
    QPushButton:disabled {{ background-color: {border}; color: #666666; }}
    QLineEdit {{
        border: 1px solid {border};
        border-radius: 3px;
        padding: 7px 10px;
        background-color: {bg_input};
        color: {fg};
        font-size: 13px;
    }}
    QLineEdit:focus {{ border-color: {accent}; }}
    QComboBox {{
        border: 1px solid {border};
        border-radius: 3px;
        padding: 5px 10px;
        background-color: {bg_input};
        color: {fg};
    }}
    QComboBox:focus {{ border-color: {accent}; }}
    QComboBox::drop-down {{ border: none; width: 24px; }}
    QComboBox QAbstractItemView {{
        background-color: {bg_card};
        color: {fg};
        selection-background-color: {accent};
        selection-color: #FFFFFF;
    }}
    QTextEdit {{
        border: 1px solid {border};
        border-radius: 3px;
        background-color: {bg_input};
        color: {fg};
        font-family: {_MONO_STACK};
        font-size: 13px;
        padding: 8px;
    }}
    QTreeWidget {{
        border: 1px solid {border};
        border-radius: 3px;
        background-color: {bg_input};
        alternate-background-color: {bg_card};
        color: {fg};
    }}
    QTreeWidget::item {{ padding: 4px 0; }}
    QTreeWidget::item:selected {{
        background-color: {accent};
        color: #FFFFFF;
    }}
    QHeaderView::section {{
        background-color: {bg};
        color: {fg};
        padding: 5px 8px;
        border: none;
        border-bottom: 2px solid {accent};
        font-weight: bold;
        font-size: 12px;
    }}
    QSplitter::handle {{ background-color: {border}; width: 2px; }}
    QDockWidget {{
        font-size: 13px;
        color: {fg};
    }}
    QDockWidget::title {{
        background-color: {bg_card};
        padding: 6px 8px;
        border-bottom: 2px solid {accent};
        font-weight: bold;
    }}
    QStatusBar {{
        background-color: {bg};
        color: {fg_dim};
        font-size: 12px;
        border-top: 1px solid {border};
        padding: 2px 8px;
    }}
    QLabel {{ color: {fg}; }}
    QScrollBar:vertical {{
        background: {bg_card};
        width: 10px;
        border-radius: 5px;
    }}
    QScrollBar::handle:vertical {{
        background: {border};
        border-radius: 5px;
        min-height: 20px;
    }}
    QScrollBar::handle:vertical:hover {{ background: {fg_dim}; }}
    QScrollBar::add-line:vertical, QScrollBar::sub-line:vertical {{ height: 0; }}
    QScrollBar:horizontal {{
        background: {bg_card};
        height: 10px;
        border-radius: 5px;
    }}
    QScrollBar::handle:horizontal {{
        background: {border};
        border-radius: 5px;
        min-width: 20px;
    }}
    QScrollBar::handle:horizontal:hover {{ background: {fg_dim}; }}
    QScrollBar::add-line:horizontal, QScrollBar::sub-line:horizontal {{ width: 0; }}
    """


# Dark theme (default) — matches Red Hat website dark footer
_DARK_STYLESHEET = _build_stylesheet(
    bg="#1A1A1A", bg_card="#2A2A2A", bg_input="#222222",
    fg="#E0E0E0", fg_dim="#999999",
    border="#383838", accent=_RH_RED,
    accent_hover=_RH_RED_HOVER, accent_dark=_RH_RED_DARK,
)

# Light theme — matches Red Hat website light header
_LIGHT_STYLESHEET = _build_stylesheet(
    bg="#FFFFFF", bg_card="#F0F0F0", bg_input="#FFFFFF",
    fg="#151515", fg_dim="#666666",
    border="#D2D2D2", accent=_RH_RED,
    accent_hover=_RH_RED_HOVER, accent_dark=_RH_RED_DARK,
)

_THEMES = {"dark": _DARK_STYLESHEET, "light": _LIGHT_STYLESHEET}

# Per-theme values for inline HTML in chat display
_THEME_COLORS = {
    "dark": {"user": "#E0E0E0", "oracle": _RH_RED, "error": "#FF6666"},
    "light": {"user": "#151515", "oracle": _RH_RED, "error": _RH_RED},
}

# Per-theme header styling
_HEADER_STYLES = {
    "dark": {
        "bg": "#151515",
        "border": "#383838",
        "title": "#FFFFFF",
        "card": "#2A2A2A",
        "section_fg": "#FFFFFF",
        "label_fg": "#E0E0E0",
        "settings_bg": "#383838",
    },
    "light": {
        "bg": "#FFFFFF",
        "border": "#D2D2D2",
        "title": "#151515",
        "card": "#FFFFFF",
        "section_fg": "#151515",
        "label_fg": "#333333",
        "settings_bg": "#151515",
    },
}

# Source type color codes for D3 mind map
_SOURCE_COLORS = {
    "github": "#EE0000",
    "gitlab": "#FF8800",
    "jira": "#0066CC",
    "slack": "#9933CC",
    "web": "#00CCCC",
    "user": "#00AA00",
}

# Edge colors by relationship type
_REL_TYPE_COLORS = {
    "same_author": "#00AA00",
    "same_label": "#888888",
    "same_issue": "#0066CC",
    "same_repo": "#EE0000",
    "similar_content": "#FF8800",
}

# D3.js mind map HTML template
_D3_MIND_MAP_HTML = """<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<script src="https://d3js.org/d3.v7.min.js"></script>
<style>
  body {{ margin: 0; overflow: hidden; background: {bg}; }}
  svg {{ width: 100%; height: 100%; }}
  .node circle {{ stroke: #fff; stroke-width: 1.5px; cursor: pointer; }}
  .node text {{ font-family: sans-serif; font-size: 10px; fill: {fg}; pointer-events: none; }}
  .link {{ stroke: #666; stroke-opacity: 0.4; }}
  .tooltip {{
    position: absolute; padding: 6px 10px; background: {tooltip_bg};
    color: {fg}; border: 1px solid {border}; border-radius: 4px;
    font-size: 11px; pointer-events: none; display: none;
    max-width: 300px; z-index: 10;
  }}
</style>
</head>
<body>
<div class="tooltip" id="tooltip"></div>
<svg id="graph"></svg>
<script>
let simulation, svg, g, linkGroup, nodeGroup, zoom;
let allNodes = [];
let allLinks = [];
let nodeIndex = {{}};

const relTypeColors = {{
  "same_author": "#00AA00",
  "same_label": "#888888",
  "same_issue": "#0066CC",
  "same_repo": "#EE0000",
  "similar_content": "#FF8800"
}};

function getSize() {{
  return [window.innerWidth || 400, window.innerHeight || 400];
}}

function init() {{
  const [w, h] = getSize();
  svg = d3.select("#graph").attr("width", w).attr("height", h);

  zoom = d3.zoom()
    .scaleExtent([0.1, 4])
    .on("zoom", (e) => {{ g.attr("transform", e.transform); }});
  svg.call(zoom);

  g = svg.append("g");
  linkGroup = g.append("g").attr("class", "links");
  nodeGroup = g.append("g").attr("class", "nodes");

  simulation = d3.forceSimulation()
    .force("link", d3.forceLink().id(d => d.id).distance(80))
    .force("charge", d3.forceManyBody().strength(-120))
    .force("center", d3.forceCenter(0, 0))
    .force("collision", d3.forceCollide().radius(d => d.radius + 5));

  window.addEventListener("resize", () => {{
    const [rw, rh] = getSize();
    svg.attr("width", rw).attr("height", rh);
    zoomToFit();
  }});
}}

function zoomToFit(duration) {{
  const [w, h] = getSize();
  const nodes = simulation.nodes();
  if (!nodes.length) return;

  let x0 = Infinity, y0 = Infinity, x1 = -Infinity, y1 = -Infinity;
  nodes.forEach(d => {{
    const r = (d.radius || 8) + 20;
    if (d.x - r < x0) x0 = d.x - r;
    if (d.y - r < y0) y0 = d.y - r;
    if (d.x + r > x1) x1 = d.x + r;
    if (d.y + r > y1) y1 = d.y + r;
  }});

  const bw = x1 - x0 || 1;
  const bh = y1 - y0 || 1;
  const padding = 30;
  const scale = Math.min((w - padding * 2) / bw, (h - padding * 2) / bh, 2.0);
  const cx = (x0 + x1) / 2;
  const cy = (y0 + y1) / 2;
  const tx = w / 2 - cx * scale;
  const ty = h / 2 - cy * scale;

  const t = d3.zoomIdentity.translate(tx, ty).scale(scale);
  if (duration) {{
    svg.transition().duration(duration).call(zoom.transform, t);
  }} else {{
    svg.call(zoom.transform, t);
  }}
}}

function renderGraph() {{
  // --- Links ---
  const linkKey = d => {{
    const sid = typeof d.source === "object" ? d.source.id : d.source;
    const tid = typeof d.target === "object" ? d.target.id : d.target;
    return sid + "->" + tid;
  }};
  const link = linkGroup.selectAll("line").data(allLinks, linkKey);
  link.exit().remove();
  const linkEnter = link.enter().append("line").attr("class", "link")
    .attr("stroke-width", d => Math.max(1, (d.weight || 1)))
    .attr("stroke", d => relTypeColors[d.rel_type] || "#666")
    .attr("stroke-dasharray", d => d.rel_type === "similar_content" ? "5,3" : null)
    .attr("stroke-opacity", d => d.rel_type ? 0.6 : 0.4);
  const allLinksSel = linkEnter.merge(link);

  // Edge tooltips
  allLinksSel.on("mouseover", (e, d) => {{
    if (d.rel_type) {{
      const tip = document.getElementById("tooltip");
      tip.style.display = "block";
      tip.innerHTML = d.rel_type.replace(/_/g, " ");
      tip.style.left = (e.pageX + 10) + "px";
      tip.style.top = (e.pageY - 10) + "px";
    }}
  }}).on("mouseout", () => {{
    document.getElementById("tooltip").style.display = "none";
  }});

  // --- Nodes ---
  const node = nodeGroup.selectAll("g.node").data(allNodes, d => d.id);
  node.exit().remove();
  const nodeEnter = node.enter().append("g").attr("class", "node")
    .attr("opacity", 0)
    .call(d3.drag()
      .on("start", (e,d) => {{ if(!e.active) simulation.alphaTarget(0.3).restart(); d.fx=e.x; d.fy=e.y; }})
      .on("drag", (e,d) => {{ d.fx=e.x; d.fy=e.y; }})
      .on("end", (e,d) => {{ if(!e.active) simulation.alphaTarget(0); d.fx=null; d.fy=null; }})
    );
  nodeEnter.append("circle")
    .attr("r", d => d.radius || 8)
    .attr("fill", d => d.color || "#999")
    .attr("stroke-width", d => d.border_width || 1.5);
  nodeEnter.append("text").attr("dx", 12).attr("dy", 4);

  // Animate new nodes in
  nodeEnter.transition().duration(400).attr("opacity", d => d.opacity != null ? d.opacity : 1.0);

  const allNodesSel = nodeEnter.merge(node);
  allNodesSel.select("circle")
    .attr("r", d => d.radius || 8)
    .attr("fill", d => d.color || "#999");
  allNodesSel.select("text").text(d => d.label || d.id);

  // Node tooltips
  allNodesSel.on("mouseover", (e, d) => {{
    const tip = document.getElementById("tooltip");
    tip.style.display = "block";
    tip.innerHTML = d.tooltip || d.id;
    tip.style.left = (e.pageX + 10) + "px";
    tip.style.top = (e.pageY - 10) + "px";
  }}).on("mouseout", () => {{
    document.getElementById("tooltip").style.display = "none";
  }});

  simulation.nodes(allNodes).on("tick", () => {{
    allLinksSel
      .attr("x1", d => d.source.x).attr("y1", d => d.source.y)
      .attr("x2", d => d.target.x).attr("y2", d => d.target.y);
    allNodesSel.attr("transform", d => `translate(${{d.x}},${{d.y}})`);
  }});
  simulation.force("link").links(allLinks);
  simulation.alpha(1).restart();

  simulation.on("end", () => {{ zoomToFit(500); }});
  setTimeout(() => {{ zoomToFit(500); }}, 800);
}}

function updateGraph(data) {{
  // Full replacement for Level 1 results
  allNodes = data.nodes || [];
  allLinks = data.links || [];
  nodeIndex = {{}};
  allNodes.forEach(n => {{ nodeIndex[n.id] = n; }});
  renderGraph();
}}

function addGraphDelta(data) {{
  // Incremental merge for Level 2/3 expansion
  const newNodes = data.nodes || [];
  const newLinks = data.links || [];

  newNodes.forEach(n => {{
    if (!nodeIndex[n.id]) {{
      nodeIndex[n.id] = n;
      allNodes.push(n);
    }}
  }});

  const linkSet = new Set(allLinks.map(l => {{
    const sid = typeof l.source === "object" ? l.source.id : l.source;
    const tid = typeof l.target === "object" ? l.target.id : l.target;
    return sid + "->" + tid;
  }}));

  newLinks.forEach(l => {{
    const key = l.source + "->" + l.target;
    if (!linkSet.has(key)) {{
      // Only add links where both endpoints exist
      if (nodeIndex[l.source] && nodeIndex[l.target]) {{
        allLinks.push(l);
        linkSet.add(key);
      }}
    }}
  }});

  renderGraph();
}}

init();
</script>
</body>
</html>"""


def _import_qt() -> Any:
    """Import PySide6 modules, raising a clear error if not installed."""
    try:
        from PySide6 import QtCore, QtGui, QtWidgets  # type: ignore[import-not-found]

        return QtCore, QtGui, QtWidgets
    except ImportError as e:
        raise RuntimeError(
            "PySide6 is not installed. Install with: pip install redhat-oracle[gui]"
        ) from e


def _import_webengine() -> Any:
    """Try to import PySide6 WebEngine for D3 mind map."""
    try:
        from PySide6.QtWebEngineWidgets import QWebEngineView  # type: ignore[import-not-found]

        return QWebEngineView
    except ImportError:
        return None


def _escape_html(text: str) -> str:
    """Escape HTML special characters for display in QTextEdit."""
    import re as _re

    escaped = (
        text.replace("&", "&amp;")
        .replace("<", "&lt;")
        .replace(">", "&gt;")
        .replace("\n", "<br>")
    )
    # Convert URLs to clickable links
    escaped = _re.sub(
        r'(https?://[^\s<>&]+)',
        r'<a href="\1" style="color: #4FC3F7;">\1</a>',
        escaped,
    )
    return escaped


class AsyncWorker:
    """Runs async coroutines in a background thread for the Qt event loop.

    Supports both blocking (run) and non-blocking (submit) patterns.
    The non-blocking submit() uses a thread-safe queue polled by a QTimer
    to deliver results back to the main thread without freezing the GUI.
    """

    def __init__(self) -> None:
        self._loop: asyncio.AbstractEventLoop | None = None
        self._thread: Any = None  # QThread
        self._pending: queue.Queue[tuple[Any, Any]] = queue.Queue()
        self._poll_timer: Any = None  # QTimer

    def start(self, qt_core: Any) -> None:
        """Start the background event loop thread."""
        worker = self

        class _LoopThread(qt_core.QThread):  # type: ignore[misc]
            def run(self_thread: Any) -> None:
                loop = asyncio.new_event_loop()
                asyncio.set_event_loop(loop)
                worker._loop = loop
                loop.run_forever()

        self._thread = _LoopThread()
        self._thread.start()
        import time

        for _ in range(100):
            if self._loop is not None:
                break
            time.sleep(0.01)

        # Poll for completed async results on the main thread
        self._poll_timer = qt_core.QTimer()
        self._poll_timer.timeout.connect(self._process_pending)
        self._poll_timer.start(50)

    def _process_pending(self) -> None:
        """Process completed async results on the main thread."""
        while not self._pending.empty():
            callback, arg = self._pending.get_nowait()
            callback(arg)

    def run(self, coro: Any) -> Any:
        """Submit a coroutine to the background loop and wait for result."""
        if self._loop is None:
            raise RuntimeError("AsyncWorker not started")
        future = asyncio.run_coroutine_threadsafe(coro, self._loop)
        return future.result(timeout=300)

    def submit(
        self,
        coro: Any,
        on_result: Any,
        on_error: Any,
    ) -> None:
        """Submit a coroutine and call back on the main thread when done.

        Args:
            coro: The coroutine to run.
            on_result: Callable receiving the result, invoked on the main thread.
            on_error: Callable receiving the exception, invoked on the main thread.
        """
        if self._loop is None:
            raise RuntimeError("AsyncWorker not started")

        future = asyncio.run_coroutine_threadsafe(coro, self._loop)

        def _on_done(fut: Any) -> None:
            try:
                result = fut.result()
                self._pending.put((on_result, result))
            except Exception as exc:
                self._pending.put((on_error, exc))

        future.add_done_callback(_on_done)

    def stop(self) -> None:
        """Stop the background event loop."""
        if self._poll_timer is not None:
            self._poll_timer.stop()
        if self._loop is not None:
            self._loop.call_soon_threadsafe(self._loop.stop)
        if self._thread is not None:
            self._thread.wait(5000)


class SessionManager:
    """Manages chat session lifecycle and combo box sync."""

    def __init__(self, config: OracleConfig, qt_widgets: Any) -> None:
        self._config = config
        self._qt_widgets = qt_widgets
        self.session: ChatSession | None = None
        self.combo: Any = None  # QComboBox

    def init_widgets(self, combo: Any) -> None:
        """Attach the combo box widget."""
        self.combo = combo

    def new(self) -> ChatSession:
        """Create and return a new session."""
        self.session = new_session(self._config)
        self.refresh_list()
        return self.session

    def load(self, name: str) -> ChatSession | None:
        """Load a session by name."""
        session = load_session(name, self._config)
        if session:
            self.session = session
        return session

    def load_latest(self) -> ChatSession | None:
        """Load the most recent session."""
        session = load_latest_session(self._config)
        if session:
            self.session = session
        return session

    def save(self) -> None:
        """Save the current session."""
        if self.session:
            save_session(self.session, self._config)
            self.refresh_list()

    def refresh_list(self) -> None:
        """Refresh the combo box with available sessions."""
        if self.combo is None:
            return
        self.combo.clear()
        for name in list_sessions(self._config):
            self.combo.addItem(name)
        if self.session and self.session.name:
            idx = self.combo.findText(self.session.name)
            if idx >= 0:
                self.combo.setCurrentIndex(idx)


class MindMapPanel:
    """D3.js force-directed knowledge graph embedded in QWebEngineView."""

    def __init__(self) -> None:
        self.web_view: Any = None  # QWebEngineView or None
        self._available = False
        self._theme = "dark"

    def init_widget(self, parent: Any, qt_widgets: Any = None) -> Any:
        """Create and return the web view widget, or a fallback label."""
        QWebEngineView = _import_webengine()
        if QWebEngineView is None:
            if qt_widgets is None:
                from PySide6 import QtWidgets  # type: ignore[import-not-found]
                qt_widgets = QtWidgets

            label = qt_widgets.QLabel(
                "Install PySide6-WebEngine for the Knowledge Map.\n"
                "pip install PySide6-WebEngine"
            )
            label.setWordWrap(True)
            return label

        self.web_view = QWebEngineView(parent)
        self._available = True
        self._load_html()
        return self.web_view

    def _load_html(self) -> None:
        """Load the D3 mind map HTML into the web view."""
        if not self.web_view:
            return
        s = _HEADER_STYLES[self._theme]
        html = _D3_MIND_MAP_HTML.format(
            bg=s["bg"],
            fg=s["section_fg"],
            tooltip_bg=s["card"],
            border=s["border"],
        )
        self.web_view.setHtml(html)

    def set_theme(self, theme: str) -> None:
        """Update the theme and reload HTML."""
        self._theme = theme
        self._load_html()

    def update_graph(self, sources: list[dict[str, Any]]) -> None:
        """Build and render a force graph from search result metadata."""
        if not self._available or not self.web_view:
            return

        nodes: dict[str, dict[str, Any]] = {}
        links: list[dict[str, Any]] = []
        seen_edges: set[str] = set()

        for result in sources:
            meta = result.get("metadata", result)
            score = result.get("score", 0.5)
            source_type = meta.get("source", "unknown")
            color = _SOURCE_COLORS.get(source_type, "#999999")

            # File / content node
            path = meta.get("path", "")
            org = meta.get("org", "")
            repo = meta.get("repo", "")
            content_type = meta.get("content_type", "")
            jira_id = meta.get("jira_issue_id", "")

            if jira_id:
                node_id = f"jira:{jira_id}"
                label = jira_id
                status = meta.get("status", "")
                priority = meta.get("priority", "")
                border_w = 3 if priority in ("Critical", "Blocker") else 1.5
                tooltip = f"{jira_id}<br>Status: {status}<br>Priority: {priority}"
            elif path:
                node_id = f"{source_type}:{org}/{repo}/{path}"
                label = path.split("/")[-1] if "/" in path else path
                tooltip = f"{org}/{repo}/{path}<br>Type: {content_type or 'code'}"
                border_w = 1.5
            else:
                continue

            radius = max(5, min(20, score * 20))
            nodes[node_id] = {
                "id": node_id,
                "label": label,
                "color": color,
                "radius": radius,
                "border_width": border_w,
                "tooltip": tooltip,
            }

            # Author node
            author = meta.get("author", "")
            if author:
                author_id = f"user:{author}"
                if author_id not in nodes:
                    nodes[author_id] = {
                        "id": author_id,
                        "label": author,
                        "color": _SOURCE_COLORS["user"],
                        "radius": 6,
                        "border_width": 1.5,
                        "tooltip": f"Author: {author}",
                    }
                edge_key = f"{author_id}->{node_id}"
                if edge_key not in seen_edges:
                    links.append({"source": author_id, "target": node_id, "weight": 1})
                    seen_edges.add(edge_key)

            # Label-based edges
            labels_str = meta.get("labels", "")
            if labels_str:
                label_list = [label.strip() for label in labels_str.split(",") if label.strip()]
                for lbl in label_list:
                    lbl_id = f"label:{lbl}"
                    if lbl_id not in nodes:
                        nodes[lbl_id] = {
                            "id": lbl_id,
                            "label": lbl,
                            "color": "#888888",
                            "radius": 5,
                            "border_width": 1,
                            "tooltip": f"Label: {lbl}",
                        }
                    edge_key = f"{node_id}->{lbl_id}"
                    if edge_key not in seen_edges:
                        links.append({"source": node_id, "target": lbl_id, "weight": 1})
                        seen_edges.add(edge_key)

            # Repo grouping edges
            if repo:
                repo_id = f"repo:{org}/{repo}"
                if repo_id not in nodes:
                    nodes[repo_id] = {
                        "id": repo_id,
                        "label": repo,
                        "color": color,
                        "radius": 10,
                        "border_width": 2,
                        "tooltip": f"Repository: {org}/{repo}",
                    }
                edge_key = f"{repo_id}->{node_id}"
                if edge_key not in seen_edges:
                    links.append({"source": repo_id, "target": node_id, "weight": 1})
                    seen_edges.add(edge_key)

        graph_data = {
            "nodes": list(nodes.values()),
            "links": links,
        }

        js = f"updateGraph({json.dumps(graph_data)});"
        self.web_view.page().runJavaScript(js)

    def add_graph_delta(self, delta: dict[str, Any]) -> None:
        """Incrementally add nodes and links to the existing graph."""
        if not self._available or not self.web_view:
            return
        js = f"addGraphDelta({json.dumps(delta)});"
        self.web_view.page().runJavaScript(js)


class GraphExpander:
    """Orchestrates multi-level relationship discovery for the knowledge graph.

    After the initial Level 1 search results are displayed, this class
    expands the graph by following metadata relationships (same author,
    same labels, same Jira issue, same repo, semantic similarity) to
    discover connected information across Level 2 and Level 3.
    """

    # Breadth per relationship type at each level
    _BREADTH = {2: 3, 3: 2}

    def __init__(self, config: OracleConfig, max_nodes: int = 150) -> None:
        self._config = config
        self._max_nodes = max_nodes
        self._store: Any = None  # VectorStore, lazily set
        self._expanded_ids: set[str] = set()
        self._all_node_ids: set[str] = set()

    def set_store(self, store: Any) -> None:
        """Set the shared VectorStore instance."""
        self._store = store

    def reset(self) -> None:
        """Clear state for a new query."""
        self._expanded_ids = set()
        self._all_node_ids = set()

    @staticmethod
    def make_node_id(result: dict[str, Any]) -> str:
        """Generate a stable node ID from a search result."""
        meta = result.get("metadata", result)
        jira_id = meta.get("jira_issue_id", "")
        if jira_id:
            return f"jira:{jira_id}"
        source = meta.get("source", "unknown")
        org = meta.get("org", "")
        repo = meta.get("repo", "")
        path = meta.get("path", "")
        if path:
            return f"{source}:{org}/{repo}/{path}"
        return f"{source}:{result.get('id', '')}"

    def register_initial_nodes(self, sources: list[dict[str, Any]]) -> None:
        """Record Level 1 node IDs to avoid duplication."""
        for src in sources:
            node_id = self.make_node_id(src)
            self._all_node_ids.add(node_id)

    def _make_graph_node(
        self, result: dict[str, Any], level: int,
    ) -> dict[str, Any]:
        """Build a D3 node dict from a search result."""
        meta = result.get("metadata", result)
        source_type = meta.get("source", "unknown")
        color = _SOURCE_COLORS.get(source_type, "#999999")
        score = result.get("score", 0.5)
        jira_id = meta.get("jira_issue_id", "")
        path = meta.get("path", "")
        org = meta.get("org", "")
        repo = meta.get("repo", "")
        opacity = {1: 1.0, 2: 0.7, 3: 0.4}.get(level, 0.4)

        if jira_id:
            node_id = f"jira:{jira_id}"
            label = jira_id
            status = meta.get("status", "")
            priority = meta.get("priority", "")
            border_w = 3 if priority in ("Critical", "Blocker") else 1.5
            tooltip = f"{jira_id}<br>Status: {status}<br>Priority: {priority}"
        elif path:
            node_id = f"{source_type}:{org}/{repo}/{path}"
            label = path.split("/")[-1] if "/" in path else path
            content_type = meta.get("content_type", "")
            tooltip = f"{org}/{repo}/{path}<br>Type: {content_type or 'code'}"
            border_w = 1.5
        else:
            node_id = self.make_node_id(result)
            label = node_id.split(":")[-1][:30]
            tooltip = node_id
            border_w = 1.5

        # Smaller nodes for deeper levels
        base_radius = max(5, min(20, score * 20))
        radius = base_radius * (1.0 if level == 1 else 0.7 if level == 2 else 0.5)

        return {
            "id": node_id,
            "label": label,
            "color": color,
            "radius": radius,
            "border_width": border_w,
            "tooltip": tooltip,
            "opacity": opacity,
            "level": level,
        }

    def expand_level(
        self,
        sources: list[dict[str, Any]],
        level: int,
    ) -> dict[str, Any]:
        """Expand the graph by one level following metadata relationships.

        Args:
            sources: Source results to expand from (Level 1 or previous new_sources).
            level: The expansion level (2 or 3).

        Returns:
            Dict with nodes, links, level, new_sources, total_nodes.
        """
        if self._store is None:
            return {
                "nodes": [], "links": [], "level": level,
                "new_sources": [], "total_nodes": len(self._all_node_ids),
            }

        breadth = self._BREADTH.get(level, 2)
        new_nodes: list[dict[str, Any]] = []
        new_links: list[dict[str, Any]] = []
        new_sources: list[dict[str, Any]] = []

        for src in sources:
            src_node_id = self.make_node_id(src)
            if src_node_id in self._expanded_ids:
                continue
            self._expanded_ids.add(src_node_id)

            if len(self._all_node_ids) >= self._max_nodes:
                break

            meta = src.get("metadata", src)
            discovered = self._discover_related(meta, breadth)

            for rel_type, results in discovered.items():
                for result in results:
                    result_node_id = self.make_node_id(result)
                    if result_node_id in self._all_node_ids:
                        # Still add a link if both nodes exist
                        new_links.append({
                            "source": src_node_id,
                            "target": result_node_id,
                            "weight": 1,
                            "rel_type": rel_type,
                        })
                        continue

                    if len(self._all_node_ids) >= self._max_nodes:
                        break

                    self._all_node_ids.add(result_node_id)
                    node = self._make_graph_node(result, level)
                    new_nodes.append(node)
                    new_links.append({
                        "source": src_node_id,
                        "target": result_node_id,
                        "weight": 1,
                        "rel_type": rel_type,
                    })
                    new_sources.append(result)

        return {
            "nodes": new_nodes,
            "links": new_links,
            "level": level,
            "new_sources": new_sources,
            "total_nodes": len(self._all_node_ids),
        }

    def _discover_related(
        self,
        meta: dict[str, Any],
        breadth: int,
    ) -> dict[str, list[dict[str, Any]]]:
        """Query all relationship types for a single source node."""
        discovered: dict[str, list[dict[str, Any]]] = {}

        # 1. Same author
        author = meta.get("author", "")
        if author:
            results = self._store.search_by_metadata(
                "author", author, top_k=breadth,
            )
            if results:
                discovered["same_author"] = results[:breadth]

        # 2. Same labels
        labels_str = meta.get("labels", "")
        if labels_str:
            label_list = [lb.strip() for lb in labels_str.split(",") if lb.strip()]
            label_results: list[dict[str, Any]] = []
            seen: set[str] = set()
            for lbl in label_list:
                for r in self._store.search_by_metadata(
                    "labels", lbl, top_k=breadth,
                ):
                    rid = r.get("id", "")
                    if rid not in seen:
                        seen.add(rid)
                        label_results.append(r)
            if label_results:
                discovered["same_label"] = label_results[:breadth]

        # 3. Same Jira issue
        jira_id = meta.get("jira_issue_id", "")
        if jira_id:
            results = self._store.search_by_metadata(
                "jira_issue_id", jira_id, top_k=breadth,
            )
            if results:
                discovered["same_issue"] = results[:breadth]

        # 4. Same repo — semantic search filtered to same org/repo
        org = meta.get("org", "")
        repo = meta.get("repo", "")
        text = meta.get("text", "")
        if not text:
            # Fall back to path as query hint
            text = meta.get("path", "")

        if org and repo and text:
            source_type = meta.get("source", "")
            if source_type in ("github", "gitlab"):
                try:
                    results = self._store.search(
                        text[:200], source=source_type,
                        top_k=breadth, min_score=0.3,
                    )
                    repo_results = [
                        r for r in results
                        if r.get("metadata", {}).get("org") == org
                        and r.get("metadata", {}).get("repo") == repo
                    ]
                    if repo_results:
                        discovered["same_repo"] = repo_results[:breadth]
                except Exception:
                    pass

        # 5. Semantic similarity — cross-collection
        if text:
            try:
                results = self._store.search(
                    text[:200], top_k=breadth, min_score=0.3,
                )
                if results:
                    discovered["similar_content"] = results[:breadth]
            except Exception:
                pass

        return discovered


class ChatPanel:
    """Handles chat display, user input, and message sending."""

    def __init__(
        self,
        config: OracleConfig,
        session_mgr: SessionManager,
        async_worker: AsyncWorker,
    ) -> None:
        self._config = config
        self._session_mgr = session_mgr
        self._async_worker = async_worker
        self._theme = "dark"
        self.display: Any = None  # QTextEdit
        self.input_field: Any = None  # QLineEdit
        self.send_button: Any = None  # QPushButton
        self.status_bar: Any = None  # QStatusBar
        self.mind_map: MindMapPanel | None = None
        self._graph_expander = GraphExpander(config)
        self._store: Any = None  # Lazily created VectorStore

    def _get_store(self) -> Any:
        """Lazily create and return the shared VectorStore."""
        if self._store is None:
            from redhat_oracle.core.vectordb.store import VectorStore

            self._store = VectorStore(self._config)
        return self._store

    def render_session(self) -> None:
        """Render all messages in the current session."""
        self.display.clear()
        session = self._session_mgr.session
        if not session:
            return
        colors = _THEME_COLORS[self._theme]
        for msg in session.messages:
            if msg.role == "user":
                self.display.append(
                    f'<b style="color: {colors["user"]};">You:</b> '
                    f"{_escape_html(msg.content)}"
                )
            else:
                self.display.append(
                    f'<b style="color: {colors["oracle"]};">Oracle:</b> '
                    f"{_escape_html(msg.content)}"
                )
            self.display.append("")

    def send_message(self) -> None:
        """Send user input and dispatch the query without blocking the GUI."""
        text = self.input_field.text().strip()
        if not text:
            return

        if not self._session_mgr.session:
            self._session_mgr.new()

        session = self._session_mgr.session
        assert session is not None
        colors = _THEME_COLORS[self._theme]

        self.input_field.clear()
        session.add_message("user", text)
        self.display.append(
            f'<b style="color: {colors["user"]};">You:</b> {_escape_html(text)}'
        )
        self.display.append("")
        self.send_button.setEnabled(False)
        self.input_field.setEnabled(False)
        self.status_bar.showMessage("Searching knowledge base...")
        self._graph_expander.reset()

        def _on_result(result: Any) -> None:
            response_text, search_results = result
            session.add_message("assistant", response_text)
            self.display.append(
                f'<b style="color: {colors["oracle"]};">Oracle:</b> '
                f"{_escape_html(response_text)}"
            )
            self.display.append("")
            self._session_mgr.save()
            self.send_button.setEnabled(True)
            self.input_field.setEnabled(True)
            self.input_field.setFocus()

            # Update mind map with Level 1 results
            if self.mind_map and search_results:
                self.mind_map.update_graph(search_results)
                self._graph_expander.register_initial_nodes(search_results)

                # Start Level 2 expansion
                self.status_bar.showMessage("Expanding graph: Level 2...")
                self._graph_expander.set_store(self._get_store())
                self._async_worker.submit(
                    self._expand(search_results, 2),
                    on_result=self._on_expansion_result,
                    on_error=self._on_expansion_error,
                )
            else:
                self.status_bar.showMessage("Ready")

        def _on_error(exc: Exception) -> None:
            self.display.append(
                f'<i style="color: {colors["error"]};">'
                f"Error: {_escape_html(str(exc))}</i>"
            )
            self.status_bar.showMessage(f"Error: {exc}")
            self.send_button.setEnabled(True)
            self.input_field.setEnabled(True)
            self.input_field.setFocus()

        self._async_worker.submit(
            self._query(text),
            on_result=_on_result,
            on_error=_on_error,
        )

    def _on_expansion_result(self, delta: dict[str, Any]) -> None:
        """Handle a completed graph expansion level."""
        if self.mind_map and delta.get("nodes"):
            self.mind_map.add_graph_delta(delta)

        level = delta.get("level", 2)
        total = delta.get("total_nodes", 0)
        new_sources = delta.get("new_sources", [])

        if level == 2 and new_sources:
            self.status_bar.showMessage("Expanding graph: Level 3...")
            self._async_worker.submit(
                self._expand(new_sources, 3),
                on_result=self._on_expansion_result,
                on_error=self._on_expansion_error,
            )
        else:
            self.status_bar.showMessage(
                f"Ready \u2014 graph: {total} nodes"
            )

    def _on_expansion_error(self, exc: Exception) -> None:
        """Handle graph expansion errors gracefully."""
        logger.warning("Graph expansion error: %s", exc)
        self.status_bar.showMessage("Ready")

    async def _expand(
        self,
        sources: list[dict[str, Any]],
        level: int,
    ) -> dict[str, Any]:
        """Run graph expansion in the async worker thread."""
        return self._graph_expander.expand_level(sources, level)

    async def _query(self, question: str) -> tuple[str, list[dict[str, Any]]]:
        """Run a RAG query asynchronously.

        Returns:
            Tuple of (response_text, search_results with full metadata).
        """
        from redhat_oracle.core.llm.ollama import OllamaLLM
        from redhat_oracle.core.rag import RAGPipeline

        llm_config = self._config.llm
        llm = OllamaLLM(
            base_url=llm_config.ollama_base_url,
            model=llm_config.ollama_model,
        )
        store = self._get_store()
        pipeline = RAGPipeline(self._config, store, llm)
        result = await pipeline.query(question, session=self._session_mgr.session)

        # Return full search results (with text + metadata) for graph expansion
        search_results = result.search_results if result.search_results else result.sources
        return result.response, search_results


class SourcePanel:
    """Handles source stats display and settings dialog."""

    def __init__(self, config: OracleConfig, qt_widgets: Any) -> None:
        self._config = config
        self._qt_widgets = qt_widgets
        self.source_list: Any = None  # QTreeWidget
        self.status_bar: Any = None  # QStatusBar
        self.parent_window: Any = None  # QMainWindow

    def refresh_stats(self) -> None:
        """Refresh collection stats in the tree widget."""
        try:
            from redhat_oracle.core.vectordb.store import VectorStore

            store = VectorStore(self._config)
            stats = store.collection_stats()

            self.source_list.clear()
            total = 0
            for name, count in stats.items():
                item = self._qt_widgets.QTreeWidgetItem([name, str(count)])
                self.source_list.addTopLevelItem(item)
                total += count

            total_item = self._qt_widgets.QTreeWidgetItem(["Total", str(total)])
            self.source_list.addTopLevelItem(total_item)
            self.status_bar.showMessage(f"Stats refreshed: {total} total chunks")
        except Exception as e:
            self.status_bar.showMessage(f"Error refreshing stats: {e}")

    def show_settings(self) -> None:
        """Show a settings dialog."""
        import yaml

        QtWidgets = self._qt_widgets
        dialog = QtWidgets.QDialog(self.parent_window)
        dialog.setWindowTitle("Settings")
        dialog.resize(500, 400)

        layout = QtWidgets.QVBoxLayout(dialog)

        config_text = yaml.dump(self._config.model_dump(), default_flow_style=False)
        text_edit = QtWidgets.QTextEdit()
        text_edit.setPlainText(config_text)
        text_edit.setReadOnly(True)
        layout.addWidget(text_edit)

        info = QtWidgets.QLabel(
            f"Provider: {self._config.llm.default_provider}\n"
            f"Embedding: {self._config.vectordb.embeddings.model}\n"
            f"Min score: {self._config.rag.min_score}"
        )
        layout.addWidget(info)

        close_btn = QtWidgets.QPushButton("Close")
        close_btn.clicked.connect(dialog.accept)
        layout.addWidget(close_btn)
        dialog.exec()


class OracleGUI:
    """Top-level GUI coordinator — assembles panels and runs the app."""

    def __init__(self, config: OracleConfig | None = None) -> None:
        self._qt_core, self._qt_gui, self._qt_widgets = _import_qt()
        self._config = config or load_config()
        self._async_worker = AsyncWorker()

        self._session_mgr = SessionManager(self._config, self._qt_widgets)
        self._chat_panel = ChatPanel(self._config, self._session_mgr, self._async_worker)
        self._source_panel = SourcePanel(self._config, self._qt_widgets)
        self._mind_map = MindMapPanel()
        self._chat_panel.mind_map = self._mind_map

        self._app: Any = None
        self._window: Any = None
        self._theme = "dark"

        # Widgets that need per-theme inline styling
        self._header: Any = None
        self._title_label: Any = None
        self._src_widget: Any = None
        self._chat_widget: Any = None
        self._content: Any = None
        self._src_label: Any = None
        self._session_label: Any = None
        self._settings_btn: Any = None
        self._accent: Any = None
        self._left_dock: Any = None
        self._right_dock: Any = None

    def _apply_theme(self) -> None:
        """Apply the current theme to the window and inline-styled widgets."""
        self._window.setStyleSheet(_THEMES[self._theme])
        s = _HEADER_STYLES[self._theme]

        self._accent.setStyleSheet(f"background-color: {_RH_RED};")
        self._header.setStyleSheet(
            f"background-color: {s['bg']}; border-bottom: 1px solid {s['border']};"
        )
        self._title_label.setStyleSheet(
            f"font-size: 18px; font-weight: bold; color: {s['title']};"
        )
        self._chat_widget.setStyleSheet(
            f"background-color: {s['card']}; border-radius: 4px;"
        )
        self._session_label.setStyleSheet(
            f"font-weight: bold; color: {s['label_fg']};"
        )
        self._settings_btn.setStyleSheet(
            f"background-color: {s['settings_bg']}; color: #FFFFFF;"
        )
        self._chat_panel._theme = self._theme
        self._mind_map.set_theme(self._theme)

    def _toggle_theme(self) -> None:
        """Switch between dark and light themes."""
        self._theme = "light" if self._theme == "dark" else "dark"
        self._apply_theme()
        self._chat_panel.render_session()

    def _build_ui(self) -> None:
        """Build the main window with dock layout and wire up panels."""
        W = self._qt_widgets
        C = self._qt_core

        self._window = W.QMainWindow()
        self._window.setWindowTitle("Red Hat Oracle")
        self._window.resize(1400, 800)

        # --- Central Widget: Header + Chat ---
        central = W.QWidget()
        central_layout = W.QVBoxLayout(central)
        central_layout.setContentsMargins(0, 0, 0, 0)
        central_layout.setSpacing(0)

        # Red accent bar at top
        self._accent = W.QFrame()
        self._accent.setFixedHeight(4)
        central_layout.addWidget(self._accent)

        # Header bar with logo
        self._header = W.QWidget()
        header_layout = W.QHBoxLayout(self._header)
        header_layout.setContentsMargins(16, 8, 16, 8)

        logo_label = W.QLabel()
        try:
            from PySide6.QtCore import QByteArray  # type: ignore[import-not-found]
            from PySide6.QtGui import (  # type: ignore[import-not-found]
                QColor,
                QImage,
                QPainter,
                QPixmap,
            )
            from PySide6.QtSvg import QSvgRenderer  # type: ignore[import-not-found]

            svg_data = QByteArray(_RH_LOGO_SVG.encode("utf-8"))
            renderer = QSvgRenderer(svg_data)
            if renderer.isValid():
                img = QImage(
                    renderer.defaultSize(),
                    QImage.Format.Format_ARGB32_Premultiplied,
                )
                img.fill(QColor(0, 0, 0, 0))
                painter = QPainter(img)
                renderer.render(painter)
                painter.end()
                logo_label.setPixmap(QPixmap.fromImage(img))
            else:
                logo_label.setText("Red Hat")
        except Exception:
            logo_label.setText("Red Hat")
        header_layout.addWidget(logo_label)

        self._title_label = W.QLabel("  Oracle")
        header_layout.addWidget(self._title_label)
        header_layout.addStretch()

        central_layout.addWidget(self._header)

        # Chat panel (central content)
        self._chat_widget = W.QWidget()
        chat_layout = W.QVBoxLayout(self._chat_widget)
        chat_layout.setContentsMargins(12, 12, 12, 12)

        # Session bar
        session_bar = W.QHBoxLayout()
        self._session_label = W.QLabel("Session:")
        session_bar.addWidget(self._session_label)
        combo = W.QComboBox()
        combo.setMinimumWidth(200)
        self._session_mgr.init_widgets(combo)
        session_bar.addWidget(combo)

        new_btn = W.QPushButton("New")
        new_btn.clicked.connect(self._on_new_session)
        load_btn = W.QPushButton("Load")
        load_btn.clicked.connect(self._on_load_session)
        session_bar.addWidget(new_btn)
        session_bar.addWidget(load_btn)
        session_bar.addStretch()

        self._settings_btn = W.QPushButton("Settings")
        self._settings_btn.clicked.connect(self._source_panel.show_settings)
        session_bar.addWidget(self._settings_btn)
        chat_layout.addLayout(session_bar)

        # Chat display — QTextBrowser supports clickable links
        self._chat_panel.display = W.QTextBrowser()
        self._chat_panel.display.setOpenExternalLinks(True)
        chat_layout.addWidget(self._chat_panel.display)

        # Input bar
        input_layout = W.QHBoxLayout()
        self._chat_panel.input_field = W.QLineEdit()
        self._chat_panel.input_field.setPlaceholderText("Ask the Oracle...")
        self._chat_panel.input_field.returnPressed.connect(self._chat_panel.send_message)
        self._chat_panel.send_button = W.QPushButton("Send")
        self._chat_panel.send_button.clicked.connect(self._chat_panel.send_message)
        input_layout.addWidget(self._chat_panel.input_field)
        input_layout.addWidget(self._chat_panel.send_button)
        chat_layout.addLayout(input_layout)

        central_layout.addWidget(self._chat_widget, 1)
        self._window.setCentralWidget(central)

        # --- Left Dock: Sources & Stats (collapsible) ---
        self._left_dock = W.QDockWidget("Sources && Stats", self._window)
        self._left_dock.setAllowedAreas(
            C.Qt.LeftDockWidgetArea | C.Qt.RightDockWidgetArea
        )

        self._src_widget = W.QWidget()
        src_layout = W.QVBoxLayout(self._src_widget)
        src_layout.setContentsMargins(12, 12, 12, 12)

        self._src_label = W.QLabel("Sources & Stats")
        src_layout.addWidget(self._src_label)

        self._source_panel.source_list = W.QTreeWidget()
        self._source_panel.source_list.setHeaderLabels(["Collection", "Chunks"])
        self._source_panel.source_list.setColumnCount(2)
        self._source_panel.source_list.setAlternatingRowColors(True)
        src_layout.addWidget(self._source_panel.source_list)

        refresh_btn = W.QPushButton("Refresh Stats")
        refresh_btn.clicked.connect(self._source_panel.refresh_stats)
        src_layout.addWidget(refresh_btn)

        self._left_dock.setWidget(self._src_widget)
        self._window.addDockWidget(C.Qt.LeftDockWidgetArea, self._left_dock)

        # --- Right Dock: D3 Knowledge Map (collapsible) ---
        self._right_dock = W.QDockWidget("Knowledge Map", self._window)
        self._right_dock.setAllowedAreas(
            C.Qt.LeftDockWidgetArea | C.Qt.RightDockWidgetArea
        )

        mind_map_widget = self._mind_map.init_widget(self._window, qt_widgets=W)
        self._right_dock.setWidget(mind_map_widget)
        self._window.addDockWidget(C.Qt.RightDockWidgetArea, self._right_dock)

        # Status bar
        status_bar = self._window.statusBar()
        status_bar.showMessage("Ready")
        self._chat_panel.status_bar = status_bar
        self._source_panel.status_bar = status_bar
        self._source_panel.parent_window = self._window

        # Menu bar
        menu_bar = self._window.menuBar()
        file_menu = menu_bar.addMenu("File")
        file_menu.addAction("New Session").triggered.connect(self._on_new_session)
        file_menu.addAction("Save Session").triggered.connect(self._session_mgr.save)
        file_menu.addSeparator()
        file_menu.addAction("Quit").triggered.connect(self._window.close)

        view_menu = menu_bar.addMenu("View")
        # Dock toggle actions
        view_menu.addAction(self._left_dock.toggleViewAction())
        view_menu.addAction(self._right_dock.toggleViewAction())
        view_menu.addSeparator()
        toggle_action = view_menu.addAction("Toggle Dark/Light Theme")
        toggle_action.triggered.connect(self._toggle_theme)

        # Apply initial theme
        self._apply_theme()

    def _on_new_session(self) -> None:
        """Handle new session button."""
        self._session_mgr.new()
        self._chat_panel.display.clear()
        self._chat_panel.status_bar.showMessage(
            f"New session: {self._session_mgr.session.name}"  # type: ignore[union-attr]
        )

    def _on_load_session(self) -> None:
        """Handle load session button."""
        name = self._session_mgr.combo.currentText()
        if not name:
            return
        session = self._session_mgr.load(name)
        if session:
            self._chat_panel.render_session()
            self._chat_panel.status_bar.showMessage(f"Loaded session: {name}")

    def run(self) -> int:
        """Launch the GUI application."""
        self._app = self._qt_widgets.QApplication(sys.argv)
        self._build_ui()
        self._async_worker.start(self._qt_core)

        if not self._session_mgr.load_latest():
            self._session_mgr.new()
        self._chat_panel.render_session()
        self._session_mgr.refresh_list()
        self._source_panel.refresh_stats()

        self._window.show()
        exit_code = self._app.exec()

        self._session_mgr.save()
        self._async_worker.stop()
        return int(exit_code)


def launch_gui(config: OracleConfig | None = None) -> int:
    """Launch the Oracle GUI application."""
    gui = OracleGUI(config)
    return gui.run()
