import { create } from 'zustand'
import { type MediaKind } from '@shared/ipc'
import { isPreview as PREVIEW } from '../isPreview'
import { logError } from '../reportError'
import { revealFile } from '../reveal'
import { useSettings } from './settings'
import { useHistory } from './history'
import { useSources } from './sources'
import { useProfiles } from './profiles'
import { emit, on } from './coordinator'
import { pickRetryOptions } from './retryOptions'
import { RESOLVING, buildItem } from './downloadItem'
import { fromPersisted, persistableItems } from './queuePersist'
import { seed } from './downloadSeed'
import {
  type ChosenFormat,
  type DownloadItem,
  type DownloadStatus,
  type AddOptions,
  type AddEntry,
  type DownloadState
} from './downloadTypes'

// Re-export the store's public types from their canonical home so existing
// `import { … } from '../store/downloads'` sites keep working.
export type { MediaKind, ChosenFormat, DownloadItem, DownloadStatus, AddOptions, AddEntry }

// M4: gate the persist subscription until the initial hydrate has read the saved
// queue, so a launch-time store mutation can't overwrite queue.json with [] before
// we've loaded it back.
let queueHydrated = false

// Reset a failed/canceled item back to 'queued' for a fresh spawn, re-resolving
// its post-processing option group from the CURRENT source profile / global
// settings first (pickRetryOptions). Without this, a retry replays the options
// frozen when the item was queued, so a settings change made in between — e.g.
// turning SponsorBlock-remove off — never takes effect. Reading the sources /
// profiles stores here is a one-way lookup (they never import downloads, so no
// cycle); manual (non-source) items resolve to null and keep their explicit
// per-download options untouched.
function requeueForRetry(item: DownloadItem): DownloadItem {
  const settings = useSettings.getState()
  const fresh = pickRetryOptions(
    item.mediaItemId,
    useSources.getState().sources,
    useProfiles.getState().profiles,
    {
      kind: settings.defaultKind,
      videoQuality: settings.defaultVideoQuality,
      audioQuality: settings.defaultAudioQuality,
      options: settings.downloadOptions,
      outputTemplate: settings.collectionOutputTemplate
    }
  )
  return {
    ...item,
    ...(fresh ?? {}),
    status: 'queued',
    progress: 0,
    error: undefined,
    speedBytesPerSec: undefined,
    etaSeconds: undefined
  }
}

// Side-effects to run exactly once when a download completes: record it to
// history (unless private) and mark its source MediaItem downloaded. Shared by
// the real `applyEvent('done')` path and the preview ticker so the two can't
// drift (M3).
function recordCompletion(item: DownloadItem): void {
  if (!item.incognito) {
    useHistory.getState().add({
      id: item.id,
      title: item.title,
      channel: item.channel,
      url: item.url,
      kind: item.kind,
      quality: item.quality,
      formatId: item.formatId,
      formatHasAudio: item.formatHasAudio,
      filePath: item.filePath,
      sizeLabel: item.sizeLabel,
      thumbnail: item.thumbnail,
      // Carry the post-processing options so a re-download reproduces the same
      // output rather than the current global defaults (L87).
      options: item.options,
      completedAt: Date.now()
    })
  }
  if (item.mediaItemId) {
    // Tell the sources store to mark its MediaItem downloaded — via the event bus
    // rather than a direct import, so downloads and sources don't form a cycle (C2).
    // The quality is recorded so the retention quality-upgrade check can tell
    // which items were fetched below the source's current target.
    emit('downloadCompleted', {
      mediaItemId: item.mediaItemId,
      filePath: item.filePath,
      quality: item.quality
    })
  }
}

// UI-preview only: animate fake progress so the queue feels alive without yt-dlp.
function startFakeTicker(
  id: string,
  get: () => DownloadState,
  set: (fn: (s: DownloadState) => Partial<DownloadState>) => void
): void {
  const timer = setInterval(() => {
    const cur = get().items.find((i) => i.id === id)
    if (!cur || cur.status !== 'downloading') {
      clearInterval(timer)
      return
    }
    const next = Math.min(1, cur.progress + 0.06 + Math.random() * 0.04)
    const done = next >= 1
    set((s) => ({
      items: s.items.map((i) =>
        i.id === id
          ? {
              ...i,
              progress: next,
              channel: 'Sample Channel',
              durationLabel: '12:34',
              sizeLabel: '96.4 MB',
              speedBytesPerSec: done ? undefined : (2 + Math.random() * 5) * 1024 * 1024,
              etaSeconds: done ? undefined : (1 - next) * 90,
              status: done ? 'completed' : 'downloading',
              filePath: done ? `C:\\Users\\you\\Downloads\\${cur.title}.mp4` : undefined
            }
          : i
      )
    }))
    if (done) {
      clearInterval(timer)
      const finished = get().items.find((i) => i.id === id)
      if (finished) recordCompletion(finished)
      get().pump() // a slot just freed -- promote the next queued item
    }
  }, 650)
}

