<script>
  import { tick, onMount, onDestroy } from 'svelte';
  import { parseJsonl, toRawUrl } from './parse.js';
  import { renderMarkdown } from './markdown.js';
  import { getParam, setParam } from './url-sync.js';

  const spinnerFrames = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'];
  let spinnerFrame = $state(0);
  let spinnerInterval;

  const examples = [
    {
      label: '3D browser game',
      url: 'https://huggingface.co/datasets/0xSero/pi-sessions/blob/main/2026-01-28T17-49-25-023Z_cc5bb68b-049d-49c7-84b1-004b47ae7cdc.jsonl',
    },
    {
      label: 'Explain repo',
      url: 'https://huggingface.co/datasets/0xSero/pi-sessions/blob/main/2026-01-13T13-24-19-611Z_6622d195-0787-41ac-b9d0-9ea1118a1c6c.jsonl',
    },
    {
      label: 'Fix Swift errors',
      url: 'https://huggingface.co/datasets/0xSero/pi-sessions/blob/main/2026-01-30T19-41-29-858Z_16007873-1d9f-4efd-b222-161a55e0183f.jsonl',
    },
    {
      label: 'Triage issue',
      url: 'https://huggingface.co/datasets/badlogicgames/pi-mono/blob/main/2026-01-16T02-37-34-075Z_4293a326-81ca-4327-b450-85275e1ca645.jsonl',
    },
    {
      label: 'Release audit',
      url: 'https://huggingface.co/datasets/badlogicgames/pi-mono/blob/main/2026-01-16T03-32-51-416Z_cf56c275-9716-42a7-b79e-c3225fe7f6d2.jsonl',
    },
  ];

  let url = $state(examples[0].url);

  function pickExample(ex) {
    url = ex.url;
    load();
  }

  const HF_SPACE_URL = 'https://huggingface.co/spaces/mishig/traces-replay';
  let copied = $state(false);

  async function shareTrace() {
    const link = `${HF_SPACE_URL}?url=${encodeURIComponent(url)}`;
    try {
      await navigator.clipboard.writeText(link);
    } catch {
      window.prompt('Copy this link:', link);
      return;
    }
    copied = true;
    setTimeout(() => (copied = false), 1800);
  }
  let loading = $state(false);
  let loadedCount = $state(0);
  let error = $state('');
  let messages = $state([]);
  let focusedIdx = $state(-1);
  let playing = $state(false);
  let skipFlag = false;
  let listEl;

  async function load() {
    loading = true;
    error = '';
    messages = [];
    focusedIdx = -1;
    loadedCount = 0;
    playing = false;
    skipFlag = false;

    try {
      const res = await fetch(toRawUrl(url));
      if (!res.ok) throw new Error(`Failed to fetch (HTTP ${res.status})`);
      const text = await res.text();
      const parsed = parseJsonl(text);
      for (const msg of parsed) {
        msg._visible = false;
        msg._visibleBlocks = 0;
        for (const block of msg.blocks) {
          block._typedText = '';
          block._typing = false;
        }
      }
      messages = parsed;
      loadedCount = parsed.length;

      if (parsed.length === 0) {
        error = 'No messages parsed from this file.';
      } else {
        setParam('url', url);
        await tick();
        playback();
      }
    } catch (e) {
      error = e?.message || String(e);
    } finally {
      loading = false;
    }
  }

  const wait = (ms) => new Promise((r) => setTimeout(r, ms));

  async function playback() {
    playing = true;
    skipFlag = false;
    userScrolledUp = false;
    for (let mi = 0; mi < messages.length; mi++) {
      if (skipFlag) break;
      const msg = messages[mi];
      msg._visible = true;
      focusedIdx = mi;
      await tick();
      followTail();
      await wait(40);

      for (let bi = 0; bi < msg.blocks.length; bi++) {
        if (skipFlag) break;
        msg._visibleBlocks = bi + 1;
        await tick();
        followTail();
        const block = msg.blocks[bi];
        if (block.kind === 'text' || block.kind === 'thinking') {
          await typeBlock(block);
        } else {
          await wait(90);
          await tick();
          followTail();
        }
      }
      if (skipFlag) break;
      await wait(120);
    }

    if (skipFlag) revealAll();
    playing = false;
  }

  async function typeBlock(block) {
    const full = block.text || '';
    const len = full.length;
    if (len === 0) return;
    const totalMs = Math.max(250, Math.min(1400, len * 10));
    const tickMs = 16;
    const totalTicks = Math.ceil(totalMs / tickMs);
    const step = Math.max(1, Math.ceil(len / totalTicks));

    block._typing = true;
    for (let c = step; c < len; c += step) {
      if (skipFlag) break;
      block._typedText = full.slice(0, c);
      await tick();
      followTail();
      await wait(tickMs);
    }
    block._typedText = full;
    await tick();
    followTail();
    block._typing = false;
  }

  // Terminal-style tail: during playback, keep the scroll container pinned
  // to the bottom so new content scrolls line-by-line into view. If the user
  // actively scrolls up during playback, stop tailing until playback ends.
  let userScrolledUp = false;
  let lastAutoScrollTop = 0;

  function followTail() {
    if (!listEl || !playing || userScrolledUp) return;
    listEl.scrollTop = listEl.scrollHeight;
    lastAutoScrollTop = listEl.scrollTop;
  }

  function onListClick(e) {
    const btn = e.target.closest?.('.copy-btn');
    if (!btn) return;
    const pre =
      btn.parentElement?.querySelector('pre code') ||
      btn.parentElement?.querySelector('pre');
    if (!pre) return;
    const text = pre.textContent || '';
    const reset = () => {
      btn.textContent = 'Copy';
      btn.classList.remove('copied');
    };
    navigator.clipboard.writeText(text).then(
      () => {
        btn.textContent = 'Copied';
        btn.classList.add('copied');
        setTimeout(reset, 1500);
      },
      () => {
        window.prompt('Copy this code:', text);
      }
    );
  }

  function onListScroll() {
    if (!listEl || !playing) return;
    const distanceFromBottom =
      listEl.scrollHeight - listEl.scrollTop - listEl.clientHeight;
    // If the user scrolls back to the bottom, re-engage auto-scroll.
    if (distanceFromBottom < 20) {
      userScrolledUp = false;
      lastAutoScrollTop = listEl.scrollTop;
      return;
    }
    // Otherwise, if the position diverges from our last auto-write, pause
    // auto-scroll so the user can read without being yanked.
    if (Math.abs(listEl.scrollTop - lastAutoScrollTop) > 40) {
      userScrolledUp = true;
    }
  }

  function revealAll() {
    for (const msg of messages) {
      msg._visible = true;
      msg._visibleBlocks = msg.blocks.length;
      for (const block of msg.blocks) {
        if ('_typedText' in block) block._typedText = block.text || '';
        block._typing = false;
      }
    }
  }

  function skip() {
    skipFlag = true;
  }

  async function handleKey(e) {
    const t = e.target;
    if (
      t &&
      (t.tagName === 'INPUT' ||
        t.tagName === 'TEXTAREA' ||
        t.isContentEditable)
    )
      return;

    if (
      playing &&
      ['ArrowDown', 'ArrowUp', 'Home', 'End', 'Escape', ' '].includes(e.key)
    ) {
      e.preventDefault();
      skip();
      return;
    }

    if (messages.length === 0) return;
    if (e.key === 'ArrowDown' || e.key === 'j') {
      e.preventDefault();
      focusedIdx = Math.min(messages.length - 1, focusedIdx + 1);
    } else if (e.key === 'ArrowUp' || e.key === 'k') {
      e.preventDefault();
      focusedIdx = Math.max(0, focusedIdx - 1);
    } else if (e.key === 'Home' || (e.key === 'g' && !e.shiftKey)) {
      e.preventDefault();
      focusedIdx = 0;
    } else if (e.key === 'End' || (e.key === 'G' && e.shiftKey)) {
      e.preventDefault();
      focusedIdx = messages.length - 1;
    } else {
      return;
    }
    await tick();
    scrollToFocused('smooth');
  }

  function scrollToFocused(behavior = 'smooth') {
    if (focusedIdx < 0 || !listEl) return;
    const el = listEl.querySelector(`[data-idx="${focusedIdx}"]`);
    if (el) el.scrollIntoView({ behavior, block: 'center' });
  }

  function formatJson(obj) {
    try {
      return JSON.stringify(obj, null, 2);
    } catch {
      return String(obj);
    }
  }

  onMount(() => {
    spinnerInterval = setInterval(() => {
      spinnerFrame = (spinnerFrame + 1) % spinnerFrames.length;
    }, 90);

    const shared = getParam('url');
    if (shared) {
      url = shared;
      load();
    }
  });

  onDestroy(() => {
    clearInterval(spinnerInterval);
  });

  const roleColor = {
    user: 'text-[#1e40af]',
    assistant: 'text-[#0f5a2a]',
    tool: 'text-[#6b21a8]',
    system: 'text-[#92400e]',
    meta: 'text-[#6a6a66]',
    unknown: 'text-[#6a6a66]',
  };
