<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<link rel="stylesheet" href="https://jsdelivr.net">
<title>Jieyab89 SOCMINT X — Graph</title>
<style>
  :root {
    --bg:        #0f1117;
    --surface:   #1a1d27;
    --surface2:  #21253a;
    --border:    #2a2d3a;
    --text:      #e8eaf0;
    --muted:     #8890a4;
    --accent:    #5865f2;
    --accent-bg: #1e2240;
    --success:   #22c55e;
    --danger:    #ef4444;
    --warn:      #f59e0b;
    --purple:    #8b5cf6;
    --purple-bg: #1e1535;
    --font:      -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", sans-serif;
  }
  * { box-sizing: border-box; margin: 0; padding: 0; }
  html, body {
    height: 100%;
    background: var(--bg);
    color: var(--text);
    font-family: var(--font);
    font-size: 14px;
    overflow: hidden;
  }
  body { display: flex; flex-direction: column; }

  /* ── Header ── */
  header {
    padding: 10px 20px;
    border-bottom: 1px solid var(--border);
    background: var(--surface);
    display: flex;
    align-items: center;
    gap: 10px;
    flex-shrink: 0;
    position: relative;
  }
  header h1 { font-size: 14px; font-weight: 600; }
  header .sep { color: var(--border); }
  header .sub { color: var(--muted); font-size: 12px; flex: 1; }
  .hamburger-btn {
    margin-left: auto;
    background: none;
    border: 1px solid var(--border);
    border-radius: 5px;
    color: var(--muted);
    cursor: pointer;
    padding: 5px 9px;
    font-size: 16px;
    line-height: 1;
    transition: all 0.15s;
  }
  .hamburger-btn:hover { color: var(--text); border-color: var(--accent); }
  .nav-menu {
    position: absolute;
    top: 100%;
    right: 12px;
    margin-top: 6px;
    background: var(--surface);
    border: 1px solid var(--border);
    border-radius: 7px;
    padding: 5px;
    min-width: 175px;
    box-shadow: 0 8px 28px rgba(0,0,0,0.5);
    z-index: 200;
    display: flex;
    flex-direction: column;
    gap: 1px;
  }
  .nav-menu.hidden { display: none; }
  .nav-menu a {
    padding: 7px 12px;
    border-radius: 5px;
    color: var(--muted);
    text-decoration: none;
    font-size: 13px;
    transition: all 0.12s;
    display: block;
  }
  .nav-menu a:hover { color: var(--text); background: var(--accent-bg); }
  .nav-menu a.current { color: var(--accent); background: var(--accent-bg); }
  .nav-divider { border: none; border-top: 1px solid var(--border); margin: 3px 0; }

  /* ── Toolbar ── */
  #toolbar {
    display: flex;
    align-items: center;
    gap: 7px;
    padding: 7px 16px;
    background: var(--surface);
    border-bottom: 1px solid var(--border);
    flex-shrink: 0;
    flex-wrap: wrap;
  }
  #toolbar select, #toolbar input[type="text"], #toolbar input[type="number"] {
    background: var(--bg);
    border: 1px solid var(--border);
    color: var(--text);
    border-radius: 5px;
    padding: 5px 8px;
    font-size: 12px;
    font-family: var(--font);
    line-height: 1.4;
  }
  #toolbar select:focus, #toolbar input:focus { outline: none; border-color: var(--accent); }
  #queryInput { flex: 1; min-width: 200px; }
  #countInput { width: 68px; }

  /* ── Buttons ── */
  .btn {
    padding: 5px 12px;
    border-radius: 5px;
    border: 1px solid var(--border);
    font-size: 12px;
    font-family: var(--font);
    cursor: pointer;
    transition: all 0.15s;
    white-space: nowrap;
    line-height: 1.4;
  }
  .btn-primary  { background: var(--accent); color: #fff; border-color: var(--accent); }
  .btn-primary:hover:not(:disabled) { background: #4752c4; }
  .btn-ghost    { background: transparent; color: var(--muted); }
  .btn-ghost:hover:not(:disabled) { color: var(--text); border-color: var(--accent); background: var(--accent-bg); }
  .btn-success  { background: transparent; color: var(--success); border-color: #1a4d2e; }
  .btn-success:hover:not(:disabled) { background: rgba(34,197,94,0.1); border-color: var(--success); }
  .btn-danger   { background: transparent; color: var(--danger); border-color: #4d1a1a; }
  .btn-danger:hover:not(:disabled)  { background: rgba(239,68,68,0.1); border-color: var(--danger); }
  .btn:disabled { opacity: 0.38; cursor: not-allowed; }

  /* ── Main area ── */
  #main {
    flex: 1;
    display: flex;
    min-height: 0;
    position: relative;
  }

  /* ── Cytoscape canvas ── */
  #cy {
    flex: 1;
    min-width: 0;
    background: var(--bg);
  }

  /* ── Info panel ── */
  #infoPanel {
    width: 310px;
    border-left: 1px solid var(--border);
    background: var(--surface);
    display: flex;
    flex-direction: column;
    flex-shrink: 0;
    overflow: hidden;
    transition: width 0.2s ease;
  }
  #infoPanel.hidden { width: 0; border-left: none; }

  #infoHeader {
    padding: 9px 12px;
    border-bottom: 1px solid var(--border);
    display: flex;
    align-items: center;
    justify-content: space-between;
    flex-shrink: 0;
    gap: 8px;
  }
  #infoTitle {
    font-size: 11px;
    font-weight: 600;
    color: var(--accent);
    text-transform: uppercase;
    letter-spacing: 0.05em;
    overflow: hidden;
    text-overflow: ellipsis;
    white-space: nowrap;
  }
  #btnClosePanel {
    background: none;
    border: none;
    color: var(--muted);
    cursor: pointer;
    font-size: 18px;
    line-height: 1;
    padding: 0 2px;
    flex-shrink: 0;
  }
  #btnClosePanel:hover { color: var(--text); }

  #infoContent {
    flex: 1;
    overflow-y: auto;
    padding: 10px 12px;
    font-size: 12px;
    scrollbar-width: thin;
    scrollbar-color: var(--border) transparent;
  }
  .info-row {
    display: flex;
    gap: 8px;
    padding: 5px 0;
    border-bottom: 1px solid var(--border);
    align-items: flex-start;
  }
  .info-key {
    color: var(--muted);
    font-size: 10px;
    min-width: 95px;
    text-transform: uppercase;
    letter-spacing: 0.04em;
    flex-shrink: 0;
    padding-top: 1px;
    line-height: 1.5;
  }
  .info-val {
    color: var(--text);
    flex: 1;
    word-break: break-word;
    line-height: 1.5;
  }
  .info-val a { color: var(--accent); text-decoration: none; }
  .info-val a:hover { text-decoration: underline; }
  .info-media {
    display: flex;
    flex-wrap: wrap;
    gap: 5px;
    margin-top: 8px;
    padding-top: 8px;
    border-top: 1px solid var(--border);
  }
  .info-media a img {
    width: 80px;
    height: 60px;
    object-fit: cover;
    border-radius: 4px;
    border: 1px solid var(--border);
    display: block;
  }
  .info-media a.video-link {
    display: flex;
    align-items: center;
    justify-content: center;
    width: 80px;
    height: 60px;
    background: var(--surface2);
    border-radius: 4px;
    border: 1px solid var(--border);
    color: var(--muted);
    font-size: 10px;
    text-decoration: none;
  }
  .info-media a.video-link:hover { border-color: var(--accent); color: var(--accent); }

  #infoActions {
    padding: 10px 12px;
    border-top: 1px solid var(--border);
    display: flex;
    flex-direction: column;
    gap: 5px;
    flex-shrink: 0;
  }
  #infoActions .btn { width: 100%; text-align: center; justify-content: center; }
  #infoActions a.btn { text-decoration: none; display: block; }

  /* ── Legend / Help popup ── */
  #legendToggle {
    position: absolute;
    bottom: 14px;
    left: 14px;
    width: 28px;
    height: 28px;
    border-radius: 50%;
    background: #FFFFFF;
    border: 1px solid var(--border);
    color: var(--muted);
    font-size: 14px;
    font-weight: 600;
    cursor: pointer;
    z-index: 11;
    transition: all 0.15s;
  }
  #legendToggle:hover { color: var(--text); border-color: var(--accent); }
  #legend {
    position: absolute;
    bottom: 50px;
    left: 14px;
    background: rgba(26,29,39,0.96);
    border: 1px solid var(--border);
    border-radius: 7px;
    padding: 10px 14px;
    font-size: 11px;
    display: flex;
    flex-direction: column;
    gap: 6px;
    z-index: 10;
    box-shadow: 0 8px 28px rgba(0,0,0,0.5);
    width: 260px;
    max-height: 70vh;
    overflow-y: auto;
    scrollbar-width: thin;
    scrollbar-color: var(--border) transparent;
  }
  #legend.hidden { display: none; }
  .leg-row { display: flex; align-items: center; gap: 8px; color: var(--muted); }
  .leg-icon {
    width: 14px;
    height: 14px;
    flex-shrink: 0;
    border: 2px solid;
  }
  .leg-icon.search   { background: var(--accent); border-color: var(--accent); transform: rotate(45deg); border-radius: 1px; }
  .leg-icon.tweet    { background: var(--accent-bg); border-color: var(--accent); border-radius: 2px; }
  .leg-icon.reply    { background: rgba(34,197,94,0.12); border-color: var(--success); border-radius: 2px; }
  .leg-icon.user     { background: var(--purple-bg); border-color: var(--purple); border-radius: 50%; }
  .leg-icon.wayback  { background: #2a1f08; border-color: var(--warn); border-radius: 2px; }
  .leg-icon.viewed   { background: #ffffff; border-color: #c7c7c7; border-radius: 2px; }
  .leg-heading {
    font-size: 10px;
    text-transform: uppercase;
    letter-spacing: 0.05em;
    color: var(--muted);
    margin: 6px 0 0;
  }
  .leg-note { color: var(--muted); font-size: 10px; line-height: 1.5; max-width: 220px; margin-top: 4px; }
  .param-heading {
    font-size: 10px;
    text-transform: uppercase;
    letter-spacing: 0.05em;
    color: var(--muted);
    margin: 8px 0 3px;
    padding-top: 6px;
    border-top: 1px solid var(--border);
  }
  .param-heading:first-child { margin-top: 0; padding-top: 0; border-top: none; }
  .param-row { color: var(--muted); font-size: 10.5px; line-height: 1.5; margin: 2px 0; }
  .param-key {
    display: inline-block;
    color: var(--accent);
    background: var(--accent-bg);
    font-family: ui-monospace, "SFMono-Regular", Consolas, monospace;
    font-size: 10px;
    padding: 1px 5px;
    border-radius: 4px;
    margin-right: 4px;
  }

  /* ── Source badges (info panel) ── */
  .source-badge {
    display: inline-block;
    font-size: 10px;
    font-weight: 500;
    padding: 2px 8px;
    border-radius: 20px;
    border: 1px solid var(--border);
  }
  .source-badge.src-cookie  { color: #a78bfa; border-color: #4c3a8f; background: var(--purple-bg); }
  .source-badge.src-xquik   { color: #8891f7; border-color: #34348a; background: var(--accent-bg); }
  .source-badge.src-wayback { color: var(--warn); border-color: #78350f; background: #1c0e02; }

  .age-badge {
    display: inline-block;
    font-size: 10px;
    font-weight: 500;
    padding: 2px 8px;
    border-radius: 20px;
    border: 1px solid var(--border);
  }
  .age-badge.age-new         { color: var(--danger);  border-color: #7f1d1d; background: #1c0505; }
  .age-badge.age-recent      { color: var(--warn);    border-color: #78350f; background: #1c0e02; }
  .age-badge.age-established { color: var(--success); border-color: #14532d; background: #052011; }

  /* ── Status bar ── */
  #statusBar {
    position: absolute;
    top: 10px;
    left: 50%;
    transform: translateX(-50%);
    background: rgba(26,29,39,0.94);
    border: 1px solid var(--border);
    border-radius: 5px;
    padding: 4px 16px;
    font-size: 12px;
    color: var(--muted);
    pointer-events: none;
    z-index: 20;
    transition: opacity 0.3s;
    max-width: 420px;
    text-align: center;
    white-space: nowrap;
    overflow: hidden;
    text-overflow: ellipsis;
  }
  #statusBar.error { color: var(--danger); border-color: #4d1a1a; }
  #statusBar.hidden { opacity: 0; }

  /* ── Node count badge ── */
  #nodeCount {
    font-size: 11px;
    color: var(--muted);
    padding: 3px 9px;
    background: var(--bg);
    border-radius: 10px;
    border: 1px solid var(--border);
    white-space: nowrap;
  }

  /* ── Spinner ── */
  .spin {
    display: inline-block;
    animation: spin 1s linear infinite;
  }
  @keyframes spin { to { transform: rotate(360deg); } }

  /* ── Empty state hint ── */
  #emptyHint {
    position: absolute;
    top: 50%;
    left: 50%;
    transform: translate(-50%, -50%);
    color: var(--border);
    font-size: 13px;
    pointer-events: none;
    text-align: center;
    line-height: 2;
    z-index: 5;
  }
  #emptyHint.hidden { display: none; }
</style>
</head>
<body>

<header>
  <h1>Jieyab89 SOCMINT X</h1>
  <span class="sep">|</span>
  <span class="sub">Graph Visualization</span>
  <button id="navToggle" class="hamburger-btn" aria-label="Navigation menu">&#9776;</button>
  <nav id="navMenu" class="nav-menu hidden">
    <a href="/">Home</a>
    <a href="/graph" class="current">Graph</a>
    <hr class="nav-divider">
    <a href="/archives">Archives</a>
  </nav>
</header>

<div id="toolbar">
  <select id="toolSelect">
    <option value="tweet_search_extractor">Tweet Search</option>
    <option value="follower_explorer">Follower Explorer</option>
    <option value="post_extractor">Post Extractor</option>
    <option value="article_extractor">Article Extractor</option>
    <option value="community_post_extractor">Community Posts</option>
    <option value="tweet_replies_extractor">Tweet Replies [Cookie]</option>
    <option value="tweet_retweeters_extractor">Tweet Retweeters [Cookie]</option>
    <option value="geo_post_extractor">Geo Post Search [Cookie]</option>
    <option value="wayback_archive_search">Wayback Archive Search</option>
    <option value="multi_source_search">Multi Source Search [All]</option>
  </select>
  <select id="modeSelect">
    <option value="cookie">Cookie</option>
    <option value="api">API</option>
  </select>
  <input id="queryInput" type="text" placeholder="Search query…" spellcheck="false" autocomplete="off">
  <input id="dateFromInput" type="text" placeholder="From YYYYMMDD" title="Optional start date" maxlength="8" style="width:100px;display:none">
  <input id="dateToInput"   type="text" placeholder="To YYYYMMDD"   title="Optional end date"   maxlength="8" style="width:100px;display:none">
  <input id="countInput" type="number" value="20" min="1" max="200" title="Result count">
  <span id="nodeCount">0 nodes</span>
  <button id="btnSearch"     class="btn btn-primary">Search</button>
  <button id="btnArchiveAll" class="btn btn-ghost">Archive All</button>
  <button id="btnDump"       class="btn btn-ghost">Dump JSON</button>
  <button id="btnClear"      class="btn btn-danger">Clear</button>
</div>

<div id="main">
  <div id="cy"></div>

  <!-- Right info panel -->
  <div id="infoPanel" class="hidden">
    <div id="infoHeader">
      <span id="infoTitle">Node Details</span>
      <button id="btnClosePanel" title="Close">×</button>
    </div>
    <div id="infoContent"></div>
    <div id="infoActions">
      <button id="btnExpandReplies"   class="btn btn-ghost"    style="display:none">↩ Expand Replies</button>
      <button id="btnExpandRetweets"  class="btn btn-ghost"    style="display:none">↗ Expand Retweets</button>
      <a      id="btnOpenTweet"       class="btn btn-success"  style="display:none"
              href="#" target="_blank" rel="noopener noreferrer">Open Tweet ↗</a>
    </div>
  </div>

  <!-- Status bar (floats over cy) -->
  <div id="statusBar" class="hidden">Ready</div>

  <!-- Empty state -->
  <div id="emptyHint">
    Use the toolbar to search<br>
    Click any node to inspect · Click tweet nodes to expand replies / retweets
  </div>
</div>

<!-- Legend / help popup (floats over cy, bottom-left) -->
<button id="legendToggle" aria-label="Legend / help" title="Legend / help">?</button>
<div id="legend" class="hidden">
  <div class="leg-row"><div class="leg-icon search"></div>  Search root</div>
  <div class="leg-row"><div class="leg-icon tweet"></div>   Tweet</div>
  <div class="leg-row"><div class="leg-icon reply"></div>   Reply</div>
  <div class="leg-row"><div class="leg-icon user"></div>    User / Retweeter</div>
  <div class="leg-row"><div class="leg-icon wayback"></div> Wayback snapshot</div>
  <div class="leg-row"><div class="leg-icon viewed"></div>  Viewed (clicked)</div>

  <div class="leg-heading">Source (info panel)</div>
  <div class="leg-row"><span class="source-badge src-cookie">Cookie</span></div>
  <div class="leg-row"><span class="source-badge src-xquik">Xquik API</span></div>
  <div class="leg-row"><span class="source-badge src-wayback">Wayback Machine</span></div>

  <div class="leg-heading">Account age (info panel)</div>
  <div class="leg-row"><span class="age-badge age-new">New</span> &lt; 30 days</div>
  <div class="leg-row"><span class="age-badge age-recent">Recent</span> &lt; 1 year</div>
  <div class="leg-row"><span class="age-badge age-established">Established</span> 1+ year</div>
  <div class="leg-note">Decoded from the account's numeric ID. "~" = estimated (pre-Snowflake id); "Unknown" = id in an undatable gap.</div>

  <div class="leg-heading">What each parameter means</div>
  {% include "_field_glossary.html" %}
</div>

<script src="https://unpkg.com/cytoscape@3.28.1/dist/cytoscape.min.js" crossorigin="anonymous"></script>
<script nonce="{{ g.csp_nonce }}">
(function () {
'use strict';

// ── State ─────────────────────────────────────────────────────────────────────
const allItems = [];   // { type, item }
let selectedNode = null;
let cy;

// ── Cytoscape styles ──────────────────────────────────────────────────────────
const CY_STYLE = [
  { selector: 'node[type="search"]', style: {
      shape: 'diamond',
      'background-color': '#5865f2',
      label: 'data(label)',
      color: '#e8eaf0',
      'font-size': '11px',
      'min-zoomed-font-size': 7,
      'text-valign': 'bottom',
      'text-halign': 'center',
      'text-margin-y': '9px',
      width: '46px',
      height: '46px',
      'font-family': '-apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif',
  }},
  // Tweet/reply/wayback cards: fixed, compact footprint regardless of content —
  // text-wrap 'ellipsis' truncates long post text to fit instead of growing the
  // box or overflowing it, which keeps dense graphs (100s of nodes) readable.
  // Full untruncated text is always available in the side panel on click.
  { selector: 'node[type="tweet"], node[type="reply"], node[type="wayback"]', style: {
      shape: 'roundrectangle',
      label: 'data(label)',
      color: '#e8eaf0',
      'font-size': '9px',
      'min-zoomed-font-size': 7,
      'text-valign': 'center',
      'text-halign': 'center',
      'text-wrap': 'ellipsis',
      'text-max-width': '128px',
      'border-width': '2px',
      width: '148px',
      height: '52px',
      'font-family': '-apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif',
  }},
  { selector: 'node[type="tweet"]', style: {
      'background-color': '#1e2240',
      'border-color': '#5865f2',
  }},
  { selector: 'node[type="reply"]', style: {
      'background-color': '#0d2018',
      'border-color': '#22c55e',
  }},
  { selector: 'node[type="wayback"]', style: {
      'background-color': '#2a1f08',
      'border-color': '#f59e0b',
  }},
  { selector: 'node[type="retweeter"], node[type="user"]', style: {
      shape: 'ellipse',
      'background-color': '#1e1535',
      'border-color': '#8b5cf6',
      'border-width': '2px',
      label: 'data(label)',
      color: '#e8eaf0',
      'font-size': '10px',
      'min-zoomed-font-size': 7,
      'text-valign': 'bottom',
      'text-halign': 'center',
      'text-margin-y': '7px',
      'text-wrap': 'ellipsis',
      'text-max-width': '90px',
      width: '68px',
      height: '68px',
      'font-family': '-apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif',
  }},
  // Viewed marker — set once a node has been clicked/inspected. Deliberately
  // plain white with no other color coding so it reads as one thing: "seen".
  // Placed after the type selectors so it always wins on background/border.
  { selector: 'node[?viewed][type="tweet"], node[?viewed][type="reply"], node[?viewed][type="wayback"]', style: {
      'background-color': '#ffffff',
      'border-color': '#c7c7c7',
      color: '#0f1117',
  }},
  { selector: 'node[?viewed][type="search"], node[?viewed][type="user"], node[?viewed][type="retweeter"]', style: {
      'background-color': '#ffffff',
      'border-color': '#c7c7c7',
  }},
  // Softer, thinner and semi-transparent — a hub with many edges (a popular
  // tweet's replies, a big multi-source search) stays legible instead of
  // turning into a bright white tangle.
  { selector: 'edge', style: {
      'line-color': '#FFFFFF',
      'line-style': 'dashed',
      'line-opacity': 1.0,
      width: '1.1px',
      'target-arrow-color': '#FFFFFF',
      'target-arrow-shape': 'triangle',
      'arrow-scale': 1.0,
      'curve-style': 'bezier',
  }},
];

// ── Init ──────────────────────────────────────────────────────────────────────
function init() {
  cy = cytoscape({
    container: document.getElementById('cy'),
    style: CY_STYLE,
    layout: { name: 'preset' },
    minZoom: 0.08,
    maxZoom: 4,
    wheelSensitivity: 0.25,
    boxSelectionEnabled: false,
    // Keep panning/zooming smooth once a graph has hundreds of nodes/edges —
    // edges & a rasterized texture stand in for the full render mid-gesture.
    hideEdgesOnViewport: true,
    textureOnViewport: true,
  });

  cy.on('tap', 'node', function (evt) {
    selectedNode = evt.target;
    selectedNode.data('viewed', true);
    showPanel(selectedNode.data());
  });

  cy.on('tap', function (evt) {
    if (evt.target === cy) hidePanel();
  });

  // Hamburger nav
  var navBtn  = document.getElementById('navToggle');
  var navMenu = document.getElementById('navMenu');
  navBtn.addEventListener('click', function(e) {
    e.stopPropagation();
    navMenu.classList.toggle('hidden');
  });
  document.addEventListener('click', function() { navMenu.classList.add('hidden'); });
  navMenu.addEventListener('click', function(e) { e.stopPropagation(); });

  // Legend / help popup
  var legendBtn   = document.getElementById('legendToggle');
  var legendPopup = document.getElementById('legend');
  legendBtn.addEventListener('click', function (e) {
    e.stopPropagation();
    legendPopup.classList.toggle('hidden');
  });
  document.addEventListener('click', function () { legendPopup.classList.add('hidden'); });
  legendPopup.addEventListener('click', function (e) { e.stopPropagation(); });

  document.getElementById('btnSearch').addEventListener('click', runSearch);
  document.getElementById('queryInput').addEventListener('keydown', function (e) {
    if (e.key === 'Enter') runSearch();
  });
  document.getElementById('toolSelect').addEventListener('change', function () {
    updatePlaceholder();
    updateModeVisibility();
  });
  document.getElementById('btnArchiveAll').addEventListener('click', archiveAll);
  document.getElementById('btnDump').addEventListener('click', dumpJSON);
  document.getElementById('btnClear').addEventListener('click', clearGraph);
  document.getElementById('btnClosePanel').addEventListener('click', hidePanel);
  document.getElementById('btnExpandReplies').addEventListener('click', function () {
    expandNode('tweet_replies_extractor');
  });
  document.getElementById('btnExpandRetweets').addEventListener('click', function () {
    expandNode('tweet_retweeters_extractor');
  });

  updatePlaceholder();
  updateModeVisibility();
}

// ── Placeholder by tool type ──────────────────────────────────────────────────
function updatePlaceholder() {
  var map = {
    tweet_search_extractor:     'Search query…',
    follower_explorer:          'Username or user ID…',
    post_extractor:             'Username or user ID…',
    article_extractor:          'Tweet ID…',
    community_post_extractor:   'Community ID…',
    tweet_replies_extractor:    'Tweet ID…',
    tweet_retweeters_extractor: 'Tweet ID…',
    geo_post_extractor:         'Keyword (geocodes profile location)…',
    wayback_archive_search:     'Username or tweet URL…',
    multi_source_search:        'Keyword, mention, username or URL…',
  };
  var tool = document.getElementById('toolSelect').value;
  document.getElementById('queryInput').placeholder = map[tool] || 'Query…';
}

// These tools don't use the Cookie/API mode select: wayback needs no auth at
// all, multi_source_search always fans out across every mode simultaneously.
// Both also accept an optional YYYYMMDD date range to narrow results.
var NO_AUTH_TOOLS = ['wayback_archive_search', 'multi_source_search'];

function updateModeVisibility() {
  var tool = document.getElementById('toolSelect').value;
  var show = NO_AUTH_TOOLS.indexOf(tool) !== -1;
  document.getElementById('modeSelect').style.display    = show ? 'none' : '';
  document.getElementById('dateFromInput').style.display = show ? '' : 'none';
  document.getElementById('dateToInput').style.display    = show ? '' : 'none';
}

// ── Status bar ────────────────────────────────────────────────────────────────
var _statusTimer = null;

function setStatus(msg, isError, persist) {
  var bar = document.getElementById('statusBar');
  bar.textContent = msg;
  bar.classList.toggle('error', !!isError);
  bar.classList.remove('hidden');
  if (_statusTimer) clearTimeout(_statusTimer);
  if (!persist) {
    _statusTimer = setTimeout(function () { bar.classList.add('hidden'); }, 4500);
  }
}

function updateNodeCount() {
  var n = cy.nodes().length;
  document.getElementById('nodeCount').textContent = n + ' node' + (n !== 1 ? 's' : '');
  document.getElementById('emptyHint').classList.toggle('hidden', n > 0);
}

// ── API helpers ───────────────────────────────────────────────────────────────
function buildBody(tool, query, count, mode) {
  var body = { toolType: tool, mode: mode, count: count };
  if (tool === 'tweet_search_extractor' || tool === 'geo_post_extractor' ||
      tool === 'wayback_archive_search' || tool === 'multi_source_search') {
    body.searchQuery = query;
  } else if (tool === 'follower_explorer' || tool === 'post_extractor') {
    body.targetUsername = query;
  } else if (
    tool === 'article_extractor' ||
    tool === 'tweet_replies_extractor' ||
    tool === 'tweet_retweeters_extractor'
  ) {
    body.targetTweetId = query;
  } else if (tool === 'community_post_extractor') {
    body.targetCommunityId = query;
  }

  if (tool === 'wayback_archive_search' || tool === 'multi_source_search') {
    var from = document.getElementById('dateFromInput').value.trim();
    var to   = document.getElementById('dateToInput').value.trim();
    if (tool === 'wayback_archive_search') {
      if (from) body.waybackFrom = from;
      if (to)   body.waybackTo   = to;
    } else {
      if (from) body.dateFrom = from;
      if (to)   body.dateTo   = to;
    }
  }
  return body;
}

async function apiFetch(body) {
  var res = await fetch('/api/run', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify(body),
  });
  var json = await res.json();
  if (!json.ok) throw new Error(json.error || 'API error');
  return json.data;
}

// ── Label builder ─────────────────────────────────────────────────────────────
function makeLabel(item, type) {
  if (type === 'user' || type === 'retweeter') {
    var handle = item.screen_name || item.user || '';
    return handle ? '@' + handle : 'Unknown';
  }
  if (type === 'wayback') {
    var snap = (item.post_title || item.post_text || item.original || 'Snapshot').replace(/\s+/g, ' ').trim();
    return snap.length > 60 ? snap.substring(0, 60) + '…' : snap;
  }
  var text = (item.text || item.retweeted_text || '').replace(/\s+/g, ' ').trim();
  return text.length > 60 ? text.substring(0, 60) + '…' : (text || ('Tweet ' + (item.id || '?')));
}

// Per-item node type — multi_source_search mixes live tweets with Wayback
// snapshots in one result set, so the type has to be resolved per item rather
// than fixed for the whole tool/search.
function resolveNodeType(tool, item) {
  if (tool === 'follower_explorer') return 'user';
  if (tool === 'tweet_retweeters_extractor') return 'retweeter';
  if (tool === 'wayback_archive_search') return 'wayback';
  if (tool === 'multi_source_search') return item.source === 'Wayback Machine' ? 'wayback' : 'tweet';
  return 'tweet';
}

// ── Graph operations ──────────────────────────────────────────────────────────
function addNodes(items, nodeTypeOrFn, parentId) {
  var added = 0;
  items.forEach(function (item, i) {
    var nodeType = typeof nodeTypeOrFn === 'function' ? nodeTypeOrFn(item) : nodeTypeOrFn;
    var tweetId  = String(item.id || '');
    var cyId     = tweetId ? 'n_' + tweetId : 'n_' + Date.now() + '_' + i;

    if (cy.getElementById(cyId).length) {
      if (parentId) addEdge(parentId, cyId);
      return;
    }

    cy.add({
      data: {
        id:      cyId,
        type:    nodeType,
        label:   makeLabel(item, nodeType),
        tweetId: tweetId,
        raw:     item,
      }
    });

    if (parentId) addEdge(parentId, cyId);
    allItems.push({ type: nodeType, item: item });
    added++;
  });
  return added;
}

function addEdge(src, tgt) {
  var eId = 'e_' + src + '_' + tgt;
  if (!cy.getElementById(eId).length) {
    cy.add({ data: { id: eId, source: src, target: tgt } });
  }
}

function runLayout(fit, randomize) {
  cy.layout({
    name: 'cose',
    animate: true,
    animationDuration: 450,
    randomize: !!randomize,
    fit: !!fit,
    nodeRepulsion: 10000,
    idealEdgeLength: 130,
    gravity: 0.35,
    numIter: 1000,
    padding: 40,
  }).run();
}

// ── Search ────────────────────────────────────────────────────────────────────
async function runSearch() {
  var tool  = document.getElementById('toolSelect').value;
  var query = document.getElementById('queryInput').value.trim();
  var count = Math.max(1, Math.min(200, parseInt(document.getElementById('countInput').value, 10) || 20));
  var mode  = document.getElementById('modeSelect').value;

  if (!query) { setStatus('Enter a query first', true); return; }

  document.getElementById('btnSearch').disabled = true;
  setStatus('Searching…', false, true);

  try {
    var data  = await apiFetch(buildBody(tool, query, count, mode));
    var items = Array.isArray(data) ? data : [data];

    // Central search node
    var searchId = 'search_' + Date.now();
    var shortQ   = query.length > 40 ? query.substring(0, 40) + '…' : query;
    cy.add({ data: { id: searchId, type: 'search', label: shortQ, tweetId: '', raw: { query: query } } });

    var added = addNodes(items, function (item) { return resolveNodeType(tool, item); }, searchId);
    runLayout(true, true);
    updateNodeCount();
    setStatus('Done — ' + items.length + ' result(s) (' + added + ' new nodes)');
  } catch (e) {
    setStatus('Error: ' + e.message, true);
  } finally {
    document.getElementById('btnSearch').disabled = false;
  }
}

// ── Expand replies / retweets from selected node ──────────────────────────────
async function expandNode(expandTool) {
  if (!selectedNode) return;
  var data    = selectedNode.data();
  var tweetId = data.tweetId;
  if (!tweetId) { setStatus('Node has no tweet ID', true); return; }

  var count    = Math.max(1, Math.min(200, parseInt(document.getElementById('countInput').value, 10) || 50));
  var parentId = selectedNode.id();

  document.getElementById('btnExpandReplies').disabled  = true;
  document.getElementById('btnExpandRetweets').disabled = true;
  setStatus('Expanding…', false, true);

  try {
    var fetched  = await apiFetch({ toolType: expandTool, mode: 'cookie', count: count, targetTweetId: tweetId });
    var items    = Array.isArray(fetched) ? fetched : [fetched];
    var nodeType = expandTool === 'tweet_replies_extractor' ? 'reply' : 'retweeter';

    var added = addNodes(items, nodeType, parentId);
    runLayout(false, false);
    updateNodeCount();
    setStatus('Expanded — ' + items.length + ' result(s) (' + added + ' new nodes)');
  } catch (e) {
    setStatus('Error: ' + e.message, true);
  } finally {
    document.getElementById('btnExpandReplies').disabled  = false;
    document.getElementById('btnExpandRetweets').disabled = false;
  }
}

// ── Info panel ────────────────────────────────────────────────────────────────
function esc(s) {
  return String(s)
    .replace(/&/g, '&amp;')
    .replace(/</g, '&lt;')
    .replace(/>/g, '&gt;')
    .replace(/"/g, '&quot;');
}

var PRIORITY_KEYS = [
  'source', 'account_age_flag', 'account_age', 'account_created',
  'user', 'screen_name', 'name', 'text', 'post_title', 'post_text', 'created_at',
  'retweeted_by_user', 'retweeted_by_name', 'retweeted_text', 'retweeted_by_bio',
  'reply_count', 'retweet_count', 'favorite_count', 'view_count',
  'followers_count', 'following_count', 'tweet_count',
  'description', 'user_location', 'in_reply_to_tweet_id',
  'retweeted_tweet_id', 'retweeted_at', 'verified', 'is_blue_verified',
  'archive_url', 'preview_image', 'iso_date', 'original', 'statuscode',
];

var SKIP_KEYS = new Set(['id', 'media', 'card', 'user_id', 'retweeted_by_user_id']);

var SOURCE_CLASS = { 'Twitter Cookie': 'src-cookie', 'Xquik API': 'src-xquik', 'Wayback Machine': 'src-wayback' };

function showPanel(data) {
  var raw  = data.raw || {};
  var type = data.type;

  var titles = { search: 'Search Node', tweet: 'Tweet', reply: 'Reply', user: 'User', retweeter: 'Retweeter' };
  document.getElementById('infoTitle').textContent = titles[type] || 'Node';

  // Build key list (priority first, then remaining)
  var allKeys = Object.keys(raw);
  var ordered = PRIORITY_KEYS.concat(allKeys.filter(function (k) { return PRIORITY_KEYS.indexOf(k) === -1; }));
  var keys    = ordered.filter(function (k) {
    return !SKIP_KEYS.has(k) && raw[k] !== null && raw[k] !== undefined && raw[k] !== '';
  });

  var rows = keys.map(function (k) {
    var v   = raw[k];
    var val = esc(String(v));

    // Format tweet ID fields as links
    if ((k === 'retweeted_tweet_id' || k === 'in_reply_to_tweet_id') && /^\d+$/.test(String(v))) {
      var handle = raw.retweeted_by_user || raw.user || '';
      if (handle) {
        val = '<a href="https://x.com/' + encodeURIComponent(handle) + '/status/' + encodeURIComponent(String(v)) +
              '" target="_blank" rel="noopener noreferrer">' + esc(String(v)) + ' ↗</a>';
      }
    } else if (k === 'archive_url' || k === 'preview_image') {
      val = '<a href="' + esc(String(v)) + '" target="_blank" rel="noopener noreferrer">' +
            (k === 'archive_url' ? 'Open snapshot ↗' : 'Preview image ↗') + '</a>';
    } else if (k === 'source') {
      val = '<span class="source-badge ' + (SOURCE_CLASS[v] || '') + '">' + esc(String(v)) + '</span>';
    } else if (k === 'account_age_flag') {
      var ageLabels = { new: 'New account', recent: 'Recent account', established: 'Established account' };
      val = '<span class="age-badge age-' + esc(String(v)) + '">' + esc(ageLabels[v] || String(v)) + '</span>';
    }

    var label = esc(k.replace(/_/g, ' '));
    return '<div class="info-row"><div class="info-key">' + label + '</div><div class="info-val">' + val + '</div></div>';
  }).join('');

  // Media thumbnails
  var media = Array.isArray(raw.media) ? raw.media : [];
  if (media.length) {
    var mediaParts = ['<div class="info-media">'];
    media.forEach(function (m) {
      var thumb = m.thumb || '';
      var url   = m.url || thumb;
      var mtype = m.type || 'photo';
      if (!thumb) return;
      if (mtype === 'photo') {
        mediaParts.push(
          '<a href="' + esc(url) + '" target="_blank" rel="noopener noreferrer">' +
          '<img src="' + esc(thumb) + '" loading="lazy" alt="media"></a>'
        );
      } else {
        mediaParts.push(
          '<a href="/api/video?url=' + encodeURIComponent(url) + '" target="_blank" rel="noopener noreferrer" class="video-link">▶ ' + esc(mtype) + '</a>'
        );
      }
    });
    mediaParts.push('</div>');
    rows += mediaParts.join('');
  }

  document.getElementById('infoContent').innerHTML = rows;

  // Action buttons
  var isTweet   = type === 'tweet' || type === 'reply';
  var isWayback = type === 'wayback';
  var tweetId   = data.tweetId;
  var user      = raw.user || raw.screen_name || '';

  document.getElementById('btnExpandReplies').style.display  = isTweet ? '' : 'none';
  document.getElementById('btnExpandRetweets').style.display = isTweet ? '' : 'none';

  var openBtn = document.getElementById('btnOpenTweet');
  if (isTweet && tweetId && user) {
    openBtn.href          = 'https://x.com/' + encodeURIComponent(user) + '/status/' + encodeURIComponent(tweetId);
    openBtn.textContent   = 'Open Tweet ↗';
    openBtn.style.display = '';
  } else if (isWayback && raw.archive_url) {
    openBtn.href          = raw.archive_url;
    openBtn.textContent   = 'Open Archived Snapshot ↗';
    openBtn.style.display = '';
  } else {
    openBtn.style.display = 'none';
  }

  document.getElementById('infoPanel').classList.remove('hidden');
}

function hidePanel() {
  document.getElementById('infoPanel').classList.add('hidden');
  selectedNode = null;
  cy.elements().unselect();
}

// ── Archive All ───────────────────────────────────────────────────────────────
async function archiveAll() {
  if (!allItems.length) { setStatus('Nothing to archive yet', true); return; }
  var query = document.getElementById('queryInput').value.trim();
  var tool  = document.getElementById('toolSelect').value;

  setStatus('Archiving…', false, true);
  try {
    var res = await fetch('/api/archive', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({
        toolType:  'graph_' + tool,
        data:      allItems.map(function (d) { return d.item; }),
        queryInfo: { query: query, source: 'graph', nodes: cy.nodes().length },
      }),
    });
    var json = await res.json();
    if (!json.ok) throw new Error(json.error);
    setStatus('Archived — ID: ' + json.archiveId);
  } catch (e) {
    setStatus('Archive error: ' + e.message, true);
  }
}

// ── Dump JSON ─────────────────────────────────────────────────────────────────
function dumpJSON() {
  if (!allItems.length) { setStatus('Nothing to export yet', true); return; }
  var payload = {
    exported_at: new Date().toISOString(),
    query:       document.getElementById('queryInput').value.trim(),
    tool:        document.getElementById('toolSelect').value,
    node_count:  cy.nodes().length,
    items:       allItems,
  };
  var blob = new Blob([JSON.stringify(payload, null, 2)], { type: 'application/json' });
  var url  = URL.createObjectURL(blob);
  var a    = document.createElement('a');
  a.href     = url;
  a.download = 'graph_export_' + Date.now() + '.json';
  document.body.appendChild(a);
  a.click();
  document.body.removeChild(a);
  URL.revokeObjectURL(url);
  setStatus('Exported ' + allItems.length + ' item(s)');
}

// ── Clear ─────────────────────────────────────────────────────────────────────
function clearGraph() {
  cy.elements().remove();
  allItems.length = 0;
  hidePanel();
  updateNodeCount();
  setStatus('Graph cleared');
}

// ── Boot ──────────────────────────────────────────────────────────────────────
document.addEventListener('DOMContentLoaded', init);

})();
</script>
</body>
</html>
