<script setup lang="ts">
import { ref, computed, watch, onMounted } from 'vue'
import { useDump, type RowView } from './composables/useDump'
import { useFrames } from './composables/useFrames'
import { hue, abbreviate } from './lib/type-hue'
import { listDumps, dropDump, type CachedDump } from './lib/persist'
import { listHosted, download, fileNameOf, type HostedDump } from './lib/hosted'
import { encodeView, decodeView, emptyView, type ViewState } from './lib/view-state'
import type { SortKey } from './lib/dump-store'
import HeaderBar from '@/components/HeaderBar.vue'
const d = useDump()
const frames = useFrames()

const nameQuery = ref('')
const pathQuery = ref('')
const pathPrefix = ref('')
const activeTypes = ref<number[]>([])
const sort = ref<SortKey>('natural')
const descending = ref(false)
const inspected = ref<RowView | null>(null)
const outerOf = ref<RowView | null>(null)
const cached = ref<CachedDump[]>([])
const hosted = ref<HostedDump[]>([])
const showAllTypes = ref(false)
const dragging = ref(false)
const treeVersion = ref(0)

/**
 * Filters carried in from a link, held until the dump they belong to is up.
 *
 * The hash is kept alongside them so that a recipient who did not have the
 * dump, and then loads the file by hand, still lands on the sender's view —
 * while someone who opens an unrelated dump instead does not inherit filters
 * meant for a different one.
 */
const pending = ref<{ hash: string; view: ViewState } | null>(null)

const fmt = new Intl.NumberFormat()

// ------------------------------------------------------------------ the view

/**
 * Type filters travel as names, not as the ids the store indexes by, so both
 * directions have to go through the dump's type table. A name the current
 * dump does not have simply drops out — a link from a newer dump should lose
 * the filter it cannot express, not fail to open.
 */
function currentView(): ViewState {
  return {
    prefix: pathPrefix.value,
    name: nameQuery.value.trim(),
    path: pathQuery.value.trim(),
    sort: sort.value,
    desc: descending.value,
    types: activeTypes.value
      .map((id) => d.types.value[id])
      .filter((n): n is string => !!n)
      .sort(),
  }
}

function applyView(v: ViewState) {
  pathPrefix.value = v.prefix
  nameQuery.value = v.name
  pathQuery.value = v.path
  sort.value = v.sort
  descending.value = v.desc
  activeTypes.value = v.types.map((n) => d.types.value.indexOf(n)).filter((i) => i >= 0)
}

function syncUrl(v: ViewState) {
  history.replaceState(null, '', encodeView(d.dumpKey.value, v))
}

let timer: ReturnType<typeof setTimeout>
function schedule() {
  clearTimeout(timer)
  timer = setTimeout(commit, 120)
}

/**
 * The one path by which a filter change becomes a frame, a URL and a query.
 *
 * Replaying a frame runs through here too, and needs no guard against
 * recording itself: after `applyView` the refs are *equal* to the frame at
 * the cursor, and `push` drops a frame identical to the current one.
 */
function commit() {
  const v = currentView()
  frames.push(v)
  syncUrl(v)
  run()
}

async function run() {
  if (!d.ready.value) return
  await d.runQuery({
    typeIds: activeTypes.value,
    pathPrefix: pathPrefix.value || undefined,
    nameContains: nameQuery.value.trim() || undefined,
    pathContains: pathQuery.value.trim() || undefined,
    sort: sort.value,
    descending: descending.value,
  })
}

watch([nameQuery, pathQuery], schedule)
watch([pathPrefix, activeTypes, sort, descending], commit, { deep: true })

/** A new dump invalidates every frame: the paths and types are a different
 *  vocabulary, so the queue restarts rather than carrying over. */
function resetTo(v: ViewState) {
  applyView(v)
  inspected.value = null
  outerOf.value = null
  showAllTypes.value = false
  treeVersion.value++
  frames.reset(currentView())
  syncUrl(frames.current.value)
  run()
}

