<script setup lang="ts">
import type { AuditEvent } from '~/types/audit'

// Local UI state for filters. Drives the params we pass to /api/audit.
const search = ref('')
const actionFilter = ref('')        // exact or prefix (e.g. 'tenant')
const outcomeFilter = ref<'' | 'success' | 'failure'>('')
const tenantFilter = ref('')

const params = computed<Record<string, string>>(() => {
  const p: Record<string, string> = {}
  if (search.value.trim()) p.q = search.value.trim()
  if (actionFilter.value) p.action = actionFilter.value
  if (outcomeFilter.value) p.outcome = outcomeFilter.value
  if (tenantFilter.value.trim()) p.tenantSlug = tenantFilter.value.trim()
  return p
})

const { data: events, pending, refresh } = await useFetch<AuditEvent[]>('/api/audit', {
  default: () => [],
  query: params,
})

// Pagination via the `before` cursor — request the next page using the
// timestamp of the last event we have.
const olderPages = ref<AuditEvent[]>([])
const reachedEnd = ref(false)

const allEvents = computed(() => [...(events.value ?? []), ...olderPages.value])

// Reset older-page state whenever the live filter set changes; otherwise
// older results from a previous filter combination linger.
watch(params, () => {
  olderPages.value = []
  reachedEnd.value = false
})

async function loadMore() {
  const last = allEvents.value[allEvents.value.length - 1]
  if (!last) return
  const next = await $fetch<AuditEvent[]>('/api/audit', {
    query: { ...params.value, before: last.at },
  })
  if (!next.length) {
    reachedEnd.value = true
    return
  }
  olderPages.value.push(...next)
}

// Quick-pick filter chips that map to common queries.
const QUICK_ACTIONS = [
  { label: 'All actions',  value: '' },
  { label: 'Tenants',      value: 'tenant' },
  { label: 'Partners',     value: 'partner' },
  { label: 'Flags',        value: 'flag' },
  { label: 'Users',        value: 'user' },
]

function fmtAbs(iso: string) {
  const d = new Date(iso)
  return d.toLocaleString('da-DK', { dateStyle: 'short', timeStyle: 'medium' })
}
function tone(e: AuditEvent): 'info' | 'warn' | 'bad' | 'ok' {
  if (e.outcome === 'failure') return 'bad'
  if (e.action.includes('suspended') || e.action.includes('killed') || e.action.includes('deleted') || e.action.includes('terminated')) return 'warn'
  return 'info'
}
function shortIp(ip?: string) {
  if (!ip) return '—'
  // Strip v4-in-v6 prefix that node sockets sometimes report (::ffff:1.2.3.4 → 1.2.3.4)
  return ip.replace(/^::ffff:/, '')
}

// Row expansion — reveals metadata.changes + any other metadata keys for the
// row. Kept as a plain Set so toggling stays O(1) and the operator can have
// multiple rows open at once.
const expanded = ref(new Set<string>())
function toggleRow(id: string) {
  if (expanded.value.has(id)) expanded.value.delete(id)
  else expanded.value.add(id)
  // Force reactivity — Set mutations aren't tracked by Vue directly.
  expanded.value = new Set(expanded.value)
}
function hasDetails(e: AuditEvent): boolean {
  return !!e.metadata && Object.keys(e.metadata).length > 0
}
function formatMetaValue(v: unknown): string {
  if (v === null || v === undefined) return '—'
  if (Array.isArray(v)) return v.join(', ')
  if (typeof v === 'object') return JSON.stringify(v)
  return String(v)
}

// Compact human-readable rendering of a single side of a diff. Objects collapse
// to JSON; null / undefined / empty string render as a dim em-dash so the
// "before" side of a freshly populated field reads as clearly empty.
function formatDiffValue(v: unknown): string {
  if (v === null || v === undefined || v === '') return '—'
  if (typeof v === 'object') return JSON.stringify(v)
  return String(v)
}

