<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>

    <FailingSourcesCard
      :sources="store.failing"
      :retrying-ids="sourcesStore.checkingIds"
      :retrying-all="retryingAll"
      @retry="onRetrySource"
      @retry-all="onRetryAll"
      @view-logs="onViewFailingLogs"
    />

    <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 { useSourcesStore } from '../../stores/sources.js'
import DownloadEventRow from '../downloads/DownloadEventRow.vue'
import DownloadDetailModal from '../downloads/DownloadDetailModal.vue'
import DownloadStatChips from './DownloadStatChips.vue'
import DownloadActivitySparkline from './DownloadActivitySparkline.vue'
import FailingSourcesCard from './FailingSourcesCard.vue'
import ActiveDownloadsPanel from './ActiveDownloadsPanel.vue'
import MaintenanceMenu from './MaintenanceMenu.vue'
import DownloadsFilterPopover from './DownloadsFilterPopover.vue'

const route = useRoute()
const store = useDownloadsStore()
const sourcesStore = useSourcesStore()
const filterModel = ref({ ...store.filter })
const retryingAll = ref(false)

// 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.
async function onRetrySource(source) {
  try {
    await sourcesStore.checkNow(source.id, { force: true })
    toast({ text: `Retry queued for ${source.artist_name || source.platform}`, type: 'success' })
  } catch (e) {
    if (e?.body?.download_event_id) {
      toast({ text: 'Already running — see below', type: 'info' })
    } else {
      toast({ text: `Retry failed: ${e?.detail || e?.message || e}`, type: 'error' })
    }
  } finally {
    await Promise.all([store.loadFailing(), store.loadStats(24), store.loadFirst()])
  }
}

// Bulk retry — leaves cooldown enforcement ON so N failing sources on
// the same platform don't all retry into the rate limit the cooldown is
// preventing. Sources deferred by cooldown will be picked up by the
// next scan tick after the AppSetting expires. Toast tallies the three
// outcomes so the operator can quickly read whether cooldown is the
// dominant failure mode ("12 deferred (cooldown)" → yes, rate limit is
// the issue).
async function onRetryAll(sources) {
  retryingAll.value = true
  let ok = 0
  let conflict = 0
  let deferred = 0
  try {
    for (const s of sources) {
      try {
        const body = await sourcesStore.checkNow(s.id)
        if (body?.status === 'deferred') deferred += 1
        else ok += 1
      } catch (e) {
        if (e?.body?.download_event_id) conflict += 1
      }
    }
  } finally {
    retryingAll.value = false
    await Promise.all([store.loadFailing(), store.loadStats(24), store.loadFirst()])
  }
  const parts = []
  if (ok) parts.push(`${ok} queued`)
  if (deferred) parts.push(`${deferred} deferred (cooldown)`)
  if (conflict) parts.push(`${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)

// 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)
}

async function onViewFailingLogs(source) {
  // Find and open the most recent DownloadEvent for this source.
  // Reuses the existing DownloadDetailModal — same stdout/stderr/error
  // surface the row-click in the events feed shows.
  try {
    const ev = await store.loadLastForSource(source.id)
    if (!ev) {
      toast({
        text: `No download events recorded for ${source.artist_name || source.platform} yet.`,
        type: 'warning',
      })
    }
  } catch (e) {
    toast({ text: `Failed to load logs: ${e.message}`, type: 'error' })
  }
}
</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>
