import type { MaybeRefOrGetter } from 'vue'
import type { DragPosition, SwipeAction } from './useDragSetup'
import { computed, reactive, ref, toValue, watch } from 'vue'
import { devWarn } from './devWarn'

/**
 * Lifecycle of a single card, modelled as an explicit state machine.
 *
 * A card is never simply "in the deck" or "gone" — the swipe/restore gestures
 * are animated, so a card spends time in transient states where it's visually
 * moving but not yet logically committed. Modelling this as an explicit state
 * machine (rather than an ad-hoc mix of a history map + a transition map + an
 * `isRestoring` flag) is what makes the tricky gestures correct and readable:
 *
 *   pending ──swipe──▶ swiping ──animationEnd──▶ swiped
 *      ▲                  │                         │
 *      │              restore                   restore
 *      │                  ▼                         ▼
 *      └──animationEnd── restoring ◀───────────────┘
 *                         │
 *                  swipe (restore→next): restoring ──▶ swiping
 *
 * Where each state lives (see the two structures inside the composable):
 * - `pending`   : no record anywhere — absence IS the pending state.
 * - `swiping`   : a `records` entry. Animating off-screen, NOT yet committed.
 *                 A restore can still catch it (fast swipe → restore).
 * - `restoring` : a `records` entry. Animating back in; counts as not-active so
 *                 the active card does NOT jump (restore isn't "active" until
 *                 its animation finishes). A new swipe overwrites it, so
 *                 restore → fast next just cancels the restore.
 * - `swiped`    : a `history` entry (NOT in `records`). Committed and consumed.
 *
 * Splitting swiped (history) from in-flight (records) keeps `records` tiny —
 * bounded by overlapping animations, never by deck size — so its iterations
 * stay cheap as the deck grows.
 */
export type CardState = 'swiping' | 'restoring'

export interface CardRecord {
  state: CardState
  action: SwipeAction // swipe direction / type
  initialPosition?: DragPosition
  // In loop mode, the cycle this record was created in. A `swiping` card that
  // outlives a loop rewind (its fly-out finishes AFTER the deck cycled back)
  // belongs to a stale cycle: it must NOT count as consumed in the new cycle and
  // must NOT commit to `history` when it lands. Undefined outside loop mode.
  cycle?: number
}

export interface StackItem<T> {
  item: T
  itemId: string | number
  stackIndex: number // Current position in stack (0 = top)
  isAnimating?: boolean
  // The in-flight animation descriptor for this card (a "flight"). Internal
  // wiring only: FlashCard consumes it to drive its WAAPI fly-out/restore. NOT
  // the public `animation` config prop (keyframes/duration/easing).
  flight?: {
    type: SwipeAction
    isRestoring: boolean
    initialPosition?: DragPosition
  }
}

export interface StackListOptions<T> {
  items: T[]
  loop?: boolean
  renderLimit: number
  itemKey?: keyof T
  waitAnimationEnd?: boolean
  onLoop?: () => void
}

export interface ResetOptions {
  animate?: boolean
  delay?: number
}