// `metadata.diff` shape for partner.updated (and future *.updated actions):
// { fieldName: { from, to } }. Type-narrow safely; old audit rows that only
// carried `metadata.changes: string[]` are still rendered as plain chips by
// the fallback branch in the template.
interface DiffEntry { from: unknown; to: unknown }
function diffEntries(meta: Record<string, unknown> | undefined): [string, DiffEntry][] {
  const d = meta?.diff
  if (!d || typeof d !== 'object') return []
  return Object.entries(d as Record<string, DiffEntry>)
}

// ── Tamper-evidence (Phase 3) + cold-storage archives (Phase 4) ────────
interface VerifyReport {
  ok: boolean
  totalEventsVerified: number
  checkpointsChecked: number
  latestCheckpointAt: string | null
  latestVerifiedSeq: number | null
  // Phase 4 additions — included in /audit/verify response.
  oldestHotSeq: number | null
  highestArchivedSeq: number | null
  break?:
    | { kind: 'event-hash-mismatch'; seq: number; expected: string; actual: string }
    | { kind: 'event-prev-hash-mismatch'; seq: number; expected: string; actual: string }
    | { kind: 'checkpoint-signature-mismatch'; headSeq: number }
}
interface CheckpointSummary {
  at: string | null
  headSeq: number | null
  headHash: string | null
  reason?: string
}
interface ArchiveBatch {
  _id: string
  archivedAt: string
  startSeq: number
  endSeq: number
  eventCount: number
  manifestSha256: string
  jsonlKey: string
  manifestKey: string
  bytesUncompressed: number
}

const { data: latestCp, refresh: refreshCp } = useLazyFetch<CheckpointSummary>(
  '/api/audit/checkpoint/latest',
  { default: () => ({ at: null, headSeq: null, headHash: null }), server: false },
)
const { data: archives, refresh: refreshArchives } = useLazyFetch<ArchiveBatch[]>(
  '/api/audit/archives',
  { default: () => [], server: false },
)
const verifyReport = ref<VerifyReport | null>(null)
const verifying = ref(false)
const archiving = ref(false)
const archiveResult = ref<{ ok: boolean; reason?: string; eventCount?: number; startSeq?: number; endSeq?: number } | null>(null)

async function runVerify() {
  verifying.value = true
  try {
    verifyReport.value = await $fetch<VerifyReport>('/api/audit/verify')
    await refreshCp()
  } finally {
    verifying.value = false
  }
}

async function forceCheckpoint() {
  await $fetch('/api/audit/checkpoint', { method: 'POST' })
  await refreshCp()
}

async function forceArchive() {
  archiving.value = true
  archiveResult.value = null
  try {
    archiveResult.value = await $fetch('/api/audit/archive/run', { method: 'POST' })
    await Promise.all([refreshArchives(), refresh()])
  } finally {
    archiving.value = false
  }
}

function fmtBytes(n: number): string {
  if (n < 1024) return `${n} B`
  if (n < 1024 * 1024) return `${(n / 1024).toFixed(1)} KB`
  return `${(n / 1024 / 1024).toFixed(1)} MB`
}

function fmtRelative(iso: string | null | undefined): string {
  if (!iso) return 'never'
  const ms = Date.now() - new Date(iso).getTime()
  if (ms < 60_000) return `${Math.floor(ms / 1000)}s ago`
  if (ms < 3_600_000) return `${Math.floor(ms / 60_000)}m ago`
  if (ms < 86_400_000) return `${Math.floor(ms / 3_600_000)}h ago`
  return new Date(iso).toLocaleDateString('da-DK')
}
</script>

