<template>
  <div>
    <div class="fc-dl__sticky">
      <div class="fc-dl__top">
        <DownloadStatChips
          :stats="store.stats"
          :active-status="filterModel.status"
          @select="onStatusChipSelect"
        />
        <span v-if="liveActive" class="fc-dl__live" title="Auto-refreshing while downloads are active">
          <span class="fc-dl__live-dot" />
          live
        </span>
        <v-spacer />
        <DownloadActivitySparkline
          v-if="store.activity.buckets.length"
          :buckets="store.activity.buckets"
          :hours="store.activity.hours"
          class="fc-dl__spark"
        />
        <v-btn variant="text" icon @click="refresh">
          <v-icon>mdi-refresh</v-icon>
          <v-tooltip activator="parent" location="top">Refresh</v-tooltip>
        </v-btn>
        <MaintenanceMenu
          @retry-failed="onRetryAll(store.failing)"
          @recover-stalled="onRecoverStalled"
        />
      </div>

      <div class="fc-dl__controls">
        <v-text-field
          v-model="search"
          density="compact" variant="outlined" hide-details clearable
          prepend-inner-icon="mdi-magnify"
          placeholder="Search artist / platform / error"
          style="max-width: 340px"
        />
        <DownloadsFilterPopover v-model="filterModel" />
        <v-switch
          v-model="showNoChange"
          label="Show no-change scans"
          density="compact" hide-details color="accent"
          class="fc-dl__nochange"
        />
      </div>
    </div>

    <ActiveDownloadsPanel />

    <v-alert v-if="store.error" type="error" variant="tonal" closable class="my-4">
      {{ String(store.error) }}
    </v-alert>

    <!-- The failing-sources rollup moved to the Subscriptions landing tab
         (needs-attention strip, 2026-07-02); the maintenance menu above keeps
         its bulk-retry via the same shared store action. -->

    <div v-if="store.loading && store.events.length === 0" class="fc-dl__loading">
      <v-progress-circular indeterminate color="accent" size="36" />
    </div>

    <div v-else-if="filteredEvents.length === 0" class="fc-dl__empty">
      <p>No download events match the current filter.</p>
    </div>

    <div v-else>
      <section
        v-for="g in groups" :key="g.key"
        class="fc-dl__group"
      >
        <header
          class="fc-dl__group-head"
          role="button" tabindex="0"
          @click="toggle(g.key)" @keydown.enter="toggle(g.key)"
        >
          <v-icon size="small" class="fc-dl__group-chev">
            {{ collapsed[g.key] ? 'mdi-chevron-right' : 'mdi-chevron-down' }}
          </v-icon>
          <span class="fc-dl__group-label">{{ g.label }}</span>
          <span class="fc-dl__group-counts">
            <v-chip
              v-if="g.failedCount > 0"
              size="x-small" color="error" variant="tonal"
              prepend-icon="mdi-alert-circle"
            >{{ g.failedCount }}</v-chip>
            <v-chip size="x-small" variant="tonal">
              {{ g.items.length }}
              {{ g.items.length === 1 ? 'event' : 'events' }}
            </v-chip>
          </span>
        </header>
        <div v-if="!collapsed[g.key]" class="fc-dl__group-body">
          <DownloadEventRow
            v-for="e in g.items" :key="e.id" :event="e"
            @open="openDetail"
          />
        </div>
      </section>

      <div class="fc-dl__sentinel">
        <v-btn v-if="store.hasMore" variant="text" @click="store.loadMore()" :loading="store.loading">
          Load more
        </v-btn>
        <span v-else class="text-caption" style="opacity: 0.5">No more events.</span>
      </div>
    </div>

    <DownloadDetailModal
      :event="store.selected"
      @close="store.closeDetail()"
    />
  </div>
</template>

<script setup>
import { toast } from '../../utils/toast.js'
import { computed, onMounted, onUnmounted, reactive, ref, watch } from 'vue'
import { useRoute } from 'vue-router'

import { useDownloadsStore } from '../../stores/downloads.js'
import DownloadEventRow from '../downloads/DownloadEventRow.vue'
import DownloadDetailModal from '../downloads/DownloadDetailModal.vue'
import DownloadStatChips from './DownloadStatChips.vue'
import DownloadActivitySparkline from './DownloadActivitySparkline.vue'
import ActiveDownloadsPanel from './ActiveDownloadsPanel.vue'
import MaintenanceMenu from './MaintenanceMenu.vue'
import DownloadsFilterPopover from './DownloadsFilterPopover.vue'

const route = useRoute()
const store = useDownloadsStore()
const filterModel = ref({ ...store.filter })

// Free-text search (client-side over the loaded events) + a toggle to
// hide "no-change" scheduled scans (status=ok/skipped with 0 files) so
// real downloads + failures stand out. Operator-flagged 2026-05-28.
const search = ref('')
const showNoChange = ref(false)