export const useDownloads = create<DownloadState>((set, get) => {
  function markError(id: string, error?: string): void {
    set((s) => ({
      items: s.items.map((i) => (i.id === id ? { ...i, status: 'error', error } : i))
    }))
    pump() // a slot freed
  }

  // Actually start an item that pump() has just moved to 'downloading'.
  function launchItem(item: DownloadItem): void {
    if (PREVIEW) {
      startFakeTicker(item.id, get, set)
      return
    }
    window.api
      .startDownload({
        id: item.id,
        url: item.url,
        kind: item.kind,
        quality: item.quality,
        // No outputDir here: main routes each download into the user's per-kind
        // folder (Settings → Video/Audio folder), or the Documents\… default.
        formatId: item.formatId,
        formatHasAudio: item.formatHasAudio,
        options: item.options,
        extraArgs: item.extraArgs,
        trim: item.trim,
        // Carry the private flag to main so it also skips errorlog, notification
        // detail, and cookies — not just the renderer's history skip (L136).
        incognito: item.incognito,
        meta: item.probedMeta,
        collection: item.collection,
        collectionOutputTemplate: item.collectionOutputTemplate
      })
      .then((res) => {
        if (!res.ok) markError(item.id, res.error)
      })
      .catch((e: unknown) => markError(item.id, String(e)))
  }

  // Promote queued items (oldest first) into free slots, then launch them.
  //
  // Note: this only gates *future* promotions. Lowering maxConcurrent while N
  // downloads are already active does NOT pause the overflow -- the running
  // processes finish on their own, and the lower cap takes effect only as slots
  // free up. (audit P3 -- documented behaviour, not a bug.)
  function pump(): void {
    const items = get().items
    const running = items.filter((i) => i.status === 'downloading').length
    const slots = useSettings.getState().maxConcurrent - running
    if (slots <= 0) return
    // items are newest-first, so reverse the queued list to get oldest-first.
    const toStart = items
      .filter((i) => i.status === 'queued')
      .reverse()
      .slice(0, slots)
    if (toStart.length === 0) return
    const ids = new Set(toStart.map((i) => i.id))
    set((s) => ({
      items: s.items.map((i) => (ids.has(i.id) ? { ...i, status: 'downloading' } : i))
    }))
    for (const item of toStart) launchItem({ ...item, status: 'downloading' })
  }

  return {
    items: seed,
    pump,

    hydrate: () => {
      if (PREVIEW) return
      window.api
        .listQueue()
        .then((persisted) => {
          if (persisted.length === 0) return
          // Merge ahead of anything already present (normally nothing at launch),
          // de-duping by id so a double-hydrate can't double-insert.
          set((s) => {
            const have = new Set(s.items.map((i) => i.id))
            const add = persisted.filter((p) => !have.has(p.id)).map(fromPersisted)
            return add.length ? { items: [...s.items, ...add] } : {}
          })
          pump()
        })
        .catch(logError('listQueue'))
        .finally(() => {
          // Enable persistence once the read has completed (success or failure), so
          // subsequent queue changes are saved but the pre-hydrate window can't wipe.
          queueHydrated = true
        })
    },

    addFromUrl: (url, kind, quality, opts) => {
      set((s) => ({ items: [buildItem(url, kind, quality, opts), ...s.items] }))
      pump()
    },

    addMany: (entries) => {
      if (entries.length === 0) return
      // The queue is a newest-first array and pump() promotes the highest-index
      // (oldest) queued item first, so a batch must be stored last-entry-first for
      // the playlist/channel to download 1→N instead of N→1 (M32). Entry 1 then
      // sits at the bottom of the list -- the same way repeated single adds stack.
      const built = entries.map((e) => buildItem(e.url, e.kind, e.quality, e.opts)).reverse()
      set((s) => ({ items: [...built, ...s.items] }))
      pump()
    },

    retry: (id) => {
      set((s) => ({
        items: s.items.map((i) => (i.id === id ? requeueForRetry(i) : i))
      }))
      pump()
    },

    retryAll: () => {
      set((s) => ({
        items: s.items.map((i) => (i.status === 'error' ? requeueForRetry(i) : i))
      }))
      pump()
    },

    pause: (id) => {
      const cur = get().items.find((i) => i.id === id)
      if (!cur) return
      // Only a running download has a live process to stop; a queued item is
      // just parked in place. Either way it becomes 'paused' (pump ignores it).
      if (!PREVIEW && cur.status === 'downloading') window.api.pauseDownload(id).catch(() => {})
      set((s) => ({
        items: s.items.map((i) =>
          i.id === id && (i.status === 'downloading' || i.status === 'queued')
            ? { ...i, status: 'paused', speedBytesPerSec: undefined, etaSeconds: undefined }
            : i
        )
      }))
      pump() // pausing a running item frees a slot
    },

    resume: (id) => {
      // Re-queue WITHOUT resetting progress (unlike retry): pump() promotes it and
      // launchItem re-spawns yt-dlp, which continues the partial file by default.
      set((s) => ({
        items: s.items.map((i) =>
          i.id === id && i.status === 'paused' ? { ...i, status: 'queued' } : i
        )
      }))
      pump()
    },

    prioritize: (id) => {
      // Reposition the item so pump() -- which promotes the highest-index queued
      // item first (oldest-first over a newest-first array) -- picks it next.
      set((s) => {
        const idx = s.items.findIndex((i) => i.id === id)
        if (idx < 0 || s.items[idx]?.status !== 'queued') return {}
        const items = s.items.slice()
        const [it] = items.splice(idx, 1)
        if (!it) return {}
        let after = -1
        for (let k = 0; k < items.length; k++) if (items[k]?.status === 'queued') after = k
        items.splice(after + 1, 0, it)
        return { items }
      })
      pump()
    },

    saveForLater: (id) => {
      set((s) => ({
        items: s.items.map((i) =>
          i.id === id && i.status === 'queued'
            ? { ...i, status: 'saved', scheduledFor: undefined }
            : i
        )
      }))
    },

    queueNow: (id) => {
      set((s) => ({
        items: s.items.map((i) =>
          i.id === id && i.status === 'saved'
            ? { ...i, status: 'queued', scheduledFor: undefined }
            : i
        )
      }))
      pump()
    },

    promoteDueScheduled: () => {
      // Wall-clock comparison: each scheduled item fires once (the promotion flips it
      // out of 'saved'), so a backward clock jump only delays it and can't double-fire;
      // a forward jump can fire it early. Accepted as minor per R9 — a monotonic
      // scheduler would be overkill for a park-until-time feature.
      const now = Date.now()
      set((s) => ({
        items: s.items.map((i) =>
          i.status === 'saved' && i.scheduledFor != null && i.scheduledFor <= now
            ? { ...i, status: 'queued', scheduledFor: undefined }
            : i
        )
      }))
      pump()
    },

    cancel: (id) => {
      const cur = get().items.find((i) => i.id === id)
      if (!cur) return
      // Only running items have a live process to kill; queued ones never started.
      if (!PREVIEW && cur.status === 'downloading') window.api.cancelDownload(id).catch(() => {})
      set((s) => ({
        items: s.items.map((i) =>
          i.id === id && (i.status === 'downloading' || i.status === 'queued')
            ? { ...i, status: 'canceled', speedBytesPerSec: undefined, etaSeconds: undefined }
            : i
        )
      }))
      pump()
    },

    cancelAll: () => {
      const active = get().items.filter((i) => i.status === 'downloading' || i.status === 'queued')
      if (active.length === 0) return
      // Kill the live processes for the running ones; queued items never started.
      if (!PREVIEW) {
        for (const i of active) {
          if (i.status === 'downloading') window.api.cancelDownload(i.id).catch(() => {})
        }
      }
      set((s) => ({
        items: s.items.map((i) =>
          i.status === 'downloading' || i.status === 'queued'
            ? { ...i, status: 'canceled', speedBytesPerSec: undefined, etaSeconds: undefined }
            : i
        )
      }))
      pump()
    },

    remove: (id) => {
      set((s) => ({ items: s.items.filter((i) => i.id !== id) }))
      pump()
    },

    clearFinished: () =>
      set((s) => ({
        items: s.items.filter(
          (i) =>
            i.status === 'downloading' ||
            i.status === 'queued' ||
            i.status === 'paused' ||
            i.status === 'saved'
        )
      })),

    openFile: (id) => {
      const item = get().items.find((i) => i.id === id)
      if (!PREVIEW && item?.filePath) revealFile('open', item.filePath)
    },

    showInFolder: (id) => {
      const item = get().items.find((i) => i.id === id)
      if (!PREVIEW && item?.filePath) revealFile('folder', item.filePath)
    },

    applyEvent: (ev) => {
      set((s) => ({
        items: s.items.map((i) => {
          if (i.id !== ev.id) return i
          switch (ev.type) {
            case 'meta':
              // A late meta event can arrive between cancel() and the child's
              // close -- don't overwrite a canceled item's fields (B5; mirrors the
              // canceled guard on the done/error cases below).
              if (i.status === 'canceled') return i
              return {
                ...i,
                title: ev.meta.title ?? i.title,
                // Clear the 'Resolving…' placeholder even when the probe returns
                // no channel, so it can't stick forever (SR6); a real channel is
                // always preserved.
                channel: ev.meta.channel ?? (i.channel === RESOLVING ? undefined : i.channel),
                durationLabel: ev.meta.durationLabel ?? i.durationLabel
              }
            case 'progress':
              // Only a launched (downloading) item should take progress. Never
              // promote a 'queued' item here -- pump() owns the queued→downloading
              // transition and the concurrency cap (L88); a stray progress event
              // for a non-downloading item is ignored.
              if (i.status !== 'downloading') return i
              return {
                ...i,
                status: 'downloading',
                progress: ev.progress.progress,
                // Main owns the latch and resends it on every tick, so reflecting
                // it directly resets cleanly on a retry's fresh spawn (SR7).
                finishing: ev.progress.finishing,
                speedBytesPerSec: ev.progress.speedBytesPerSec,
                etaSeconds: ev.progress.etaSeconds,
                sizeUnknown: ev.progress.sizeUnknown,
                sizeLabel: ev.progress.sizeLabel ?? i.sizeLabel
              }
            case 'done':
              if (i.status === 'canceled') return i
              return {
                ...i,
                status: 'completed',
                progress: 1,
                finishing: false,
                speedBytesPerSec: undefined,
                etaSeconds: undefined,
                channel: i.channel === RESOLVING ? undefined : i.channel,
                filePath: ev.filePath ?? i.filePath
              }
            case 'error':
              if (i.status === 'canceled') return i
              return {
                ...i,
                status: 'error',
                finishing: false,
                speedBytesPerSec: undefined,
                etaSeconds: undefined,
                channel: i.channel === RESOLVING ? undefined : i.channel,
                error: ev.error
              }
            default:
              return i
          }
        })
      }))
      // Record completion (history + source markDownloaded) via the shared helper.
      if (ev.type === 'done') {
        const item = get().items.find((i) => i.id === ev.id)
        if (item && item.status === 'completed') recordCompletion(item)
      }
      // A finished item frees a slot -- promote whatever is queued next.
      if (ev.type === 'done' || ev.type === 'error') pump()
    }
  }
})

