import { create } from 'zustand'
import type { Source, MediaItem, IndexProgress, MediaKind } from '@shared/ipc'
import { qualityRank } from '@shared/ipc'
import { resolveProfile } from '@shared/profileResolve'
import { selectPruneCandidates, selectUpgradeCandidates, type PrunePolicy } from '@shared/retention'
import { isPreview as PREVIEW } from '../isPreview'
import { useSettings } from './settings'
import { useProfiles } from './profiles'
import { emit, on } from './coordinator'
import { logError } from '../reportError'
import { createReconciler } from '../lib/reconcile'

// --- Preview seed data ------------------------------------------------------

function seedItems(sourceId: string, playlist: string, n: number, downloadedUpTo = 0): MediaItem[] {
  return Array.from({ length: n }, (_, i) => ({
    id: `${sourceId}:${playlist}-${i + 1}`,
    sourceId,
    videoId: `${playlist}-${i + 1}`,
    title: `${playlist} -- part ${i + 1}`,
    url: `https://youtube.com/watch?v=${sourceId}${i + 1}`,
    playlistTitle: playlist,
    playlistIndex: i + 1,
    durationLabel: `${10 + i}:${String(i * 7).padStart(2, '0')}`,
    downloaded: i < downloadedUpTo
  }))
}

// Gated on `import.meta.env.DEV` too so Rollup drops these preview seeds from the
// packaged production renderer (DEV is statically false there) (L128).
const seedSources: Source[] =
  import.meta.env.DEV && PREVIEW
    ? [
        {
          id: 'src-dev',
          url: 'https://www.youtube.com/@DevChannel',
          kind: 'channel',
          title: 'DevChannel',
          channel: 'DevChannel',
          addedAt: Date.now() - 86_400_000,
          lastIndexedAt: Date.now() - 3_600_000,
          itemCount: 9,
          watched: true
        },
        {
          id: 'src-mix',
          url: 'https://www.youtube.com/playlist?list=PLmix',
          kind: 'playlist',
          title: 'Lo-fi Coding Mixes',
          channel: 'ChillStudio',
          addedAt: Date.now() - 2 * 86_400_000,
          lastIndexedAt: Date.now() - 7_200_000,
          itemCount: 5
        }
      ]
    : []

const seedItemsBySource: Record<string, MediaItem[]> =
  import.meta.env.DEV && PREVIEW
    ? {
        'src-dev': [
          ...seedItems('src-dev', 'Full Electron Course', 6, 2),
          ...seedItems('src-dev', 'Uploads', 3)
        ],
        'src-mix': seedItems('src-mix', 'Lo-fi Coding Mixes', 5, 1)
      }
    : {}

// --- Store ------------------------------------------------------------------

/** Live indexing status for the add-source bar. */
interface IndexingState {
  active: boolean
  url?: string
  message?: string
  current?: number
  total?: number
}

interface SourcesState {
  sources: Source[]
  /** media items per source, lazily loaded when a source is expanded */
  itemsBySource: Record<string, MediaItem[]>
  /** which source is expanded in the library view (null = none) */
  selectedSourceId: string | null
  indexing: IndexingState
  loadSources: () => void
  selectSource: (id: string | null) => void
  /** index a pasted channel/playlist URL; resolves with ok + an error message.
   *  `canceled` is set when the user aborted, so the caller shows no error (B2). */
  indexSource: (url: string) => Promise<{ ok: boolean; error?: string; canceled?: boolean }>
  reindexSource: (id: string) => Promise<void>
  /** abort an in-flight index/reindex walk (the add-source "Cancel" button, B2) */
  cancelIndexing: () => void
  removeSource: (id: string) => void
  /**
   * Enqueue the given media items into the download queue with folder context.
   * Queues all of them -- one click can fill the queue with an entire channel,
   * which maxConcurrent then gates into download slots. Returns how many were
   * enqueued.
   */
  enqueueItems: (sourceId: string, items: MediaItem[], kind?: MediaKind) => number
  /**
   * Load a source's items (lazily fetching + caching once) WITHOUT expanding it,
   * so a per-row action can act on a collapsed source. Resolves with the items.
   */
  ensureSourceItems: (sourceId: string) => Promise<MediaItem[]>
  /** mark an item downloaded once its queue download completes (called by the downloads store) */
  markDownloaded: (itemId: string, filePath?: string, quality?: string) => void
  /** toggle a source's watched-for-new-uploads flag (Phase J) */
  setWatched: (id: string, watched: boolean) => void
  /** point a source at a MediaProfile, or clear it (profileId = null) */
  setProfile: (id: string, profileId: string | null) => void
  /** which of a source's downloaded items a retention prune policy would remove */
  prunePreview: (sourceId: string, policy: PrunePolicy) => MediaItem[]
  /** prune (delete files of) the given items; resolves with how many files were deleted */
  pruneItems: (sourceId: string, itemIds: string[]) => Promise<number>
  /** which of a source's items were downloaded below its current target quality */
  upgradePreview: (sourceId: string) => MediaItem[]
  /** re-enqueue the below-target items for a source to upgrade their quality */
  upgradeItems: (sourceId: string, items: MediaItem[]) => number
  /** whether a sync is currently running (the "Check for new" button's busy state) */
  syncing: boolean
  /**
   * Re-index all watched sources and (when autoDownloadNew is on) enqueue the new
   * videos. Resolves with how many new videos were found.
   */
  syncWatched: () => Promise<number>
  /** whether the OS-scheduled daily sync task is registered (Phase J) */
  scheduledSyncEnabled: boolean
  /** load the Task Scheduler state (Library view mount) */
  loadScheduledSync: () => void
  /** register/unregister the daily sync task; resolves with an error message or null */
  setScheduledSync: (enabled: boolean) => Promise<string | null>
  /** re-register the task after the sync time changed; error message or null (L51) */
  commitSyncTime: () => Promise<string | null>
}