// Clicking a stat chip toggles a status filter (re-click clears it).
function onStatusChipSelect(statusKey) {
  filterModel.value = { ...filterModel.value, status: statusKey }
}

// Each group's collapsed state persists across refreshes for the
// lifetime of the SubscriptionsView (operator-friendly default: all
// expanded; collapse what you don't care about right now).
const collapsed = reactive({
  today: false, yesterday: false, week: false, earlier: false,
})
function toggle(key) {
  collapsed[key] = !collapsed[key]
}

async function refresh() {
  await Promise.all([
    store.loadFirst(),
    store.loadStats(24),
    store.loadActivity(24),
    store.loadFailing(),
    store.loadActive(),
  ])
}

// Retry a single failing source (re-runs its whole feed) then refresh the
// rollup + stats so the operator sees it move. Passes force=true so the
// platform cooldown is bypassed — single-source click is an explicit
// operator override, useful for rapid auth-fix or fixture testing.
// Bulk retry for the maintenance menu — the shared store action keeps the
// cooldown semantics + tally shape identical to the needs-attention card on
// the Subscriptions tab. Toast tallies the three outcomes so the operator
// can read whether cooldown is the dominant failure mode ("12 deferred
// (cooldown)" → yes, rate limit is the issue).
async function onRetryAll(sources) {
  const t = await store.retryAllFailing(sources)
  await store.loadFirst()
  const parts = []
  if (t.ok) parts.push(`${t.ok} queued`)
  if (t.deferred) parts.push(`${t.deferred} deferred (cooldown)`)
  if (t.conflict) parts.push(`${t.conflict} already running`)
  toast({ text: parts.join(', ') || 'Nothing to retry', type: 'info' })
}

// Manual trigger for recover_stalled_download_events. The sweep runs on
// Beat every 5 min; this lets the operator force-clear stuck pending/
// running events on demand. Fire-and-forget: the API returns 202 once the
// task is dispatched, and we refresh after a few seconds so swept rows
// show up in the failing rollup.
async function onRecoverStalled() {
  try {
    await store.recoverStalled()
    toast({
      text: 'Recovery sweep queued — refreshing in a few seconds',
      type: 'success',
    })
    setTimeout(refresh, 4000)
  } catch (e) {
    toast({ text: `Sweep failed: ${e?.detail || e?.message || e}`, type: 'error' })
  }
}

// Live auto-refresh: while any download is queued or running, poll the
// stats + first page every 4s so the operator can watch events succeed/
// fail in real time without hitting Refresh. Polling stops automatically
// when the queue drains (nothing pending/running), and is paused while
// the tab is backgrounded. Operator-flagged 2026-05-28: couldn't tell if
// downloads were succeeding because the view was static.
const POLL_MS = 4000
const liveActive = computed(
  () => (store.stats.pending || 0) + (store.stats.running || 0) > 0,
)
let pollId = null
function startPolling() {
  if (pollId) return
  pollId = setInterval(async () => {
    if (document.hidden) return
    // Refresh stats + sparkline + active panel every tick (so new runs
    // surface within 4s even from idle); only reload the full event list +
    // failing rollup when there's active work (keeps idle ticks light).
    await Promise.all([store.loadStats(24), store.loadActivity(24), store.loadActive()])
    if (liveActive.value) await Promise.all([store.loadFirst(), store.loadFailing()])
  }, POLL_MS)
}
function stopPolling() {
  if (pollId) { clearInterval(pollId); pollId = null }
}

onMounted(() => {
  if (route.query.source_id) {
    filterModel.value = { ...filterModel.value, source_id: Number(route.query.source_id) }
  }
  refresh()
  startPolling()
})
onUnmounted(stopPolling)

// The needs-attention card (Subscriptions tab) deep-links here with
// ?source_id= while this tab may ALREADY be mounted (v-window keeps tabs
// alive) — react to the query, not just the mount.
watch(() => route.query.source_id, (v) => {
  if (v) {
    filterModel.value = { ...filterModel.value, source_id: Number(v) }
    refresh()
  }
})

// Client-side date filter on the loaded page (avoids a backend round-trip
// for the date pickers; the existing /api/downloads endpoint can grow
// these as proper query params later if a UX need shows up).
const filteredEvents = computed(() => {
  let arr = store.events
  const from = filterModel.value.from_date
  const to = filterModel.value.to_date
  if (from) {
    const fromTs = new Date(from).getTime()
    arr = arr.filter((e) => new Date(e.started_at).getTime() >= fromTs)
  }
  if (to) {
    const toTs = new Date(to).getTime() + 24 * 3600 * 1000 - 1
    arr = arr.filter((e) => new Date(e.started_at).getTime() <= toTs)
  }
  // Hide no-change scheduled scans (succeeded/skipped, downloaded
  // nothing) unless the operator opts to show them.
  if (!showNoChange.value) {
    arr = arr.filter(
      (e) => !((e.status === 'ok' || e.status === 'skipped') && !(e.files_count > 0)),
    )
  }
  const q = search.value?.trim().toLowerCase()
  if (q) {
    arr = arr.filter((e) =>
      (e.artist_name || '').toLowerCase().includes(q)
      || (e.platform || '').toLowerCase().includes(q)
      || (e.error || '').toLowerCase().includes(q),
    )
  }
  return arr
})