watch(d.count, () => {
  const p = pending.value
  const claimed = !!p && p.hash === d.dumpKey.value
  resetTo(claimed ? p!.view : emptyView())
  if (claimed) {
    pending.value = null
    linkMiss.value = false
  }
  refreshCached()
})

function setSort(key: SortKey, desc: boolean) {
  sort.value = key
  descending.value = desc
}

function step(delta: -1 | 1) {
  const f = delta < 0 ? frames.back() : frames.forward()
  if (!f) return
  applyView(f)
  syncUrl(f)
  run()
}

const copied = ref(false)
async function copyLink() {
  try {
    await navigator.clipboard.writeText(location.href)
    copied.value = true
    setTimeout(() => (copied.value = false), 1400)
  } catch {
    /* clipboard blocked; the URL bar already shows the link */
  }
}

function toggleType(id: number) {
  const i = activeTypes.value.indexOf(id)
  i === -1 ? activeTypes.value.push(id) : activeTypes.value.splice(i, 1)
}

/** Types the current filter can actually reach, busiest first. */
const typeChips = computed(() => {
  const counts = d.typeCounts.value
  return d.types.value
    .map((name, id) => ({ id, name, count: counts[id] ?? 0 }))
    .filter((t) => t.count > 0 || activeTypes.value.includes(t.id))
    .sort((a, b) => b.count - a.count)
})
const shownChips = computed(() =>
  showAllTypes.value ? typeChips.value : typeChips.value.slice(0, 14),
)

async function inspect(row: RowView) {
  inspected.value = row
  outerOf.value = null
  if (row.outer) outerOf.value = (await d.resolveAddress(row.outer)).row
}

function jumpTo(path: string) {
  pathPrefix.value = path
  nameQuery.value = ''
  pathQuery.value = ''
}

async function onDrop(e: DragEvent) {
  dragging.value = false
  const file = e.dataTransfer?.files?.[0]
  if (file) await d.loadFile(file)
}

function onPick(e: Event) {
  const file = (e.target as HTMLInputElement).files?.[0]
  if (file) d.loadFile(file)
}

async function refreshCached() {
  cached.value = await listDumps().catch(() => [])
}

async function forget(key: string) {
  await dropDump(key)
  refreshCached()
}

// ---------------------------------------------------------------- site dumps

const grabbing = ref('')
const grabPct = ref<number | null>(0)
const grabError = ref('')

/** Only dumps this browser has not already taken. The cached copy records the
 *  manifest id it came from, which is what makes "already have it" answerable
 *  without re-hashing a 37 MB file. */
const offered = computed(() => {
  const have = new Set(cached.value.map((c) => c.source).filter(Boolean))
  return hosted.value.filter((h) => !have.has(h.id))
})

/**
 * Download on demand, never on load. One interaction fetches, caches, and
 * opens the dump, so the offer disappearing from this list is the same
 * gesture that puts it on screen.
 */
async function grab(h: HostedDump) {
  grabbing.value = h.id
  grabError.value = ''
  grabPct.value = 0
  try {
    const buffer = await download(h, (f) => (grabPct.value = f))
    await d.loadBuffer(buffer, fileNameOf(h), { format: h.format ?? 'text', source: h.id })
    await refreshCached()
  } catch (e) {
    grabError.value = (e as Error).message
  } finally {
    grabbing.value = ''
    grabPct.value = 0
  }
}

/** Unload the dump and drop back to the intro screen. The parsed copy stays
 *  in IndexedDB, so it reappears under "Already parsed". */
async function clearDump() {
  // The d.count watcher runs resetTo(), which clears every filter, the frame
  // queue and the URL. Only the things outside that surface are left here.
  await d.reset()
  dragging.value = false
}

/**
 * A link carries a dump hash and a set of filters, but never the dump — 37 MB
 * does not fit in a URL. The recipient either already has that dump in
 * IndexedDB, in which case it opens with the sender's filters intact, or they
 * do not, and they land on the normal intro with a note saying so.
 */
const linkMiss = ref(false)