// Set by cancelIndexing() so the awaiting index/reindex call knows the user
// aborted and can resolve as canceled (no error UI) rather than as a failure (B2).
let indexCanceledRequested = false

// Mutations apply main's authoritative return via a latest-wins reconciler so the
// optimistic state can't diverge from disk until reload (L142). The source list
// and each source's item list are independent keys.
const reconcile = createReconciler()

export const useSources = create<SourcesState>((set, get) => ({
  sources: seedSources,
  itemsBySource: seedItemsBySource,
  selectedSourceId: null,
  indexing: { active: false },
  syncing: false,

  loadSources: () => {
    if (PREVIEW) return
    // Shares the mutations' 'sources' key so a slow list response can't clobber
    // a newer setWatched/removeSource authoritative result (L142).
    reconcile(
      'sources',
      window.api.listSources(),
      (sources) => set({ sources }),
      logError('listSources')
    )
  },

  selectSource: (id) => {
    set({ selectedSourceId: id })
    // Lazily fetch a source's items the first time it's expanded.
    if (id && !get().itemsBySource[id] && !PREVIEW) {
      window.api
        .listSourceItems(id)
        .then((items) => set((s) => ({ itemsBySource: { ...s.itemsBySource, [id]: items } })))
        .catch(logError('listSourceItems'))
    }
  },

  indexSource: async (url) => {
    indexCanceledRequested = false
    set({ indexing: { active: true, url, message: 'Reading source…' } })
    if (PREVIEW) {
      await new Promise((r) => setTimeout(r, 900)) // simulate the index walk
      set({ indexing: { active: false } })
      return indexCanceledRequested ? { ok: false, canceled: true } : { ok: true }
    }
    try {
      const res = await window.api.indexSource(url)
      set({ indexing: { active: false } })
      // The user hit Cancel: report canceled (no error UI) regardless of what main
      // returned for the aborted walk (B2).
      if (indexCanceledRequested) return { ok: false, canceled: true }
      if (res.ok) {
        get().loadSources()
        if (res.source) get().selectSource(res.source.id)
      }
      return { ok: res.ok, error: res.error }
    } catch (e) {
      set({ indexing: { active: false } })
      if (indexCanceledRequested) return { ok: false, canceled: true }
      return { ok: false, error: e instanceof Error ? e.message : String(e) }
    }
  },

  reindexSource: async (id) => {
    indexCanceledRequested = false
    const src = get().sources.find((s) => s.id === id)
    set({ indexing: { active: true, url: src?.url, message: 'Refreshing…' } })
    if (PREVIEW) {
      await new Promise((r) => setTimeout(r, 700))
      set({ indexing: { active: false } })
      return
    }
    try {
      const res = await window.api.reindexSource(id)
      set({ indexing: { active: false } })
      if (indexCanceledRequested) return // user aborted — leave the source as-is
      if (res.ok) {
        get().loadSources()
        const items = await window.api.listSourceItems(id)
        set((s) => ({ itemsBySource: { ...s.itemsBySource, [id]: items } }))
      }
    } catch (e) {
      logError('reindexSource')(e)
      set({ indexing: { active: false } })
    }
  },

  cancelIndexing: () => {
    // Optimistically clear the indexing UI; main aborts the in-flight probe child.
    // The pending index/reindex promise then resolves and is treated as canceled
    // via indexCanceledRequested (B2).
    indexCanceledRequested = true
    set({ indexing: { active: false } })
    if (!PREVIEW) window.api.cancelIndex().catch(logError('cancelIndex'))
  },

  removeSource: (id) => {
    set((s) => ({
      sources: s.sources.filter((x) => x.id !== id),
      selectedSourceId: s.selectedSourceId === id ? null : s.selectedSourceId
    }))
    // Cancel any of this source's downloads still in flight, so removing a source
    // stops its videos from finishing into now-orphaned folders (L157). Routed via
    // the event bus (C2) so sources doesn't import downloads.
    emit('sourceRemoved', id)
    if (!PREVIEW)
      reconcile(
        'sources',
        window.api.removeSource(id),
        (sources) => set({ sources }),
        logError('removeSource')
      )
  },

  enqueueItems: (sourceId, items, kindOverride) => {
    const src = get().sources.find((s) => s.id === sourceId)
    const settings = useSettings.getState()
    // A source's Media Profile (if any) resolves kind/quality/options/output-
    // template/extra-args from the profile first, falling back to the globals
    // field by field (resolveProfile). A per-batch Library video/audio toggle
    // (kindOverride) still wins for kind.
    const profile = src?.profileId
      ? (useProfiles.getState().profiles.find((p) => p.id === src.profileId) ?? null)
      : null
    const resolved = resolveProfile(profile, {
      kind: settings.defaultKind,
      videoQuality: settings.defaultVideoQuality,
      audioQuality: settings.defaultAudioQuality,
      options: settings.downloadOptions,
      outputTemplate: settings.collectionOutputTemplate
    })
    const kind = kindOverride ?? resolved.kind
    // If the toggle flipped kind away from the profile's, re-pick the matching
    // global quality unless the profile pinned its own.
    const quality =
      kindOverride && !profile?.quality
        ? kind === 'video'
          ? settings.defaultVideoQuality
          : settings.defaultAudioQuality
        : resolved.quality
    // Hand the batch to the downloads store via the event bus (C2) rather than a
    // direct import. addMany applies it as one batched state update + pump, so
    // queuing a whole channel stays O(n).
    emit(
      'enqueueDownloads',
      items.map((it) => ({
        url: it.url,
        kind,
        quality,
        opts: {
          title: it.title,
          channel: src?.channel,
          durationLabel: it.durationLabel,
          options: resolved.options,
          extraArgs: resolved.extraArgs || undefined,
          collectionOutputTemplate: resolved.outputTemplate || undefined,
          collection: {
            channel: src?.channel || src?.title || 'Channel',
            playlist: it.playlistTitle,
            index: it.playlistIndex
          },
          mediaItemId: it.id
        }
      }))
    )
    return items.length
  },

  ensureSourceItems: async (sourceId) => {
    const cached = get().itemsBySource[sourceId]
    if (cached) return cached
    // Preview has no IPC — fall back to whatever seed items exist for the source.
    if (PREVIEW) return get().itemsBySource[sourceId] ?? []
    const items = await window.api.listSourceItems(sourceId)
    set((s) => ({ itemsBySource: { ...s.itemsBySource, [sourceId]: items } }))
    return items
  },

  markDownloaded: (itemId, filePath, quality) => {
    // Which loaded source holds this item — also the reconcile key, so overlapping
    // completions in different sources never suppress each other's response.
    const owner = Object.entries(get().itemsBySource).find(([, list]) =>
      list.some((m) => m.id === itemId)
    )?.[0]
    set((s) => {
      const next: Record<string, MediaItem[]> = {}
      let changed = false
      for (const [sid, list] of Object.entries(s.itemsBySource)) {
        if (list.some((m) => m.id === itemId)) {
          changed = true
          next[sid] = list.map((m) =>
            m.id === itemId
              ? {
                  ...m,
                  downloaded: true,
                  downloadedAt: Date.now(),
                  filePath,
                  downloadedQuality: quality
                }
              : m
          )
        } else {
          next[sid] = list
        }
      }
      return changed ? { itemsBySource: next } : {}
    })
    if (!PREVIEW)
      reconcile(
        `items:${owner ?? itemId}`,
        window.api.setMediaItemDownloaded(itemId, filePath, quality),
        (items) => {
          // Main returns the updated list for the item's source, or [] when the id
          // is unknown there (e.g. a queue item without a persisted MediaItem).
          const sid = items[0]?.sourceId
          if (sid) set((s) => ({ itemsBySource: { ...s.itemsBySource, [sid]: items } }))
        },
        logError('setMediaItemDownloaded')
      )
  },

  setWatched: (id, watched) => {
    set((s) => ({ sources: s.sources.map((x) => (x.id === id ? { ...x, watched } : x)) }))
    if (!PREVIEW)
      reconcile(
        'sources',
        window.api.setSourceWatched(id, watched),
        (sources) => set({ sources }),
        logError('setSourceWatched')
      )
  },

  setProfile: (id, profileId) => {
    set((s) => ({
      sources: s.sources.map((x) => (x.id === id ? { ...x, profileId: profileId ?? undefined } : x))
    }))
    if (!PREVIEW)
      reconcile(
        'sources',
        window.api.setSourceProfile(id, profileId),
        (sources) => set({ sources }),
        logError('setSourceProfile')
      )
  },

  prunePreview: (sourceId, policy) =>
    selectPruneCandidates(get().itemsBySource[sourceId] ?? [], policy, Date.now()),

  pruneItems: async (sourceId, itemIds) => {
    if (PREVIEW || itemIds.length === 0) return 0
    const res = await window.api.pruneMediaItems(sourceId, itemIds)
    set((s) => ({ itemsBySource: { ...s.itemsBySource, [sourceId]: res.items } }))
    return res.deleted
  },

  upgradePreview: (sourceId) => {
    const src = get().sources.find((s) => s.id === sourceId)
    const settings = useSettings.getState()
    const profile = src?.profileId
      ? (useProfiles.getState().profiles.find((p) => p.id === src.profileId) ?? null)
      : null
    const resolved = resolveProfile(profile, {
      kind: settings.defaultKind,
      videoQuality: settings.defaultVideoQuality,
      audioQuality: settings.defaultAudioQuality,
      options: settings.downloadOptions,
      outputTemplate: settings.collectionOutputTemplate
    })
    return selectUpgradeCandidates(
      get().itemsBySource[sourceId] ?? [],
      resolved.quality,
      qualityRank
    )
  },

  upgradeItems: (sourceId, items) => get().enqueueItems(sourceId, items),

  syncWatched: async () => {
    if (get().syncing) return 0
    set({ syncing: true })
    try {
      if (PREVIEW) {
        await new Promise((r) => setTimeout(r, 700))
        return 0 // preview has no real feed to poll
      }
      const res = await window.api.syncWatchedSources()
      if (!res.ok) return 0
      get().loadSources()
      // Refresh any expanded source's items so new videos appear in the tree.
      const sel = get().selectedSourceId
      if (sel) {
        const items = await window.api.listSourceItems(sel)
        set((s) => ({ itemsBySource: { ...s.itemsBySource, [sel]: items } }))
      }
      // Auto-enqueue the new videos, grouped by source, when the setting is on.
      if (useSettings.getState().autoDownloadNew && res.newItems.length > 0) {
        const bySource = new Map<string, MediaItem[]>()
        for (const it of res.newItems) {
          const arr = bySource.get(it.sourceId) ?? []
          arr.push(it)
          bySource.set(it.sourceId, arr)
        }
        for (const [sid, items] of bySource) get().enqueueItems(sid, items)
      }
      return res.newItems.length
    } finally {
      set({ syncing: false })
    }
  },

  scheduledSyncEnabled: false,

  loadScheduledSync: () => {
    if (PREVIEW) return
    window.api
      .getScheduledSync()
      .then((s) => set({ scheduledSyncEnabled: s.enabled }))
      .catch(logError('getScheduledSync'))
  },

  setScheduledSync: async (enabled) => {
    set({ scheduledSyncEnabled: enabled }) // optimistic
    if (PREVIEW) return null
    const res = await window.api.setScheduledSync(enabled, useSettings.getState().syncTime)
    set({ scheduledSyncEnabled: res.enabled })
    return res.error ?? null
  },

  commitSyncTime: async () => {
    if (!get().scheduledSyncEnabled || PREVIEW) return null
    const res = await window.api.setScheduledSync(true, useSettings.getState().syncTime)
    return res.error ?? null
  }
}))