// Group events by relative date bucket and pin failed runs to the
// top of each bucket. Buckets boundaries are computed against the
// operator's local-time start-of-day so "Today" matches their
// intuition regardless of the event's stored UTC timestamp.
const groups = computed(() => {
  const now = new Date()
  const startOfToday = new Date(
    now.getFullYear(), now.getMonth(), now.getDate(),
  ).getTime()
  const startOfYesterday = startOfToday - 24 * 3600 * 1000
  const startOfWeek = startOfToday - 7 * 24 * 3600 * 1000

  const buckets = { today: [], yesterday: [], week: [], earlier: [] }
  for (const e of filteredEvents.value) {
    const t = new Date(e.started_at).getTime()
    if (t >= startOfToday) buckets.today.push(e)
    else if (t >= startOfYesterday) buckets.yesterday.push(e)
    else if (t >= startOfWeek) buckets.week.push(e)
    else buckets.earlier.push(e)
  }

  function withFailedPinned(items) {
    const fail = items.filter((e) => e.status === 'error')
    const rest = items.filter((e) => e.status !== 'error')
    return [...fail, ...rest]
  }

  const meta = [
    { key: 'today', label: 'Today' },
    { key: 'yesterday', label: 'Yesterday' },
    { key: 'week', label: 'Last 7 days' },
    { key: 'earlier', label: 'Earlier' },
  ]
  return meta
    .map(({ key, label }) => {
      const items = withFailedPinned(buckets[key])
      const failedCount = items.filter((e) => e.status === 'error').length
      return { key, label, items, failedCount }
    })
    .filter((g) => g.items.length > 0)
})

watch(filterModel, async (m) => {
  await store.applyFilter({
    status: m.status,
    source_id: m.source_id || null,
    artist_id: m.artist_id || null,
    from_date: m.from_date,
    to_date: m.to_date,
  })
  await store.loadStats(24)
}, { deep: true })

async function openDetail(id) {
  await store.loadOne(id)
}

</script>

<style scoped>
/* Sticky control header: stat chips + search + filter + toggle stay
   pinned below the top nav while the event feed scrolls. Opaque
   background so rows don't bleed through. */
.fc-dl__sticky {
  position: sticky;
  top: 0;
  z-index: 3;
  background: rgb(var(--v-theme-background));
  padding: 8px 0 10px;
  margin-bottom: 4px;
  border-bottom: 1px solid rgb(var(--v-theme-on-surface-variant) / 0.12);
}
.fc-dl__top {
  display: flex; gap: 8px; align-items: center;
  margin-bottom: 10px;
  flex-wrap: wrap;
}
.fc-dl__controls {
  display: flex; gap: 12px; align-items: center; flex-wrap: wrap;
}
.fc-dl__nochange { flex: 0 0 auto; }
.fc-dl__spark { flex: 0 0 auto; margin-right: 4px; max-width: 240px; }
.fc-dl__live {
  display: inline-flex; align-items: center; gap: 5px;
  font-size: 0.72rem; text-transform: uppercase; letter-spacing: 0.06em;
  color: rgb(var(--v-theme-info));
}
.fc-dl__live-dot {
  width: 7px; height: 7px; border-radius: 50%;
  background: rgb(var(--v-theme-info));
  animation: fc-dl-pulse 1.4s ease-in-out infinite;
}
@keyframes fc-dl-pulse {
  0%, 100% { opacity: 1; transform: scale(1); }
  50% { opacity: 0.35; transform: scale(0.7); }
}
@media (prefers-reduced-motion: reduce) {
  .fc-dl__live-dot { animation: none; }
}
.fc-dl__loading, .fc-dl__empty {
  display: flex; justify-content: center; padding: 3rem 0;
  color: rgb(var(--v-theme-on-surface-variant));
}
.fc-dl__sentinel {
  display: flex; justify-content: center; padding: 1rem 0;
}

.fc-dl__group { margin-bottom: 12px; }
.fc-dl__group-head {
  display: flex; align-items: center; gap: 8px;
  padding: 6px 8px;
  background: rgb(var(--v-theme-on-surface) / 0.04);
  border-radius: 4px;
  cursor: pointer;
  user-select: none;
}
.fc-dl__group-head:hover {
  background: rgb(var(--v-theme-on-surface) / 0.08);
}
.fc-dl__group-chev {
  color: rgb(var(--v-theme-on-surface-variant));
}
.fc-dl__group-label {
  font-weight: 600;
  color: rgb(var(--v-theme-on-surface));
  flex: 1;
}
.fc-dl__group-counts {
  display: flex; gap: 6px; align-items: center;
}
.fc-dl__group-body { margin-top: 4px; }
</style>