<template>
  <div>
    <PageHeader
      eyebrow="Compliance"
      title="Global audit log"
      :subtitle="`${allEvents.length} event${allEvents.length === 1 ? '' : 's'} · every privileged action recorded by platform-api`"
    >
      <template #actions>
        <UiButton variant="secondary" :disabled="pending" @click="refresh()">
          <template #leading><UiIcon name="refresh" :size="13" /></template>
          Refresh
        </UiButton>
      </template>
    </PageHeader>

    <div class="stage">
      <div class="toolbar">
        <div class="search">
          <UiIcon name="search" :size="13" />
          <input v-model="search" placeholder="action, actor, target, tenant…" type="text" />
        </div>
        <div class="chips">
          <button
            v-for="q in QUICK_ACTIONS"
            :key="q.label"
            :class="['chip', { on: actionFilter === q.value }]"
            type="button"
            @click="actionFilter = q.value"
          >{{ q.label }}</button>
        </div>
        <div class="chips">
          <button :class="['chip', { on: outcomeFilter === '' }]" type="button" @click="outcomeFilter = ''">Any</button>
          <button :class="['chip', { on: outcomeFilter === 'success' }]" type="button" @click="outcomeFilter = 'success'">Success</button>
          <button :class="['chip', { on: outcomeFilter === 'failure' }]" type="button" @click="outcomeFilter = 'failure'">Failure</button>
        </div>
        <Mono dim class="streaming">
          <StatusDot color="var(--ok)" :size="6" :glow="false" />
          live · backed by Mongo
        </Mono>
      </div>

      <Card :pad="0">
        <table v-if="allEvents.length">
          <thead>
            <tr>
              <th class="caret-col"></th>
              <th>Time</th>
              <th>Actor</th>
              <th>Action</th>
              <th>Target</th>
              <th>Tenant</th>
              <th>IP</th>
              <th class="r">Result</th>
            </tr>
          </thead>
          <tbody>
            <template v-for="e in allEvents" :key="e._id">
              <tr
                :class="['row', { expandable: hasDetails(e), open: expanded.has(e._id) }]"
                @click="hasDetails(e) && toggleRow(e._id)"
              >
                <td class="caret-col">
                  <UiIcon
                    v-if="hasDetails(e)"
                    :name="expanded.has(e._id) ? 'chevDown' : 'chevRight'"
                    :size="12"
                  />
                </td>
                <td><Mono>{{ fmtAbs(e.at) }}</Mono></td>
                <td class="actor">
                  <div v-if="e.actorType === 'system'" class="sys">sys</div>
                  <Avatar v-else :name="e.actorEmail || '?'" :size="22" />
                  <div>
                    <div class="name">{{ e.actorEmail || 'system' }}</div>
                    <Mono dim>{{ e.source }}</Mono>
                  </div>
                </td>
                <td><Mono class="action">{{ e.action }}</Mono></td>
                <td><span class="target">{{ e.resourceName || e.resourceId || '—' }}</span></td>
                <td>
                  <Mono v-if="e.tenantSlug">{{ e.tenantSlug }}</Mono>
                  <Mono v-else dim>—</Mono>
                </td>
                <td><Mono dim>{{ shortIp(e.actorIp) }}</Mono></td>
                <td class="r">
                  <Badge :tone="tone(e)" dot>
                    {{ e.outcome === 'failure' ? 'fail' : e.outcome === 'success' ? 'ok' : '—' }}
                  </Badge>
                </td>
              </tr>
              <tr v-if="expanded.has(e._id) && hasDetails(e)" class="detail-row">
                <td></td>
                <td colspan="7">
                  <!-- New format: { diff: { field: {from, to} } } — render as a
                       table of before → after rows so the operator can read the
                       change inline. -->
                  <table v-if="diffEntries(e.metadata).length" class="diff-table">
                    <tbody>
                      <tr v-for="[field, entry] in diffEntries(e.metadata)" :key="field">
                        <td class="diff-key"><Mono>{{ field }}</Mono></td>
                        <td class="diff-from"><Mono dim>{{ formatDiffValue(entry.from) }}</Mono></td>
                        <td class="diff-arrow"><Mono dim>→</Mono></td>
                        <td class="diff-to"><Mono>{{ formatDiffValue(entry.to) }}</Mono></td>
                      </tr>
                    </tbody>
                  </table>
                  <!-- Generic metadata renderer — covers older events that only
                       have metadata.changes (string[]) plus all non-update
                       actions whose metadata is free-form key/values. -->
                  <dl v-else class="meta">
                    <template v-for="(value, key) in e.metadata" :key="key">
                      <dt><Mono dim>{{ key }}</Mono></dt>
                      <dd>
                        <span v-if="key === 'changes' && Array.isArray(value)" class="changes">
                          <Mono v-for="field in value as string[]" :key="field" class="change-chip">
                            {{ field }}
                          </Mono>
                        </span>
                        <Mono v-else>{{ formatMetaValue(value) }}</Mono>
                      </dd>
                    </template>
                  </dl>
                </td>
              </tr>
            </template>
          </tbody>
        </table>
        <div v-else class="empty"><Mono dim>// no events match the current filters</Mono></div>
      </Card>

      <div v-if="allEvents.length" class="footer">
        <UiButton v-if="!reachedEnd" variant="secondary" :disabled="pending" @click="loadMore">
          Load older events
        </UiButton>
        <Mono v-else dim>// reached the start of the log</Mono>
      </div>

      <!-- Tamper-evidence panel — Phase 3 -->
      <Card :pad="0" class="verify-card">
        <div class="verify-head">
          <div>
            <Eyebrow>Tamper evidence</Eyebrow>
            <div class="cap">
              Hash chain · {{ latestCp?.headSeq != null ? `signed through seq ${latestCp.headSeq}` : 'no checkpoints yet' }}
            </div>
          </div>
          <div class="verify-actions">
            <UiButton variant="secondary" :disabled="verifying" @click="forceCheckpoint">
              Force checkpoint
            </UiButton>
            <UiButton variant="primary" :disabled="verifying" @click="runVerify">
              {{ verifying ? 'Verifying…' : 'Verify chain' }}
            </UiButton>
          </div>
        </div>

        <div class="verify-meta">
          <div class="kv"><Eyebrow>Last signed checkpoint</Eyebrow><Mono>{{ fmtRelative(latestCp?.at ?? null) }}</Mono></div>
          <div v-if="latestCp?.headHash" class="kv"><Eyebrow>Head hash</Eyebrow><Mono dim>{{ latestCp.headHash.slice(0, 16) }}…</Mono></div>
          <div v-if="latestCp?.reason" class="kv"><Eyebrow>Reason</Eyebrow><Mono dim>{{ latestCp.reason }}</Mono></div>
        </div>

        <!-- Verify result -->
        <div v-if="verifyReport" class="verify-result" :data-ok="verifyReport.ok">
          <div v-if="verifyReport.ok" class="result-line">
            <Badge tone="ok" dot>verified</Badge>
            <Mono>{{ verifyReport.totalEventsVerified }} event(s) · {{ verifyReport.checkpointsChecked }} checkpoint(s) · last seq {{ verifyReport.latestVerifiedSeq ?? '—' }}</Mono>
          </div>
          <div v-else class="result-line">
            <Badge tone="bad" dot>BROKEN</Badge>
            <Mono v-if="verifyReport.break?.kind === 'event-hash-mismatch'">
              event hash mismatch at seq {{ verifyReport.break.seq }} · stored {{ verifyReport.break.actual.slice(0, 16) }}… expected {{ verifyReport.break.expected.slice(0, 16) }}…
            </Mono>
            <Mono v-else-if="verifyReport.break?.kind === 'event-prev-hash-mismatch'">
              chain link broken at seq {{ verifyReport.break.seq }} · prevHash mismatch
            </Mono>
            <Mono v-else-if="verifyReport.break?.kind === 'checkpoint-signature-mismatch'">
              checkpoint signature mismatch at head seq {{ verifyReport.break.headSeq }}
            </Mono>
          </div>
        </div>
      </Card>

      <!-- Cold-storage archives — Phase 4 -->
      <Card :pad="0" class="archive-card">
        <div class="archive-head">
          <div>
            <Eyebrow>Cold storage</Eyebrow>
            <div class="cap">
              {{
                archives?.length
                  ? `archived through seq ${archives[0]!.endSeq} · ${archives.length} batch${archives.length === 1 ? '' : 'es'}`
                  : 'no archives yet · 90-day hot retention'
              }}
            </div>
          </div>
          <UiButton variant="secondary" :disabled="archiving" @click="forceArchive">
            {{ archiving ? 'Archiving…' : 'Run archive now' }}
          </UiButton>
        </div>

        <div v-if="archiveResult" class="archive-result" :data-ok="archiveResult.ok">
          <Badge v-if="archiveResult.ok && archiveResult.eventCount" tone="ok" dot>archived</Badge>
          <Badge v-else-if="archiveResult.ok" tone="info" dot>no-op</Badge>
          <Badge v-else tone="bad" dot>failed</Badge>
          <Mono v-if="archiveResult.ok && archiveResult.eventCount">
            {{ archiveResult.eventCount }} event(s) · seq {{ archiveResult.startSeq }}–{{ archiveResult.endSeq }}
          </Mono>
          <Mono v-else dim>{{ archiveResult.reason || '—' }}</Mono>
        </div>

        <table v-if="archives?.length">
          <thead>
            <tr>
              <th>Archived</th>
              <th>Seq range</th>
              <th>Events</th>
              <th>Size</th>
              <th>Manifest sha256</th>
            </tr>
          </thead>
          <tbody>
            <tr v-for="b in archives" :key="b._id">
              <td><Mono>{{ fmtAbs(b.archivedAt) }}</Mono></td>
              <td><Mono>{{ b.startSeq }}–{{ b.endSeq }}</Mono></td>
              <td><Mono>{{ b.eventCount }}</Mono></td>
              <td><Mono dim>{{ fmtBytes(b.bytesUncompressed) }}</Mono></td>
              <td><Mono dim>{{ b.manifestSha256.slice(0, 16) }}…</Mono></td>
            </tr>
          </tbody>
        </table>
        <div v-else class="empty"><Mono dim>// no archive batches yet — events stay in hot Mongo for {{ '90' }} days, then move to S3 (MinIO in dev / Hetzner in prod)</Mono></div>
      </Card>

      <Mono dim class="note">
        // hot tier: Mongo · cold tier: S3-compatible object storage ·
        sha256 hash-chain with HMAC-signed checkpoints + signed archive
        manifests · retention 90 days hot, indefinite cold · production
        encryption at rest is SSE-S3
      </Mono>
    </div>
  </div>