onMounted(async () => {
  await refreshCached()
  listHosted()
    .then((h) => (hosted.value = h))
    .catch(() => {})

  const { dump, view } = decodeView(location.search)
  if (!dump) return
  pending.value = { hash: dump, view }
  if (!(await d.restore(dump))) {
    linkMiss.value = true
    // Not surfaced as an error: a link arriving without its dump is the
    // expected case, and `linkMiss` says so in plainer terms. The pending
    // view stays put so that loading the file by hand still lands on it.
    d.error.value = ''
  }
})

const tail = (s: string, max = 72) =>
  s.length <= max ? s : '…' + s.slice(s.length - max + 1)
const hex = (n: number) => '0x' + n.toString(16).toUpperCase().padStart(12, '0')
const mb = (n: number) => (n / 1e6).toFixed(1) + ' MB'
</script>

<template>
  <div class="app" @dragover.prevent="dragging = true" @dragleave="dragging = false" @drop.prevent="onDrop">
    
    <!-- header -->
    <header>
    <HeaderBar :onPick="onPick" :onClear="clearDump" :shows-clear="d.ready.value">      
      <div v-if="d.ready.value" class="readout mono">
        <span class="file">{{ d.label.value }}</span>
        <span class="sep">·</span>
        <span>{{ fmt.format(d.count.value) }} objects</span>
        <span class="sep">·</span>
        <span>{{ d.types.value.length }} types</span>
        <span v-if="d.skipped.value" class="sep">·</span>
        <span v-if="d.skipped.value" class="warn">{{ d.skipped.value }} unparsed lines</span>
      </div>
      
    </HeaderBar>

  </header>

    <!-- first run -->
    <section v-if="!d.ready.value" class="intro">
      <div class="intro-card">
        <p class="eyebrow">Start Here</p>
        <h1>Drop a UE4SS object dump.</h1>
        <NuxtLink href="https://github.com/UE4SS-RE/RE-UE4SS/blob/main/docs/feature-overview/dumpers.md" target="_blank">See documentation about dumpers here.</NuxtLink>
        <p class="lede">
          Drag and drop a UE4SS object dump below. This tool is not meant for unreal header dumps, but the actual object dump. It's generally a single large text file.
        </p>     
         <p class="lede">
         Dumps are saved locally. While this tool was created for Palworld, it will probably work for most other UE4SS supported games.
        </p>     
        <label class="drop" :class="{ hot: dragging }">
          <input type="file" accept=".txt,.log,.dump" hidden @change="onPick" />
          <span v-if="!d.loading.value" class="mono">Drop UE4SS_ObjectDump.txt here</span>
          <span v-else class="mono">Parsing… {{ Math.round(d.progress.value * 100) }}%</span>
          <div v-if="d.loading.value" class="meter">
            <i :style="{ width: d.progress.value * 100 + '%' }" />
          </div>
        </label>

        <p v-if="d.error.value" class="err mono">{{ d.error.value }}</p>
        <p v-if="linkMiss" class="err mono">
          That link points at a dump this browser has not loaded yet. Open the same
          file below or drop it above, and the link will work from then on.
        </p>

        <div v-if="offered.length" class="cached">
          <p class="eyebrow">Available from this site</p>
          <div v-for="h in offered" :key="h.id" class="cache-row mono">
            <span class="cache-name">{{ h.title }}</span>
            <span class="cache-meta">
              <template v-if="h.bytes">{{ mb(h.bytes) }}</template>
              <template v-if="h.note"> · {{ h.note }}</template>
            </span>
            <button
              class="get"
              :disabled="!!grabbing"
              @click="grab(h)"
            >
              <template v-if="grabbing !== h.id">download</template>
              <template v-else-if="grabPct === null">downloading…</template>
              <template v-else>{{ Math.round(grabPct * 100) }}%</template>
            </button>
          </div>
          <div v-if="grabbing" class="meter thin">
            <i :style="{ width: (grabPct ?? 0) * 100 + '%' }" />
          </div>
          <p v-if="grabError" class="err mono">{{ grabError }}</p>
        </div>

        <div v-if="cached.length" class="cached">
          <p class="eyebrow">Already parsed</p>
          <button v-for="c in cached" :key="c.key" class="cache-row mono" @click="d.restore(c.key)">
            <span class="cache-name">{{ c.label }}</span>
            <span class="cache-meta">{{ fmt.format(c.cols.count) }} objects · {{ mb(c.bytes) }}</span>
            <span class="cache-drop" @click.stop="forget(c.key)">forget</span>
          </button>
        </div>
      </div>
    </section>

    <!-- explorer -->
    <main v-else class="body">
      <PathTree
        v-model="pathPrefix"
        :fetch-children="d.fetchChildren"
        :version="treeVersion"
      />

      <section class="main">
        <div class="controls">
          <div class="searches">
            <!-- Filter history. Every change to the query surface lands here as
                 a frame, so stepping back is stepping back through views. -->
            <div class="frames mono">
              <button
                :disabled="!frames.canBack.value"
                title="Previous view"
                aria-label="Previous view"
                @click="step(-1)"
              >
                ‹
              </button>
              <span class="tally" :title="`Frame ${frames.index.value + 1} of ${frames.frames.value.length}`">
                {{ frames.index.value + 1 }}/{{ frames.frames.value.length }}
              </span>
              <button
                :disabled="!frames.canForward.value"
                title="Next view"
                aria-label="Next view"
                @click="step(1)"
              >
                ›
              </button>
            </div>

            <input v-model="nameQuery" placeholder="search name…" spellcheck="false" />
            <input v-model="pathQuery" placeholder="search full path…" spellcheck="false" />

            <button class="share mono" title="Copy a link to this exact view" @click="copyLink">
              {{ copied ? 'copied' : 'link' }}
            </button>
          </div>

          <div v-if="pathPrefix" class="crumb mono">
            <span class="eyebrow">under</span>
            <span class="crumb-path" :title="pathPrefix">{{ tail(pathPrefix) }}</span>
            <button @click="pathPrefix = ''">×</button>
          </div>

          <div class="chips">
            <button
              v-for="t in shownChips"
              :key="t.id"
              class="chip mono"
              :class="{ on: activeTypes.includes(t.id) }"
              :style="{ '--chip': hue(t.name) }"
              :title="t.name"
              @click="toggleType(t.id)"
            >
              <i class="tick" />{{ abbreviate(t.name) }}
              <b>{{ fmt.format(t.count) }}</b>
            </button>
            <button
              v-if="typeChips.length > 14"
              class="chip more"
              @click="showAllTypes = !showAllTypes"
            >
              {{ showAllTypes ? 'fewer' : `+${typeChips.length - 14} more` }}
            </button>
          </div>
        </div>

        <ObjectTable
          :total="d.total.value"
          :shared-digits="d.sharedAddressDigits.value"
          :sort="sort"
          :descending="descending"
          :fetch-window="d.fetchWindow"
          @inspect="inspect"
          @sort="setSort"
        />

        <footer class="status mono">
          <span>{{ fmt.format(d.total.value) }} / {{ fmt.format(d.count.value) }} shown</span>
          <span class="sep">·</span>
          <span>{{ d.queryMs.value.toFixed(0) }} ms</span>
          <span v-if="d.parseMs.value" class="sep">·</span>
          <span v-if="d.parseMs.value">parsed in {{ (d.parseMs.value / 1000).toFixed(1) }} s</span>
        </footer>
      </section>

      <!-- inspector -->
      <aside v-if="inspected" class="inspect">
        <div class="inspect-head">
          <span class="eyebrow">Object</span>
          <button @click="inspected = null">×</button>
        </div>
        <dl class="mono">
          <dt>type</dt>
          <dd :style="{ color: hue(inspected.type) }">{{ inspected.type }}</dd>
          <dt>name</dt>
          <dd class="strong">{{ inspected.name }}</dd>
          <dt>address</dt>
          <dd class="addr">{{ hex(inspected.address) }}</dd>
          <dt v-if="inspected.offset >= 0">offset</dt>
          <dd v-if="inspected.offset >= 0">
            0x{{ inspected.offset.toString(16).toUpperCase() }}
            <i class="dec">({{ inspected.offset }})</i>
          </dd>
          <dt>path</dt>
          <dd class="wrap">{{ inspected.path }}</dd>
          <dt v-if="outerOf">owner</dt>
          <dd v-if="outerOf" class="wrap">
            <button class="link" @click="jumpTo(outerOf.path)">{{ outerOf.path }}</button>
          </dd>
        </dl>
        <button class="scope" @click="jumpTo(inspected.path)">Scope tree to this object</button>
      </aside>
    </main>

    <div v-if="dragging && d.ready.value" class="veil mono">Release to parse a new dump</div>
  </div>
