import { create } from 'zustand'
import { qk } from '@/shared/api/query-keys'
import { HttpError, refreshAccessToken } from '@/shared/api/client'
import { queryClient } from '@/shared/api/query-client'
import { importInterstitials } from '@/features/admin/interstitials/api'
import { addEpisode } from '@/features/admin/shows/api'
import { toast } from '@/shared/ui/toast-store'
import type { CreatedIdResponse } from '@/shared/api/types'
import { importMovies, listMedia, uploadMedia } from './api'

export type UploadItem = {
  id: string
  name: string
  percent: number
  status: 'queued' | 'uploading' | 'done' | 'error'
}

type UploadStore = {
  items: UploadItem[]
  active: boolean
  minimized: boolean
  skipped: number
  enqueue: (files: File[], options?: EnqueueOptions) => Promise<void>
  cancel: (id: string) => void
  cancelAll: () => void
  retry: (id: string) => void
  toggleMinimize: () => void
  dismiss: () => void
}

/**
 * Доп-опции загрузки: привязка загружаемых файлов к шоу (добавляются сериями после аплоада).
 * <c>showId</c> — общий для всех файлов; <c>resolveShowId</c> — привязка на каждый файл (напр.
 * автоопределение шоу по имени релиза). Приоритет у <c>resolveShowId</c>, затем общий <c>showId</c>.
 */
type EnqueueOptions = {
  showId?: string
  resolveShowId?: (file: File) => string | undefined
  /** Загрузка с экрана «Ролики»: каждый файл после аплоада становится роликом (Show + серия). */
  interstitial?: boolean
  /**
   * Разбор фильмов: по каждому файлу заводится своё шоу с выбранными в таблице названием и
   * кандидатом источника. Возвращает undefined — файл грузится как обычно, без шоу.
   */
  resolveMovie?: (file: File) => MoviePlan | undefined
  /** Источник метаданных для заведения фильмов — тот, что выбран в таблице разбора. */
  provider?: string
}

/** Что известно про фильм к моменту загрузки файла: строку таблицы уже подтвердили. */
export type MoviePlan = { title: string; year: number | null; externalId: string | null }

// Очередь и флаг живут вне React — загрузка продолжается при любой навигации.
let counter = 0
type Job = {
  id: string
  file: File
  showId?: string
  interstitial?: boolean
  movie?: MoviePlan
  provider?: string
}
const queue: Job[] = []
const controllers = new Map<string, AbortController>()
const failed = new Map<string, Job>() // упавшие — для ручного повтора
let running = false

const MAX_ATTEMPTS = 3
const RETRY_DELAY_MS = 1500

const delay = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms))

/** Временная ли ошибка (стоит ретраить): 5xx/сеть — да; 4xx (формат/дубликат) — нет. */
function isTransient(error: unknown): boolean {
  if (error instanceof HttpError) return error.status >= 500 || error.status === 0
  return true
}

function isAbort(error: unknown): boolean {
  return error instanceof DOMException && error.name === 'AbortError'
}

const patch = (id: string, changes: Partial<UploadItem>) =>
  useUploadStore.setState((s) => ({
    items: s.items.map((i) => (i.id === id ? { ...i, ...changes } : i)),
  }))

/**
 * Стоит ли повторить попытку: отмена — нет; истёкший access-токен (XHR идёт мимо авто-refresh) —
 * обновляем и повторяем сразу; прочие временные сбои — после паузы.
 */
async function shouldRetry(error: unknown, attempt: number, signal: AbortSignal): Promise<boolean> {
  if (isAbort(error) || signal.aborted) return false
  if (attempt >= MAX_ATTEMPTS) return false
  if (error instanceof HttpError && error.status === 401) return await refreshAccessToken()
  if (!isTransient(error)) return false
  await delay(RETRY_DELAY_MS)
  return true
}

/** Аплоад с ретраями временных сбоев (например, 502 от прокси) — до MAX_ATTEMPTS попыток. */
async function uploadWithRetries(
  job: Job,
  signal: AbortSignal,
): Promise<{ created: CreatedIdResponse | null; lastError: unknown }> {
  let lastError: unknown = null
  for (let attempt = 1; attempt <= MAX_ATTEMPTS; attempt++) {
    patch(job.id, { status: 'uploading', percent: 0 })
    try {
      const created = await uploadMedia(job.file, (percent) => patch(job.id, { percent }), signal)
      return { created, lastError: null }
    } catch (error) {
      lastError = error
      if (!(await shouldRetry(error, attempt, signal))) break
    }
  }
  return { created: null, lastError }
}