</template>

<style scoped>
.stage { padding: 24px 40px 64px 40px; display: flex; flex-direction: column; gap: 16px; }

.toolbar { display: flex; align-items: center; gap: 12px; flex-wrap: wrap; }
.search {
  display: inline-flex;
  align-items: center;
  gap: 8px;
  padding: 6px 12px;
  background: var(--surface);
  border: 1px solid var(--border);
  border-radius: 6px;
  color: var(--text-mute);
  min-width: 320px;
}
.search input {
  background: transparent;
  border: 0;
  outline: 0;
  flex: 1;
  font-family: inherit;
  font-size: 12px;
  color: var(--text);
}

.chips { display: flex; gap: 6px; flex-wrap: wrap; }
.chip {
  appearance: none;
  padding: 6px 10px;
  border: 1px solid var(--border);
  background: var(--surface);
  color: var(--text-dim);
  border-radius: 999px;
  font-family: inherit;
  font-size: 12px;
  cursor: pointer;
}
.chip:hover { color: var(--text); }
.chip.on { background: var(--text); color: var(--bg); border-color: var(--text); }

.streaming { display: inline-flex; align-items: center; gap: 8px; margin-left: auto; }

table { width: 100%; border-collapse: collapse; }
th {
  text-align: left;
  font-family: var(--font-mono);
  font-size: 9px;
  letter-spacing: 0.12em;
  text-transform: uppercase;
  color: var(--text-mute);
  padding: 12px 16px;
  font-weight: 500;
  border-bottom: 1px solid var(--border);
}
th.r, td.r { text-align: right; }
td { padding: 10px 16px; font-size: 12px; border-top: 1px solid var(--border); vertical-align: middle; }
td.actor { display: flex; align-items: center; gap: 10px; }

