import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
import { useApi } from '../composables/useApi.js'
import { useInflightToken } from '../composables/useInflightToken.js'

// Initial paint is a SINGLE request (INITIAL_LIMIT). The old 10×serial-
// batch loop (2026-05-30) only staggered METADATA, which isn't the visual
// bottleneck — thumbnails load as independent `<img>` requests, and
// GalleryItem now reveals each tile on its own image `@load`. One fetch is
// far fewer round-trips and faster to first paint; the reveal-on-load is
// what makes appearance progressive. Infinite scroll pulls PAGE per trigger.
// Reworked 2026-06-04.
const PAGE = 25
const INITIAL_LIMIT = 50

export const useGalleryStore = defineStore('gallery', () => {
  const api = useApi()

  const images = ref([])
  const dateGroups = ref([])  // [{year, month, image_ids}]
  const nextCursor = ref(null)
  const loading = ref(false)
  const error = ref(null)
  const filter = ref({
    tag_ids: [], artist_id: null, media_type: null,
    sort: 'posted_new', post_id: null,
    // #6 structured tag filter (AND-of-OR + exclude). tag_ids are the AND
    // "include" singletons (light editor + back-compat); tag_or is a list of
    // OR-groups (each group ORs, groups AND); tag_exclude is the NOT set.
    tag_or: [], tag_exclude: [],
    // Phase-2 faceted refine params.
    platform: null, untagged: false, no_artist: false,
    // Reveal the presentation chrome (banner / editor screenshot) the gallery
    // hides by default (milestone 141).
    include_hidden: false,
    date_from: null, date_to: null,
    // Phase-3 visual similarity: when set, the gallery is in "similar mode" —
    // ranked by cosine distance to this image, bounded top-N, no cursor.
    similar_to: null,
  })

  // Live facet counts for the refine panel; fetched on-demand (panel open +
  // filter change), never on plain scroll. Null until first load.
  const facets = ref(null)
  const facetsLoading = ref(false)
  const facetsInflight = useInflightToken()
  // Display names for the active filter chips — resolved by id on deep-link
  // and pre-noted by the filter bar when a user picks from autocomplete.
  const tagLabels = ref({})    // tagId -> name
  const artistLabel = ref(null)

  const timelineBuckets = ref([])
  const timelineLoading = ref(false)

  // Was a hand-rolled inflightId counter; the audit-2026-06-02 fan-out
  // moved this pattern into useInflightToken so every store can share it.
  const inflight = useInflightToken()

  async function loadInitial() {
    inflight.cancel()
    images.value = []
    dateGroups.value = []
    nextCursor.value = null
    await loadMore(INITIAL_LIMIT)
  }

  async function loadMore(limit = PAGE) {
    if (loading.value) return
    loading.value = true
    error.value = null
    const t = inflight.claim()
    try {
      const params = { limit, ...activeFilterParam() }
      if (nextCursor.value) params.cursor = nextCursor.value
      const body = await api.get('/api/gallery/scroll', { params })
      if (!t.isCurrent()) return
      images.value.push(...body.images)
      dateGroups.value = mergeGroups(dateGroups.value, body.date_groups)
      nextCursor.value = body.next_cursor
    } catch (e) {
      error.value = e.message
    } finally {
      if (t.isCurrent()) loading.value = false
    }
  }

  async function loadTimeline() {
    timelineLoading.value = true
    try {
      const params = activeFilterParam()
      timelineBuckets.value = await api.get('/api/gallery/timeline', { params })
    } finally {
      timelineLoading.value = false
    }
  }

  // Visual "more like this": ranked top-N by cosine distance, scope filters
  // composed (AND). No cursor / no timeline — bounded result set.
  async function loadSimilar() {
    inflight.cancel()
    images.value = []
    dateGroups.value = []
    nextCursor.value = null
    timelineBuckets.value = []
    loading.value = true
    error.value = null
    const t = inflight.claim()
    try {
      const f = filter.value
      const params = { similar_to: f.similar_to, limit: 100 }
      if (f.tag_ids.length) params.tag_id = f.tag_ids.join(',')
      const orParam = _orGroupsParam(f.tag_or)
      if (orParam.length) params.tag_or = orParam
      if (f.tag_exclude.length) params.tag_not = f.tag_exclude.join(',')
      if (f.artist_id) params.artist_id = f.artist_id
      if (f.media_type) params.media = f.media_type
      if (f.platform) params.platform = f.platform
      if (f.untagged) params.untagged = '1'
      if (f.no_artist) params.no_artist = '1'
      if (f.date_from) params.date_from = f.date_from
      if (f.date_to) params.date_to = f.date_to
      const body = await api.get('/api/gallery/similar', { params })
      if (!t.isCurrent()) return
      images.value = body.images
      // ranked + bounded → no next page (nextCursor stays null → hasMore false)
    } catch (e) {
      error.value = e.message
    } finally {
      if (t.isCurrent()) loading.value = false
    }
  }

  async function jumpTo(year, month) {
    // Rapid timeline-jump clicks need the same race guard as
    // loadMore — first jump's late body could clobber the second
    // jump's already-applied state.
    inflight.cancel()
    const t = inflight.claim()
    const params = { year, month, ...activeFilterParam() }
    const body = await api.get('/api/gallery/jump', { params })
    if (!t.isCurrent()) return
    if (body.cursor) {
      images.value = []
      dateGroups.value = []
      nextCursor.value = body.cursor
      await loadMore()
    }
  }

  function activeFilterParam() {
    // post_id is the exclusive post-detail view.
    if (filter.value.post_id) return { post_id: filter.value.post_id }
    const p = {}
    if (filter.value.tag_ids.length) p.tag_id = filter.value.tag_ids.join(',')
    const orParam = _orGroupsParam(filter.value.tag_or)
    if (orParam.length) p.tag_or = orParam  // array → repeated tag_or= key
    if (filter.value.tag_exclude.length) p.tag_not = filter.value.tag_exclude.join(',')
    if (filter.value.artist_id) p.artist_id = filter.value.artist_id
    if (filter.value.media_type) p.media = filter.value.media_type
    if (filter.value.sort && filter.value.sort !== 'posted_new') p.sort = filter.value.sort
    if (filter.value.platform) p.platform = filter.value.platform
    if (filter.value.untagged) p.untagged = '1'
    if (filter.value.no_artist) p.no_artist = '1'
    if (filter.value.include_hidden) p.include_hidden = '1'
    if (filter.value.date_from) p.date_from = filter.value.date_from
    if (filter.value.date_to) p.date_to = filter.value.date_to
    return p
  }

  // Live facet counts, scoped to the current filter (panel-gated — callers
  // are the refine panel only). Single-flighted so rapid toggles don't let a
  // stale response clobber a newer one.
  async function loadFacets() {
    facetsLoading.value = true
    const t = facetsInflight.claim()
    try {
      const body = await api.get('/api/gallery/facets', { params: activeFilterParam() })
      if (!t.isCurrent()) return
      facets.value = body
    } catch (e) {
      if (t.isCurrent()) error.value = e.message
    } finally {
      if (t.isCurrent()) facetsLoading.value = false
    }
  }

  // URL is the source of truth for filters. GalleryView calls this on mount
  // and on every route-query change; the filter bar mutates the URL
  // (router.push) rather than the store directly, so deep-links, the back
  // button, and bar actions all funnel through one path.
  async function applyFilterFromQuery(q) {
    filter.value = {
      tag_ids: _parseIds(q.tag_id),
      tag_or: _parseGroups(q.tag_or),
      tag_exclude: _parseIds(q.tag_not),
      artist_id: _toId(q.artist_id),
      media_type: ['image', 'video'].includes(q.media) ? q.media : null,
      sort: ['newest', 'oldest', 'posted_new', 'posted_old'].includes(q.sort) ? q.sort : 'posted_new',
      post_id: _toId(q.post_id),
      platform: q.platform || null,
      untagged: _truthy(q.untagged),
      no_artist: _truthy(q.no_artist),
      include_hidden: _truthy(q.include_hidden),
      date_from: _parseDate(q.date_from),
      date_to: _parseDate(q.date_to),
      similar_to: _toId(q.similar_to),
    }
    if (filter.value.similar_to) {
      // Similar mode: ranked, no timeline scroll.
      await loadSimilar()
    } else {
      await loadInitial()
      await loadTimeline()
    }
    _resolveLabels()
  }

  function _truthy(v) { return v === '1' || v === 'true' || v === true }
  function _parseDate(v) {
    return typeof v === 'string' && /^\d{4}-\d{2}-\d{2}$/.test(v) ? v : null
  }

  function _toId(v) {
    const n = Number(v)
    return Number.isInteger(n) && n > 0 ? n : null
  }
  function _parseIds(raw) {
    if (!raw) return []
    return String(raw).split(',').map(Number).filter((n) => Number.isInteger(n) && n > 0)
  }
  // tag_or arrives from the URL as a string (one group) or string[] (many);
  // each value is a comma-separated OR-group. Normalize to ids-of-ids, dropping
  // empties so a stray `tag_or=` can't become a match-nothing group.
  function _parseGroups(raw) {
    if (raw == null) return []
    const values = Array.isArray(raw) ? raw : [raw]
    return values.map(_parseIds).filter((g) => g.length)
  }

  // Pre-seed a label so a freshly-picked chip shows its name without a
  // round-trip; the bar calls these before pushing the new URL.
  function noteTagLabel(id, name) { tagLabels.value = { ...tagLabels.value, [id]: name } }
  function noteArtistLabel(name) { artistLabel.value = name || null }

  async function _resolveLabels() {
    // Every tag id referenced anywhere in the structured filter needs a chip
    // label — includes, every OR-group member, and excludes.
    const allTagIds = new Set([
      ...filter.value.tag_ids,
      ...filter.value.tag_or.flat(),
      ...filter.value.tag_exclude,
    ])
    for (const id of allTagIds) {
      if (tagLabels.value[id]) continue
      try {
        const t = await api.get(`/api/tags/${id}`)
        tagLabels.value = { ...tagLabels.value, [id]: t.name }
      } catch { /* chip falls back to #id */ }
    }
    if (filter.value.artist_id && !artistLabel.value) {
      // The filtered set is this artist — derive the chip label from a tile.
      const hit = images.value.find(
        (i) => i.artist && i.artist.id === filter.value.artist_id
      )
      artistLabel.value = hit?.artist?.name || null
    }
    if (!filter.value.artist_id) artistLabel.value = null
  }

  const hasMore = computed(() => nextCursor.value !== null)
  const isEmpty = computed(() => !loading.value && images.value.length === 0 && error.value === null)

  return {
    images, dateGroups, hasMore, isEmpty, loading, error,
    filter, tagLabels, artistLabel, timelineBuckets, timelineLoading,
    facets, facetsLoading,
    loadInitial, loadMore, loadTimeline, jumpTo, loadFacets, loadSimilar,
    applyFilterFromQuery, noteTagLabel, noteArtistLabel,
  }
})