export function useStackList<T extends Record<string, unknown>>(_options: MaybeRefOrGetter<StackListOptions<T>>) {
  const options = computed(() => toValue(_options))

  // -------------------------------------------------------------------------
  // Card lifecycle, split across two structures for performance — but driven by
  // ONE explicit state machine (see CardState docs):
  //
  //   records : only IN-FLIGHT cards (`swiping` / `restoring`). Always small
  //             (bounded by how many animations overlap), so every iteration
  //             over it is cheap regardless of deck size.
  //   history : committed (`swiped`) cards. Grows with the deck, but is only
  //             ever touched by O(1) get/has/set/delete — never iterated on the
  //             hot path.
  //
  // A card's state is the union: in `records` => its record.state; else in
  // `history` => `swiped`; else `pending`. The transition functions keep the
  // two in lock-step so this union is always consistent.
  // -------------------------------------------------------------------------
  const records = reactive(new Map<string | number, CardRecord>())
  const history = reactive(new Map<string | number, SwipeAction>())

  // Monotonic loop-cycle counter. Bumped every time the deck rewinds (loop
  // mode). Records stamped with an older cycle are "stale" — they belong to a
  // deck that no longer exists, so they're ignored by the active-card scan and
  // never committed when their fly-out finally lands. (Stays 0 outside loop.)
  const cycle = ref(0)

  // True while any card is animating (swiping out or restoring back in).
  const hasCardsInTransition = computed(() => records.size > 0)

  // Generate ID for card
  function getId(item: T, index: number): string | number {
    if (!item)
      return index

    const trackKey = options.value.itemKey || 'id'
    return item[trackKey as keyof T] as string | number ?? index
  }

  // -------------------------------------------------------------------------
  // Dev-only sanity checks. Identity tracking is the silent footgun here: when
  // `itemKey` is omitted and items have no `id`, `getId` falls back to the array
  // INDEX. That's fine for a static deck, but the moment `items` is mutated (or
  // `loop` rewinds), index-based ids reassign cards to the wrong history/flight
  // records — cards flicker, restore the wrong one, or get stuck. Warn so the
  // cause isn't a mystery. All stripped from production builds.
  // -------------------------------------------------------------------------
  if (import.meta.env.DEV) {
    watch(
      () => {
        const { items, itemKey } = options.value
        return { len: items.length, sample: items[0], itemKey }
      },
      ({ sample, itemKey }) => {
        if (itemKey || !sample)
          return
        if (!(typeof sample === 'object' && sample !== null && 'id' in sample)) {
          devWarn(
            'missing-item-key',
            'No `itemKey` set and items have no `id` field, so cards are tracked '
            + 'by array index. This breaks `loop` and any runtime mutation of '
            + '`items` (cards may flicker or restore the wrong one). Pass '
            + '`itemKey` pointing to a stable unique field.',
          )
        }
      },
      { immediate: true },
    )
  }

  /**
   * Map of itemId -> index in the source array. Rebuilt only when `items`
   * actually changes, turning the scattered `findIndex` scans into O(1) lookups.
   */
  const idToIndex = computed(() => {
    const { items } = options.value
    const map = new Map<string | number, number>()
    for (let i = 0; i < items.length; i++)
      map.set(getId(items[i], i), i)
    return map
  })

  function indexOfId(itemId: string | number): number {
    return idToIndex.value.get(itemId) ?? -1
  }

  /**
   * A card is "consumed" (no longer the active card) when it's committed
   * (`swiped`, in history) or animating away (`swiping`). A `restoring` card is
   * NOT consumed, but it sits behind the cursor so the forward scan skips it.
   * O(1): two map lookups.
   *
   * A `swiping` record left over from a previous loop cycle is NOT consumed: in
   * the new cycle that same card is a fresh, active card again, even though its
   * old fly-out animation hasn't finished yet. Honouring it would make the deck
   * skip (or hide) a live card on the cycle boundary.
   */
  function isConsumed(itemId: string | number): boolean {
    if (history.has(itemId))
      return true
    const rec = records.get(itemId)
    return rec?.state === 'swiping' && !isStale(rec)
  }

  // A record is stale once the deck has cycled past the cycle it was stamped in.
  function isStale(rec: CardRecord): boolean {
    return rec.cycle !== undefined && rec.cycle !== cycle.value
  }

  // -------------------------------------------------------------------------
  // Active-card cursor (O(1) amortized lookups). `cursorId` is a forward-scan
  // start hint, moved explicitly on swipe/restore/reset/loop. Resilient to
  // runtime mutations of `items`: cards inserted before the cursor never steal
  // focus (we only scan forward); a removed cursor card restarts from 0.
  // -------------------------------------------------------------------------
  const cursorId = ref<string | number | null>(null)

  function resolveActiveIndex(startIndex: number): number {
    const { items } = options.value
    for (let i = Math.max(0, startIndex); i < items.length; i++) {
      if (!isConsumed(getId(items[i], i)))
        return i
    }
    return items.length
  }

  // Position of the active card (first non-consumed), or items.length at end.
  const currentIndex = computed(() => {
    const hintIdx = cursorId.value === null ? 0 : indexOfId(cursorId.value)
    return resolveActiveIndex(hintIdx === -1 ? 0 : hintIdx)
  })

  // ID of the active card (a real id, or the number items.length at end-of-deck
  // — unchanged public activeItemKey contract).
  const currentItemId = computed<string | number>(() => {
    const { items } = options.value
    return getId(items[currentIndex.value], currentIndex.value)
  })

  // Advance the cursor hint to the active card after a transition.
  function syncCursor() {
    const { items } = options.value
    const idx = currentIndex.value
    cursorId.value = idx < items.length ? getId(items[idx], idx) : null
  }

  // -------------------------------------------------------------------------
  // Derived view preserving the original public shape: `cardsInTransition`
  // looks like the old list of animating StackItems. (`history` is the Map
  // declared above — consumers read history.get/has/size directly.)
  //
  // IMPORTANT: StackItem objects (and their nested `flight` object) must keep
  // a STABLE identity for as long as the underlying record is unchanged.
  // FlashCard.vue watches `flight` by reference to drive its WAAPI animation
  // and treats a new reference as "animation replaced" (cancel + re-run) — so
  // rebuilding these objects on every recompute would falsely restart in-flight
  // animations when a sibling card starts animating (e.g. fast swipe → fast
  // restore). We cache by itemId and only rebuild when the record's meaningful
  // fields change.
  // -------------------------------------------------------------------------
  const stackItemCache = new Map<string | number, { rec: CardRecord, stackItem: StackItem<T> }>()

  function getTransitionStackItem(itemId: string | number, rec: CardRecord): StackItem<T> {
    const cached = stackItemCache.get(itemId)
    if (
      cached
      && cached.rec.state === rec.state
      && cached.rec.action === rec.action
      && cached.rec.initialPosition === rec.initialPosition
    ) {
      return cached.stackItem
    }

    const idx = indexOfId(itemId)
    const stackItem: StackItem<T> = {
      item: options.value.items[idx] as T,
      itemId,
      stackIndex: 0,
      isAnimating: true,
      flight: {
        type: rec.action,
        isRestoring: rec.state === 'restoring',
        initialPosition: rec.initialPosition,
      },
    }
    stackItemCache.set(itemId, { rec, stackItem })
    return stackItem
  }

  // Cards currently animating in the DOM. `records` holds ONLY in-flight cards
  // (swiping/restoring), so this is bounded by overlapping animations, not deck
  // size.
  const transitionList = computed<StackItem<T>[]>(() => {
    const result: StackItem<T>[] = []
    for (const [id, rec] of records) {
      // A stale swiping record (its fly-out outlived a loop rewind) keeps
      // animating off-screen — UNLESS the same item has already become the live
      // active card in the new cycle (fast-swipe wrapped all the way back to it).
      // In that one case rendering it as an animating ghost would duplicate the
      // vnode key and steal the active card, so skip it: the deck renders the
      // fresh live card instead (its FlashCard cancels the leftover fly-out).
      if (isStale(rec) && id === currentItemId.value)
        continue
      if (indexOfId(id) !== -1)
        result.push(getTransitionStackItem(id, rec))
    }
    // Drop cache entries for cards that left transition, so they rebuild fresh
    // if they animate again later.
    if (stackItemCache.size > records.size) {
      for (const id of stackItemCache.keys()) {
        if (!records.has(id))
          stackItemCache.delete(id)
      }
    }
    return result
  })

  // Number of cards currently restoring (used to look ahead for isStart/isEnd).
  const restoringCount = computed(() => {
    let n = 0
    for (const rec of records.values()) {
      if (rec.state === 'restoring')
        n++
    }
    return n
  })

  // Expected index once in-flight restores settle — keeps isStart/isEnd stable.
  const expectedIndex = computed(() => currentIndex.value - restoringCount.value)

  // -------------------------------------------------------------------------
  // Loop mode: on a completed cycle, clear committed cards and rewind.
  // -------------------------------------------------------------------------
  watch(expectedIndex, (ci) => {
    const { loop, items, onLoop } = options.value
    if (loop && ci === items.length) {
      // Drop all committed cards and rewind. In-flight fly-outs are left running
      // (they finish visually), but they now belong to the OLD cycle: bumping
      // `cycle` marks their records stale so they neither block the fresh active
      // card nor re-commit when they land. See `isStale` / `removeAnimatingCard`.
      history.clear()
      cursorId.value = null
      cycle.value++
      onLoop?.()
    }
  })

  // -------------------------------------------------------------------------
  // Stack generation (animating cards first, then the forward window).
  // -------------------------------------------------------------------------
  function generateStackItems(startIndex: number, limit: number, items: T[], loop: boolean): StackItem<T>[] {
    const result: StackItem<T>[] = []
    const len = items.length
    const animatingAdded = new Set<string | number>()

    // Animating cards (swiping/restoring) render on top.
    for (const card of transitionList.value) {
      result.push(card)
      animatingAdded.add(card.itemId)
    }

    // Then the regular forward window.
    for (let i = 0; i < limit; i++) {
      const index = loop ? (startIndex + i + len) % len : startIndex + i
      if (!loop && index >= len)
        break

      const item = items[index]
      const itemId = getId(item, index)

      if (!animatingAdded.has(itemId)) {
        result.push({ item, itemId, stackIndex: i, isAnimating: false })
      }
    }

    return result
  }

  const stackList = computed(() => {
    const { renderLimit, items, loop = false } = options.value
    if (!items.length)
      return []

    return generateStackItems(currentIndex.value, renderLimit, items, loop)
  })

  const isStart = computed(() => expectedIndex.value === 0)
  const isEnd = computed(() => expectedIndex.value >= options.value.items.length)

  /**
   * Can restore if there's a committed-or-swiping card before the active one.
   * (A `swiping` card counts — fast swipe → restore must be able to catch it.)
   */
  const canRestore = computed(() => {
    if (options.value.items.length <= 1 || expectedIndex.value === 0)
      return false

    const { items } = options.value
    for (let i = expectedIndex.value - 1; i >= 0; i--) {
      const id = getId(items[i], i)
      const rec = records.get(id)
      // committed (history) or still swiping away — both are restorable.
      // A stale swiping record (old loop cycle) is not part of this deck.
      if (history.has(id) || (rec?.state === 'swiping' && !isStale(rec)))
        return true
    }
    return false
  })

  // -------------------------------------------------------------------------
  // Transitions
  // -------------------------------------------------------------------------

  /**
   * swipe: pending/swiped/restoring ──▶ swiping
   * Overwriting an existing record is intentional: restore → fast next lands
   * here and simply cancels the in-flight restore by switching it to swiping.
   */
  function swipeCard(itemId: string | number, type: SwipeAction, initialPosition?: DragPosition): T | undefined {
    const { items, waitAnimationEnd } = options.value

    // Block new actions while something animates, if requested.
    if (hasCardsInTransition.value && waitAnimationEnd)
      return

    const idx = indexOfId(itemId)
    if (idx === -1)
      return
    const item = items[idx]

    // Card is now in-flight again; it lives in `records`, not `history`
    // (covers restore → fast next: a restoring card that was still committed).
    records.set(itemId, { state: 'swiping', action: type, initialPosition, cycle: cycle.value })
    history.delete(itemId)
    // The swiped card is now consumed; advance the cursor.
    syncCursor()

    return item
  }

  /**
   * Swipe whatever card is "active" for a button-triggered action.
   *
   * Normally that's the current card. But if a restore is mid-animation, the
   * intent of pressing next/swipe is to act on the card being restored (restore
   * → fast next cancels the restore and sends it back out). This encapsulates
   * that target-selection so consumers don't need to know about the state
   * machine — they just call swipeActive(type).
   */
  function swipeActive(type: SwipeAction): T | undefined {
    // A restoring card takes priority as the action target. With several cards
    // restoring at once (e.g. two fast restores), the target is the TOPMOST card
    // the user sees — the one with the highest z-index.
    //
    // z-index of animating cards follows `records` INSERTION order (see the
    // stack template: animating cards render in `transitionList` / `records`
    // order, later ones on top). Restores run back-to-front (descending array
    // index), so the LAST-inserted restoring record — the last one iterated, NOT
    // the highest array index — is the one on top. Picking by array index would
    // swipe the card UNDERNEATH the one the user is looking at.
    let target: string | number | undefined
    for (const [id, rec] of records) {
      if (rec.state === 'restoring')
        target = id // keep overwriting → ends on the last (topmost) one
    }
    if (target !== undefined)
      return swipeCard(target, type)
    // Otherwise act on the current active card, if it's not already animating.
    const id = currentItemId.value
    if (indexOfId(id) !== -1 && !records.has(id))
      return swipeCard(id, type)
    return undefined
  }

  /**
   * restore: finds the most recent committed (`swiped`) or in-flight (`swiping`)
   * card before the active one and moves it to `restoring`. The active card does
   * NOT change yet — the restored card only becomes active when its animation
   * finishes (so restore → fast next can cancel it cleanly).
   */
  function restoreCard(): T | undefined {
    if (!canRestore.value)
      return

    const { items } = options.value
    // Search backward from the active card for something to restore.
    for (let i = expectedIndex.value - 1; i >= 0; i--) {
      const itemId = getId(items[i], i)
      const rec = records.get(itemId)

      if (rec?.state === 'restoring')
        continue // already on its way back

      // The action is whatever it was swiped as — from the in-flight record if
      // it's still swiping, otherwise from committed history.
      const action = rec?.state === 'swiping' ? rec.action : history.get(itemId)
      if (action) {
        records.set(itemId, { state: 'restoring', action, initialPosition: rec?.initialPosition })
        return items[i]
      }
    }

    return undefined
  }

  /**
   * animationEnd: resolve a transient state to its committed form.
   *   swiping   ──▶ swiped   (commit; in loop mode only if it's still this cycle)
   *   restoring ──▶ pending  (delete record; card returns to the deck)
   */
  function removeAnimatingCard(itemId: string | number) {
    const rec = records.get(itemId)
    if (!rec)
      return

    if (rec.state === 'restoring') {
      records.delete(itemId)
      history.delete(itemId)
      // Restored card sits behind the cursor; point the cursor at it directly.
      moveCursorTo(itemId)
    }
    else if (rec.state === 'swiping') {
      // A fly-out that outlived a loop rewind belongs to the old cycle: just drop
      // its record. Committing it would mark a card the new cycle treats as fresh
      // as already-swiped (a phantom gap). The card is already pending again, so
      // dropping is all that's needed.
      if (isStale(rec))
        records.delete(itemId)
      else
        commit(itemId, rec)
      syncCursor()
    }
  }

  // swiping ──▶ swiped: drop the in-flight record, commit to history.
  function commit(itemId: string | number, rec: CardRecord) {
    records.delete(itemId)
    history.set(itemId, rec.action)
  }

  function moveCursorTo(itemId: string | number) {
    cursorId.value = itemId
  }

  // -------------------------------------------------------------------------
  // Reset
  // -------------------------------------------------------------------------
  async function reset(resetOptions?: ResetOptions) {
    if (resetOptions?.animate) {
      // Cascade: restore committed cards one by one for a fanning effect.
      const committed = [...history.keys()]
      for (let i = 0; i < committed.length; i++) {
        if (restoreCard())
          await new Promise(resolve => setTimeout(resolve, resetOptions?.delay ?? 90))
      }
      // Records clear themselves as restore animations finish.
    }
    else {
      records.clear()
      history.clear()
      cursorId.value = null
    }
  }

  return {
    history,
    currentIndex,
    isStart,
    isEnd,
    canRestore,
    stackList,
    swipeCard,
    swipeActive,
    restoreCard,
    removeAnimatingCard,
    reset,
    hasCardsInTransition,
    currentItemId,
    cardsInTransition: transitionList,
  }
}