</template>

<style scoped>
.app {
  height: 100%;
  display: flex;
  flex-direction: column;
  position: relative;
}

/* first run */
.intro {
  flex: 1;
  display: grid;
  place-items: center;
  padding: 24px;
  overflow: auto;
}
.intro-card {
  width: min(560px, 100%);
}
.intro h1 {
  font-size: 30px;
  font-weight: 700;
  letter-spacing: -0.025em;
  margin: 10px 0 12px;
  color: var(--ink-strong);
}
.lede {
  color: var(--dim);
  line-height: 1.65;
  margin: 10px 0 22px;
  max-width: 52ch;
}

.drop {
  display: grid;
  place-items: center;
  gap: 12px;
  height: 130px;
  border: 1px dashed var(--rule);
  border-radius: 4px;
  color: var(--dim);
  font-size: 12px;
  cursor: pointer;
  transition: border-color 0.15s, color 0.15s;
}
.drop:hover,
.drop.hot {
  border-color: var(--amber);
  color: var(--amber);
}

.meter {
  width: 260px;
  height: 2px;
  background: var(--rule);
}
.meter i {
  display: block;
  height: 100%;
  background: var(--amber);
  transition: width 0.1s linear;
}

.err {
  color: var(--t-bool);
  font-size: 12px;
  margin-top: 14px;
}