// Shared by GalleryFilterBar and GalleryFacetPanel so both write the URL in
// one format. post_id is intentionally absent — the bar/panel are hidden in
// the exclusive post-detail view.
export function cloneFilter(f) {
  return {
    tag_ids: [...f.tag_ids],
    tag_or: (f.tag_or || []).map((g) => [...g]),  // deep-clone the groups
    tag_exclude: [...(f.tag_exclude || [])],
    artist_id: f.artist_id, media_type: f.media_type,
    sort: f.sort, platform: f.platform, untagged: f.untagged,
    no_artist: f.no_artist, include_hidden: f.include_hidden,
    date_from: f.date_from, date_to: f.date_to,
    similar_to: f.similar_to,
  }
}

export function filterToQuery(f) {
  const q = {}
  if (f.tag_ids?.length) q.tag_id = f.tag_ids.join(',')
  const orParam = _orGroupsParam(f.tag_or)
  if (orParam.length) q.tag_or = orParam  // array → repeated query key
  if (f.tag_exclude?.length) q.tag_not = f.tag_exclude.join(',')
  if (f.artist_id) q.artist_id = String(f.artist_id)
  if (f.media_type) q.media = f.media_type
  if (f.sort && f.sort !== 'posted_new') q.sort = f.sort
  if (f.platform) q.platform = f.platform
  if (f.untagged) q.untagged = '1'
  if (f.no_artist) q.no_artist = '1'
  if (f.include_hidden) q.include_hidden = '1'
  if (f.date_from) q.date_from = f.date_from
  if (f.date_to) q.date_to = f.date_to
  if (f.similar_to) q.similar_to = String(f.similar_to)
  return q
}

// Serialize OR-groups to repeated-param form: [[1,2],[3]] → ['1,2','3'],
// dropping empty groups. Shared by the store's activeFilterParam and the
// router-facing filterToQuery so both write tag_or the same way.
function _orGroupsParam(groups) {
  return (groups || [])
    .map((g) => (g || []).join(','))
    .filter((s) => s.length)
}

function mergeGroups(existing, incoming) {
  // Merge sequential groups with the same (year, month) instead of duplicating.
  const merged = [...existing]
  for (const g of incoming) {
    const tail = merged[merged.length - 1]
    if (tail && tail.year === g.year && tail.month === g.month) {
      tail.image_ids = [...tail.image_ids, ...g.image_ids]
    } else {
      merged.push({ ...g, image_ids: [...g.image_ids] })
    }
  }
  return merged
}