// Consume the sources store's enqueue requests (C2): when the user downloads a
// channel's media items, sources emits the batch on the bus and we add it here —
// no direct import from sources, so the two stores stay acyclic.
on('enqueueDownloads', (entries) => useDownloads.getState().addMany(entries))

// A removed source cancels its in-flight downloads (L157): MediaItem ids are
// `${sourceId}:${videoId}` (indexerCore), so a download's mediaItemId prefix
// identifies its source. Only active (downloading/queued) items are canceled;
// finished/failed ones are left alone. cancel() owns the process kill + pump.
on('sourceRemoved', (sourceId) => {
  const st = useDownloads.getState()
  const prefix = `${sourceId}:`
  for (const it of st.items) {
    if (
      (it.status === 'downloading' || it.status === 'queued') &&
      it.mediaItemId?.startsWith(prefix)
    ) {
      st.cancel(it.id)
    }
  }
})

// M4: mirror the durable queue to main whenever it changes, so saved/scheduled and
// other pending items survive a quit. Skipped in preview (no IPC bridge). A
// progress-only update doesn't churn this — toPersisted drops progress/speed/eta, so
// the persistable snapshot is unchanged by a tick and no redundant save fires. Main's
// jsonStore coalesces the actual disk writes.
if (!PREVIEW) {
  let lastSig = ''
  useDownloads.subscribe((state) => {
    // Don't persist until the initial hydrate has read the saved file (above).
    if (!queueHydrated) return
    const persistable = persistableItems(state.items)
    const sig = JSON.stringify(persistable)
    if (sig === lastSig) return
    lastSig = sig
    window.api.saveQueue(persistable).catch(logError('saveQueue'))
  })
}

// The scheduled-download promoter tick lives in App (a useEffect), not here, so
// importing this store in tests/preview doesn't start a stray never-cleared timer
// on module load (L1). See SCHEDULE_TICK_MS / App.tsx.

// Wire main → renderer push events. (Output folder + cap live in the settings store.)
if (!PREVIEW) {
  window.api.onDownloadEvent((ev) => useDownloads.getState().applyEvent(ev))
} else {
  // Browser preview: animate any seed item that starts out 'downloading' so the
  // queue is live (and its completion promotes the queued seed item -- a real
  // demo of the concurrency cap).
  useDownloads
    .getState()
    .items.filter((i) => i.status === 'downloading')
    .forEach((i) => startFakeTicker(i.id, useDownloads.getState, (fn) => useDownloads.setState(fn)))
}