.cached {
  margin-top: 28px;
}
.cache-row {
  display: flex;
  align-items: center;
  gap: 12px;
  width: 100%;
  padding: 9px 0;
  border-bottom: 1px solid var(--rule-soft);
  font-size: 12px;
  text-align: left;
}
.cache-row:hover .cache-name {
  color: var(--amber);
}
.cache-name {
  color: var(--ink-strong);
}
.cache-meta {
  color: var(--dimmer);
  font-size: 11px;
  margin-left: auto;
}
.cache-drop {
  color: var(--dimmer);
  font-size: 10px;
  text-transform: uppercase;
  letter-spacing: 0.08em;
}
.cache-drop:hover {
  color: var(--t-bool);
}

.get {
  flex: none;
  font-family: inherit;
  font-size: 10px;
  letter-spacing: 0.08em;
  text-transform: uppercase;
  color: var(--amber);
  background: none;
  border: 1px solid var(--rule);
  border-radius: 3px;
  padding: 4px 9px;
  cursor: pointer;
}
.get:hover:not(:disabled) {
  border-color: var(--amber);
}
.get:disabled {
  color: var(--dimmer);
  cursor: default;
}

.meter.thin {
  width: 100%;
  margin-top: 8px;
}

/* explorer */
.body {
  flex: 1;
  display: flex;
  min-height: 0;
}

.main {
  flex: 1;
  display: flex;
  flex-direction: column;
  min-width: 0;
  min-height: 0;
}

.controls {
  flex: none;
  padding: 10px 12px;
  border-bottom: 1px solid var(--rule);
  display: flex;
  flex-direction: column;
  gap: 9px;
}

.searches {
  display: flex;
  gap: 8px;
  align-items: stretch;
}
.searches input {
  flex: 1;
  min-width: 0;
}

.frames {
  display: flex;
  align-items: center;
  flex: none;
  border: 1px solid var(--rule);
  border-radius: 3px;
  overflow: hidden;
}
.frames button {
  width: 22px;
  align-self: stretch;
  font-size: 15px;
  line-height: 1;
  color: var(--amber);
  background: none;
  border: none;
  cursor: pointer;
}
.frames button:disabled {
  color: var(--dimmer);
  cursor: default;
}
.frames button:not(:disabled):hover {
  background: var(--raise);
}
.tally {
  font-size: 10px;
  color: var(--dim);
  padding: 0 6px;
  border-left: 1px solid var(--rule);
  border-right: 1px solid var(--rule);
  align-self: stretch;
  display: flex;
  align-items: center;
}