/* Expandable row: rows with metadata show a caret + become clickable. */
.caret-col { width: 28px; padding-left: 12px; padding-right: 0; color: var(--text-mute); }
tr.row.expandable { cursor: pointer; }
tr.row.expandable:hover { background: var(--surface); }
tr.row.open .caret-col { color: var(--text); }

tr.detail-row td {
  background: var(--surface);
  border-top: 0;
  padding: 10px 16px 14px 16px;
}
.meta {
  margin: 0;
  display: grid;
  grid-template-columns: max-content 1fr;
  gap: 6px 14px;
  align-items: baseline;
}
.meta dt { font-size: 10px; letter-spacing: 0.06em; text-transform: uppercase; }
.meta dd { margin: 0; font-size: 12px; color: var(--text-dim); }
.changes { display: inline-flex; flex-wrap: wrap; gap: 6px; }
.change-chip {
  padding: 2px 8px;
  border: 1px solid var(--border);
  border-radius: 999px;
  background: var(--bg);
  font-size: 11px;
  color: var(--text);
}

/* before → after diff table inside the expanded row */
.diff-table {
  width: auto;
  border-collapse: collapse;
  margin: 0;
}
.diff-table td {
  padding: 4px 14px 4px 0;
  border: 0;
  font-size: 12px;
  vertical-align: top;
}
.diff-key { font-weight: 500; min-width: 110px; }
.diff-from { color: var(--text-mute); max-width: 360px; word-break: break-word; }
.diff-arrow { width: 18px; text-align: center; }
.diff-to { max-width: 360px; word-break: break-word; }
.name { font-size: 12px; font-weight: 500; }
.action { font-weight: 500; }
.target { font-size: 12px; color: var(--text-dim); }
.sys {
  width: 22px; height: 22px;
  border-radius: 4px;
  background: var(--text);
  color: var(--bg);
  display: inline-flex;
  align-items: center;
  justify-content: center;
  font-size: 9px;
  font-family: var(--font-mono);
}