// When a download tied to one of our MediaItems finishes, the downloads store
// emits this on the bus (C2) — mark the item downloaded so the library reflects it.
on('downloadCompleted', ({ mediaItemId, filePath, quality }) =>
  useSources.getState().markDownloaded(mediaItemId, filePath, quality)
)

// Load persisted sources on startup, and subscribe to live indexing progress.
if (!PREVIEW) {
  window.api
    .listSources()
    .then((sources) => {
      useSources.setState({ sources })
      // If any source is watched, kick off a sync shortly after launch (covers a fresh
      // `--sync` launch and a normal launch alike).
      if (sources.some((s) => s.watched)) {
        setTimeout(() => useSources.getState().syncWatched(), 1500)
      }
    })
    .catch(logError('startup listSources'))
  // A scheduled `--sync` relaunch that hit the already-running instance nudges us here,
  // so the daily sync fires even when AeroFetch was already open (e.g. tray mode).
  window.api.onRunWatchedSync?.(() => useSources.getState().syncWatched())
  window.api.onIndexProgress((p: IndexProgress) => {
    useSources.setState({
      indexing: {
        active: p.phase !== 'done' && p.phase !== 'error',
        url: p.url,
        message: p.message,
        current: p.current,
        total: p.total
      }
    })
  })
}