.share {
  flex: none;
  font-size: 10px;
  letter-spacing: 0.08em;
  text-transform: uppercase;
  color: var(--dim);
  background: none;
  border: 1px solid var(--rule);
  border-radius: 3px;
  padding: 0 10px;
  cursor: pointer;
}
.share:hover {
  color: var(--amber);
  border-color: var(--amber);
}

.crumb {
  display: flex;
  align-items: center;
  gap: 8px;
  font-size: 11px;
  color: var(--amber);
}
.crumb-path {
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}
.crumb button {
  color: var(--dim);
  flex: none;
}

.chips {
  display: flex;
  flex-wrap: wrap;
  gap: 5px;
}
.chip {
  display: flex;
  align-items: center;
  gap: 5px;
  font-size: 11px;
  padding: 3px 8px;
  border-radius: 2px;
  border: 1px solid var(--rule);
  color: var(--dim);
}
.chip .tick {
  width: 3px;
  height: 9px;
  border-radius: 1px;
  background: var(--chip);
}
.chip b {
  font-weight: 400;
  color: var(--dimmer);
}
.chip:hover {
  border-color: var(--chip);
  color: var(--ink);
}
.chip.on {
  border-color: var(--chip);
  color: var(--ink-strong);
  background: color-mix(in srgb, var(--chip) 12%, transparent);
}
.chip.more {
  color: var(--dim);
  border-style: dashed;
}

.status {
  flex: none;
  display: flex;
  gap: 8px;
  align-items: center;
  height: 26px;
  padding: 0 12px;
  font-size: 11px;
  color: var(--dim);
  background: var(--panel);
  border-top: 1px solid var(--rule);
}

/* inspector */
.inspect {
  width: 320px;
  flex: none;
  background: var(--panel);
  border-left: 1px solid var(--rule);
  padding: 0 0 14px;
  overflow: auto;
}
.inspect-head {
  display: flex;
  align-items: center;
  justify-content: space-between;
  padding: 10px 12px;
  border-bottom: 1px solid var(--rule);
}
.inspect-head button {
  color: var(--dim);
}

.inspect dl {
  margin: 0;
  padding: 12px;
  font-size: 12px;
}
.inspect dt {
  font-size: 10px;
  letter-spacing: 0.12em;
  text-transform: uppercase;
  color: var(--dimmer);
  margin-top: 12px;
}
.inspect dt:first-child {
  margin-top: 0;
}
.inspect dd {
  margin: 3px 0 0;
  color: var(--ink);
}
.inspect dd.strong {
  color: var(--ink-strong);
}
.inspect dd.addr {
  color: var(--amber);
}
.inspect dd.wrap {
  word-break: break-all;
  line-height: 1.5;
}
.dec {
  color: var(--dimmer);
  font-style: normal;
}
.link {
  color: var(--t-container);
  text-align: left;
  word-break: break-all;
  line-height: 1.5;
}
.link:hover {
  text-decoration: underline;
}

.scope {
  margin: 4px 12px 0;
  width: calc(100% - 24px);
  font-size: 11px;
  letter-spacing: 0.06em;
  text-transform: uppercase;
  color: var(--amber);
  border: 1px solid var(--rule);
  border-radius: 3px;
  padding: 7px;
}
.scope:hover {
  border-color: var(--amber);
}

.veil {
  position: absolute;
  inset: 0;
  display: grid;
  place-items: center;
  background: color-mix(in srgb, var(--void) 82%, transparent);
  color: var(--amber);
  font-size: 13px;
  border: 1px dashed var(--amber);
  pointer-events: none;
}

@media (max-width: 900px) {
  .body {
    flex-direction: column;
  }
  .inspect {
    width: 100%;
    border-left: none;
    border-top: 1px solid var(--rule);
    max-height: 45vh;
  }
  .readout {
    display: none;
  }
}
</style>