.empty { padding: 40px 20px; text-align: center; }
.footer { display: flex; justify-content: center; padding: 4px 0; }
.note { display: block; padding: 4px 4px 0 4px; }

/* Tamper-evidence panel */
.verify-card { margin-top: 8px; }
.verify-head {
  padding: 14px 18px;
  display: flex; justify-content: space-between; align-items: center;
  gap: 16px;
  border-bottom: 1px solid var(--border);
}
.verify-head .cap { font-family: var(--font-display); font-weight: 600; font-size: 15px; margin-top: 2px; }
.verify-actions { display: flex; gap: 8px; flex-shrink: 0; }

.verify-meta {
  padding: 12px 18px;
  display: flex; gap: 24px; flex-wrap: wrap;
}
.verify-meta .kv { display: flex; flex-direction: column; gap: 4px; }

.verify-result {
  padding: 12px 18px;
  border-top: 1px solid var(--border);
}
.verify-result[data-ok="true"]  { background: rgba(31, 138, 91, 0.05); }
.verify-result[data-ok="false"] { background: rgba(240, 88, 88, 0.06); }
.result-line { display: flex; align-items: center; gap: 12px; flex-wrap: wrap; }

/* Cold-storage archives panel */
.archive-card { margin-top: 8px; }
.archive-head {
  padding: 14px 18px;
  display: flex; justify-content: space-between; align-items: center;
  gap: 16px;
  border-bottom: 1px solid var(--border);
}
.archive-head .cap { font-family: var(--font-display); font-weight: 600; font-size: 15px; margin-top: 2px; }
.archive-result {
  padding: 10px 18px;
  display: flex; align-items: center; gap: 12px; flex-wrap: wrap;
  border-bottom: 1px solid var(--border);
}
.archive-result[data-ok="true"]  { background: rgba(31, 138, 91, 0.05); }
.archive-result[data-ok="false"] { background: rgba(240, 88, 88, 0.06); }
.archive-card table { width: 100%; border-collapse: collapse; }
.archive-card th, .archive-card td { padding: 10px 18px; font-size: 12px; text-align: left; }
.archive-card th { font-family: var(--font-mono); font-size: 9px; letter-spacing: 0.12em; text-transform: uppercase; color: var(--text-mute); font-weight: 500; border-bottom: 1px solid var(--border); }
.archive-card td { border-top: 1px solid var(--border); }
.archive-card .empty { padding: 16px 18px; }
</style>