/** Что делаем со свежим ассетом: привязка к шоу серией (порядок — как в очереди) либо ролик. */
async function linkUploaded(job: Job, assetId: string) {
  if (job.showId) {
    try {
      await addEpisode(job.showId, assetId)
      void queryClient.invalidateQueries({ queryKey: qk.shows.all })
    } catch {
      toast.error(`${job.file.name}: не удалось добавить в шоу`)
    }
  } else if (job.movie && job.provider) {
    // Фильм заводится сразу после аплоада: строку уже подтвердили в таблице разбора, и ждать
    // окончания всей пачки незачем — библиотека наполняется по мере загрузки.
    try {
      await importMovies(
        [
          {
            relativePath: null,
            assetId,
            title: job.movie.title,
            year: job.movie.year,
            externalId: job.movie.externalId,
          },
        ],
        job.provider,
      )
      void queryClient.invalidateQueries({ queryKey: qk.shows.all })
    } catch {
      toast.error(`${job.file.name}: не удалось завести фильм`)
    }
  } else if (job.interstitial) {
    // Ролик заводится сразу после аплоада: длительность подтянется, когда ассет обработается.
    try {
      await importInterstitials([assetId])
      void queryClient.invalidateQueries({ queryKey: qk.interstitials.all })
    } catch {
      toast.error(`${job.file.name}: не удалось завести ролик`)
    }
  }
}

async function runJob(job: Job) {
  const controller = new AbortController()
  controllers.set(job.id, controller)
  const { created, lastError } = await uploadWithRetries(job, controller.signal)
  controllers.delete(job.id)

  if (created) {
    patch(job.id, { status: 'done', percent: 100 })
    void queryClient.invalidateQueries({ queryKey: qk.media.all })
    await linkUploaded(job, created.id)
  } else if (!isAbort(lastError) && !controller.signal.aborted) {
    // Отмена — тихо: элемент уже убран из списка; всё остальное оставляем для ручного повтора.
    failed.set(job.id, job)
    patch(job.id, { status: 'error' })
  }
}

async function pump() {
  if (running) return
  running = true
  useUploadStore.setState({ active: true, minimized: false })

  while (queue.length > 0) await runJob(queue.shift()!)

  running = false
  useUploadStore.setState({ active: false })
}

export const useUploadStore = create<UploadStore>((set) => ({
  items: [],
  active: false,
  minimized: false,
  skipped: 0,

  enqueue: async (files, options) => {
    // Пропускаем дубликаты: уже в библиотеке (не проваленные) и уже в текущей очереди.
    let existing = new Set<string>()
    try {
      const all = await listMedia({
        page: 1,
        pageSize: 1000,
        statuses: ['Pending', 'Processing', 'Ready'],
      })
      existing = new Set(all.items.map((a) => a.originalFileName))
    } catch {
      // положимся на серверную проверку
    }
    const inFlight = new Set(
      useUploadStore
        .getState()
        .items.filter((i) => i.status !== 'error')
        .map((i) => i.name),
    )

    const toAdd = files.filter((f) => !existing.has(f.name) && !inFlight.has(f.name))
    const skipped = files.length - toAdd.length

    const newItems: UploadItem[] = toAdd.map((file) => {
      const id = `u${++counter}`
      const showId = options?.resolveShowId?.(file) ?? options?.showId
      queue.push({
        id,
        file,
        showId,
        interstitial: options?.interstitial,
        movie: options?.resolveMovie?.(file),
        provider: options?.provider,
      })
      return { id, name: file.name, percent: 0, status: 'queued' }
    })

    set((s) => ({
      items: newItems.length ? [...s.items, ...newItems] : s.items,
      skipped: s.skipped + skipped,
      minimized: newItems.length ? false : s.minimized,
    }))
    if (newItems.length) void pump()
  },

  cancel: (id) => {
    // Из очереди — если ещё не стартовал; активную загрузку прерываем.
    const queuedIndex = queue.findIndex((j) => j.id === id)
    if (queuedIndex !== -1) queue.splice(queuedIndex, 1)
    controllers.get(id)?.abort()
    failed.delete(id)
    set((s) => ({ items: s.items.filter((i) => i.id !== id) }))
  },

  cancelAll: () => {
    queue.length = 0
    controllers.forEach((controller) => controller.abort())
    // Оставляем уже завершённые/ошибочные, убираем очередь и текущую загрузку.
    set((s) => ({
      items: s.items.filter((i) => i.status === 'done' || i.status === 'error'),
    }))
  },

  retry: (id) => {
    const job = failed.get(id)
    if (!job) return
    failed.delete(id)
    queue.push(job)
    patch(id, { status: 'queued', percent: 0 })
    void pump()
  },

  toggleMinimize: () => set((s) => ({ minimized: !s.minimized })),
  dismiss: () => {
    failed.clear()
    set({ items: [], skipped: 0 })
  },
}))