</script>

<svelte:window onkeydown={handleKey} />

<div
  class="frame-bg frame-shadow w-[960px] max-w-[calc(100vw-48px)] rounded-[20px] p-[3px]"
>
  <div
    class="w-full h-[85vh] bg-[#fbfbf9] rounded-[17px] overflow-hidden flex flex-col font-mono text-[14px] leading-[1.7] text-[#232323]"
  >
    <!-- chrome -->
    <div class="flex items-center gap-2 pt-4 px-[18px] pb-2 shrink-0">
      <span class="w-3 h-3 rounded-full bg-[#d7d7d3]"></span>
      <span class="w-3 h-3 rounded-full bg-[#d7d7d3]"></span>
      <span class="w-3 h-3 rounded-full bg-[#d7d7d3]"></span>
      <a
        href="https://huggingface.co/datasets?format=format%3Aagent-traces"
        target="_blank"
        rel="noopener noreferrer"
        class="ml-auto text-[12px] text-[#6a6a66] hover:text-[#222220] hover:underline select-none transition-colors"
        title="Browse agent-trace datasets on Hugging Face"
      >
        🤗 traces
      </a>
    </div>

    <!-- url prompt -->
    <div
      class="flex items-center gap-2 px-5 py-3 border-b border-[#eeeae0] shrink-0"
    >
      <span class="text-[#6a6a66] select-none">›</span>
      <input
        type="url"
        bind:value={url}
        onkeydown={(e) => {
          if (e.key === 'Enter') load();
        }}
        placeholder="paste .jsonl dataset URL and press Enter"
        class="flex-1 bg-transparent border-none outline-none text-[13px] text-[#222220] placeholder:text-[#b0b0aa]"
      />
      <button
        type="button"
        onclick={load}
        disabled={loading}
        class="px-3 py-1 bg-[#ffd21e] rounded text-[12px] font-semibold hover:bg-[#ffbb1a] disabled:opacity-50 cursor-pointer"
      >
        load
      </button>
      <button
        type="button"
        onclick={shareTrace}
        disabled={messages.length === 0 || loading}
        title="Copy a shareable link to this replay"
        class="inline-flex items-center gap-1.5 px-3 py-1 bg-white border border-[#ffd21e] rounded text-[12px] font-semibold text-[#8a6b00] hover:bg-[#fffbe6] disabled:opacity-40 disabled:cursor-not-allowed cursor-pointer transition-colors shrink-0"
      >
        {#if copied}
          <span class="text-[#0f7a3a]">✓</span>
          <span class="text-[#0f5a2a]">copied</span>
        {:else}
          <svg
            xmlns="http://www.w3.org/2000/svg"
            viewBox="0 0 16 16"
            fill="currentColor"
            class="w-3.5 h-3.5"
            aria-hidden="true"
          >
            <path
              d="M8.707 2.293a1 1 0 0 0-1.414 0L3.293 6.293a1 1 0 1 0 1.414 1.414L7 5.414V11a1 1 0 1 0 2 0V5.414l2.293 2.293a1 1 0 1 0 1.414-1.414l-4-4z"
            />
            <path
              d="M3 12a1 1 0 1 0-2 0v1a2 2 0 0 0 2 2h10a2 2 0 0 0 2-2v-1a1 1 0 1 0-2 0v1H3v-1z"
            />
          </svg>
          <span>Share replay</span>
        {/if}
      </button>
    </div>

    <!-- examples -->
    <div
      class="flex flex-wrap items-center gap-2 px-5 py-2 border-b border-[#eeeae0] shrink-0"
    >
      <span class="text-[11px] text-[#888] select-none">examples:</span>
      {#each examples as ex}
        <button
          type="button"
          onclick={() => pickExample(ex)}
          disabled={loading || playing}
          class="text-[11px] px-2 py-1 bg-[#fffbe6] border border-[#ffedb0] rounded hover:bg-[#ffd21e] hover:border-[#ffbb1a] disabled:opacity-50 disabled:hover:bg-[#fffbe6] disabled:hover:border-[#ffedb0] transition-colors cursor-pointer"
        >
          {ex.label}
        </button>
      {/each}
    </div>

    <!-- messages / log -->
    <!-- svelte-ignore a11y_click_events_have_key_events -->
    <!-- svelte-ignore a11y_no_static_element_interactions -->
    <div
      bind:this={listEl}
      onscroll={onListScroll}
      onclick={onListClick}
      class="flex-1 overflow-y-auto thin-scrollbar px-5 py-3"
    >
      {#if loading}
        <div class="flex items-baseline gap-2">
          <span class="w-[1ch] text-center text-[#5f5f5c]"
            >{spinnerFrames[spinnerFrame]}</span
          >
          <span class="text-[#333331]">Fetching traces...</span>
        </div>
      {:else if error}
        <div class="flex items-baseline gap-2">
          <span class="w-[1ch] text-center text-[#991b1b]">✗</span>
          <span class="text-[#991b1b]">{error}</span>
        </div>
      {:else if messages.length > 0}
        <div class="flex items-baseline gap-2 mb-3 animate-fade-in">
          <span
            class="w-[1ch] text-center {playing
              ? 'text-[#5f5f5c]'
              : 'text-[#0f7a3a] animate-ready-pulse'}"
          >
            {playing ? spinnerFrames[spinnerFrame] : '●'}
          </span>
          <span
            class="{playing
              ? 'text-[#333331]'
              : 'text-[#0f5a2a]'} font-semibold"
          >
            {playing
              ? `Streaming ${focusedIdx + 1} / ${loadedCount}...`
              : `Loaded ${loadedCount} messages`}
          </span>
        </div>
      {:else}
        <div class="text-[#888] text-[13px] leading-relaxed">
          <div class="flex items-baseline gap-2 mb-1">
            <span class="w-[1ch] text-center text-[#6b6b68]">○</span>
            <span>waiting for input</span>
          </div>
          <div class="pl-[2.2ch] text-[#888]">
            paste a <code class="text-[#8b5cf6]">.jsonl</code> dataset URL above,
            press
            <kbd
              class="px-1 py-px bg-[#f5f5f2] rounded border border-[#e5e5e0] text-[11px]"
              >Enter</kbd
            > to load.
          </div>
          <div class="pl-[2.2ch] text-[#888] mt-1">
            then <kbd
              class="px-1 py-px bg-[#f5f5f2] rounded border border-[#e5e5e0] text-[11px]"
              >↑</kbd
            >
            <kbd
              class="px-1 py-px bg-[#f5f5f2] rounded border border-[#e5e5e0] text-[11px]"
              >↓</kbd
            >
            to navigate sections,
            <kbd
              class="px-1 py-px bg-[#f5f5f2] rounded border border-[#e5e5e0] text-[11px]"
              >Home</kbd
            >/
            <kbd
              class="px-1 py-px bg-[#f5f5f2] rounded border border-[#e5e5e0] text-[11px]"
              >End</kbd
            > for start/end.
          </div>
          <div class="pl-[2.2ch] mt-3">
            <a
              href="https://huggingface.co/changelog/agent-trace-viewer"
              target="_blank"
              rel="noopener noreferrer"
              class="text-[#8b5cf6] hover:underline"
              >→ learn more about the agent trace viewer on Hugging Face</a
            >
          </div>
        </div>
      {/if}

      {#each messages as msg, i (i)}
        {#if msg._visible}
          {@const focused = i === focusedIdx}
          <!-- svelte-ignore a11y_click_events_have_key_events -->
          <!-- svelte-ignore a11y_no_static_element_interactions -->
          <div
            data-idx={i}
            onclick={() => (focusedIdx = i)}
            class="py-1 cursor-default rounded transition-colors animate-fade-in {focused
              ? 'bg-[#fffbe6]'
              : 'hover:bg-[#faf9f5]'}"
          >
            <!-- header -->
            <div class="flex items-baseline gap-2 px-2">
              <span
                class="w-[1ch] text-center {focused
                  ? 'text-[#0f7a3a] animate-ready-pulse'
                  : 'text-[#6b6b68]'}"
              >
                {focused ? '●' : '○'}
              </span>
              <span
                class="text-[11px] uppercase tracking-wider font-semibold {roleColor[
                  msg.role
                ] || roleColor.unknown}"
              >
                {msg.role}
              </span>
              {#if msg.title}
                <span class="text-[12px] text-[#6a6a66] truncate"
                  >{msg.title}</span
                >
              {/if}
              <span class="ml-auto flex items-baseline gap-3">
                {#if msg.model}
                  <span class="text-[11px] text-[#888]">{msg.model}</span>
                {/if}
                <span class="text-[11px] text-[#aaa]">#{i}</span>
              </span>
            </div>

            <!-- blocks (only show revealed ones) -->
            {#each msg.blocks as block, bi}
              {#if bi < msg._visibleBlocks}
                {@const isLast = bi === msg.blocks.length - 1}
                <div class="flex items-start gap-2 px-2 animate-fade-in">
                  <span class="w-[1ch] text-[#b3b3ad] shrink-0 mt-[2px]"
                    >{isLast ? '└' : '├'}</span
                  >
                  <div class="flex-1 min-w-0">
                    {#if block.kind === 'text'}
                      {#if block._typing || block._typedText !== block.text}
                        <pre
                          class="whitespace-pre-wrap break-words text-[13px] text-[#232323] leading-[1.65] font-mono">{block._typedText}{#if block._typing}<span
                              class="animate-blink text-[#8b5cf6]"
                              aria-hidden="true">▎</span
                            >{/if}</pre>
                      {:else}
                        <div
                          class="prose-trace text-[13px] text-[#232323] leading-[1.65]"
                        >
                          {@html renderMarkdown(block.text)}
                        </div>
                      {/if}
                    {:else if block.kind === 'thinking'}
                      <details class="py-0.5" open>
                        <summary
                          class="cursor-pointer text-[11px] text-[#8b5cf6] font-semibold select-none hover:underline"
                          >thinking</summary
                        >
                        {#if block._typing || block._typedText !== block.text}
                          <pre
                            class="whitespace-pre-wrap break-words text-[12px] text-[#6b21a8] mt-1 leading-[1.65] pl-[1ch] border-l border-[#e9d5ff]">{block._typedText}{#if block._typing}<span
                                class="animate-blink text-[#8b5cf6]"
                                aria-hidden="true">▎</span
                              >{/if}</pre>
                        {:else}
                          <div
                            class="prose-trace prose-thinking text-[12px] text-[#6b21a8] mt-1 leading-[1.65] pl-[1ch] border-l border-[#e9d5ff]"
                          >
                            {@html renderMarkdown(block.text)}
                          </div>
                        {/if}
                      </details>
                    {:else if block.kind === 'tool_call'}
                      <div class="py-0.5">
                        <div class="text-[12px] text-[#6b21a8]">
                          <span class="text-[#aaa]">tool</span>
                          <span class="font-semibold">{block.name}</span>
                        </div>
                        <pre
                          class="text-[12px] text-[#3a3a38] whitespace-pre-wrap break-words mt-0.5 pl-[1ch] border-l border-[#e9d5ff]">{formatJson(
                            block.input
                          )}</pre>
                      </div>
                    {:else if block.kind === 'tool_result'}
                      <div class="py-0.5">
                        <div
                          class="text-[12px] {block.isError
                            ? 'text-[#991b1b]'
                            : 'text-[#6a6a66]'}"
                        >
                          <span class="text-[#aaa]">result</span>
                          {#if block.isError}<span class="font-semibold"
                              >· error</span
                            >{/if}
                        </div>
                        <pre
                          class="text-[12px] text-[#3a3a38] whitespace-pre-wrap break-words mt-0.5 pl-[1ch] border-l {block.isError
                            ? 'border-[#fecaca]'
                            : 'border-[#e5e5e0]'}">{block.text}</pre>
                      </div>
                    {:else if block.kind === 'image'}
                      <div class="text-[12px] text-[#6a6a66] italic">
                        [image attachment]
                      </div>
                    {:else if block.kind === 'raw'}
                      <details>
                        <summary
                          class="cursor-pointer text-[11px] text-[#888] select-none hover:underline"
                          >raw</summary
                        >
                        <pre
                          class="text-[11px] text-[#555] whitespace-pre-wrap break-words mt-1 pl-[1ch] border-l border-[#e5e5e0]">{formatJson(
                            block.json
                          )}</pre>
                      </details>
                    {/if}
                  </div>
                </div>
              {/if}
            {/each}
          </div>
        {/if}
      {/each}
    </div>

    <!-- footer -->
    <div
      class="flex items-center gap-4 px-5 py-2 border-t border-[#eeeae0] text-[11px] text-[#888] shrink-0"
    >
      {#if messages.length > 0}
        <span>{focusedIdx + 1} / {messages.length}</span>
      {:else}
        <span>ready</span>
      {/if}
      {#if playing}
        <button
          onclick={skip}
          class="px-2 py-0.5 bg-[#f5f5f2] rounded border border-[#e5e5e0] text-[11px] hover:bg-[#eeeae0] cursor-pointer"
          >skip</button
        >
      {:else}
        <span class="flex items-center gap-1">
          <kbd class="px-1 py-px bg-[#f5f5f2] rounded border border-[#e5e5e0]"
            >↑</kbd
          >
          <kbd class="px-1 py-px bg-[#f5f5f2] rounded border border-[#e5e5e0]"
            >↓</kbd
          > navigate
        </span>
        <span class="flex items-center gap-1">
          <kbd class="px-1 py-px bg-[#f5f5f2] rounded border border-[#e5e5e0]"
            >Home</kbd
          >
          <kbd class="px-1 py-px bg-[#f5f5f2] rounded border border-[#e5e5e0]"
            >End</kbd
          > jump
        </span>
      {/if}
      <a
        href="https://huggingface.co/changelog/agent-trace-viewer"
        target="_blank"
        rel="noopener noreferrer"
        class="ml-auto text-[#8b5cf6] hover:underline"
        title="HF changelog · agent trace viewer"
        >changelog →</a
      >
    </div>
  </div>
</div>
