import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
import { setActivePinia, createPinia } from 'pinia'
import { cloneFilter, filterToQuery, useGalleryStore } from '../src/stores/gallery.js'

function stubFetch(handler) {
  globalThis.fetch = vi.fn(async (url, init) => {
    const { status, body } = handler(url, init)
    return {
      ok: status >= 200 && status < 300,
      status,
      statusText: String(status),
      text: async () => (body == null ? '' : JSON.stringify(body)),
    }
  })
}

const EMPTY = { images: [], date_groups: [], next_cursor: null }

describe('gallery store: composable filter', () => {
  beforeEach(() => setActivePinia(createPinia()))
  afterEach(() => vi.restoreAllMocks())

  it('applyFilterFromQuery parses the query and loadMore sends composable params', async () => {
    const s = useGalleryStore()
    const urls = []
    stubFetch((url) => {
      urls.push(url)
      if (url.includes('/api/tags/')) {
        return { status: 200, body: { id: 1, name: 'X', kind: 'general' } }
      }
      return { status: 200, body: EMPTY }
    })
    await s.applyFilterFromQuery({ tag_id: '1,2', artist_id: '5', media: 'video', sort: 'oldest' })
    expect(s.filter.tag_ids).toEqual([1, 2])
    expect(s.filter.artist_id).toBe(5)
    expect(s.filter.media_type).toBe('video')
    expect(s.filter.sort).toBe('oldest')

    const scroll = decodeURIComponent(urls.find((u) => u.includes('/api/gallery/scroll')))
    expect(scroll).toContain('tag_id=1,2')
    expect(scroll).toContain('artist_id=5')
    expect(scroll).toContain('media=video')
    expect(scroll).toContain('sort=oldest')
  })

  it('omits the default sort and sends no filter params when empty', async () => {
    const s = useGalleryStore()
    const urls = []
    stubFetch((url) => { urls.push(url); return { status: 200, body: EMPTY } })
    await s.applyFilterFromQuery({})
    const scroll = urls.find((u) => u.includes('/api/gallery/scroll'))
    expect(scroll).not.toContain('sort=')
    expect(scroll).not.toContain('tag_id=')
    expect(scroll).not.toContain('media=')
  })

  it('treats post_id as the exclusive filter param', async () => {
    const s = useGalleryStore()
    const urls = []
    stubFetch((url) => { urls.push(url); return { status: 200, body: EMPTY } })
    await s.applyFilterFromQuery({ post_id: '7', tag_id: '1' })
    expect(s.filter.post_id).toBe(7)
    const scroll = urls.find((u) => u.includes('/api/gallery/scroll'))
    expect(scroll).toContain('post_id=7')
    expect(scroll).not.toContain('tag_id=')
  })

  it('noteTagLabel and noteArtistLabel pre-seed chip labels', () => {
    const s = useGalleryStore()
    s.noteTagLabel(9, 'Rukia')
    expect(s.tagLabels[9]).toBe('Rukia')
    s.noteArtistLabel('Kubo')
    expect(s.artistLabel).toBe('Kubo')
  })

  it('parses faceted refine params and loadMore forwards them', async () => {
    const s = useGalleryStore()
    const urls = []
    stubFetch((url) => { urls.push(url); return { status: 200, body: EMPTY } })
    await s.applyFilterFromQuery({
      platform: 'pixiv', untagged: '1', no_artist: '1',
      date_from: '2024-01-01', date_to: '2024-12-31',
    })
    expect(s.filter.platform).toBe('pixiv')
    expect(s.filter.untagged).toBe(true)
    expect(s.filter.no_artist).toBe(true)
    expect(s.filter.date_from).toBe('2024-01-01')
    expect(s.filter.date_to).toBe('2024-12-31')
    const scroll = decodeURIComponent(urls.find((u) => u.includes('/api/gallery/scroll')))
    expect(scroll).toContain('platform=pixiv')
    expect(scroll).toContain('untagged=1')
    expect(scroll).toContain('no_artist=1')
    expect(scroll).toContain('date_from=2024-01-01')
    expect(scroll).toContain('date_to=2024-12-31')
  })

  it('drops a malformed date in the query', async () => {
    const s = useGalleryStore()
    stubFetch(() => ({ status: 200, body: EMPTY }))
    await s.applyFilterFromQuery({ date_from: 'garbage' })
    expect(s.filter.date_from).toBe(null)
  })

  it('loadFacets fetches counts scoped to the active filter', async () => {
    const s = useGalleryStore()
    const urls = []
    const FACETS = {
      total: 4, platforms: [{ value: 'patreon', count: 2 }],
      untagged: 1, no_artist: 0,
      date_min: '2020-01-01T00:00:00+00:00', date_max: '2026-06-01T00:00:00+00:00',
    }
    stubFetch((url) => {
      urls.push(url)
      if (url.includes('/api/gallery/facets')) return { status: 200, body: FACETS }
      return { status: 200, body: EMPTY }
    })
    await s.applyFilterFromQuery({ platform: 'patreon', untagged: '1' })
    await s.loadFacets()
    const facetsUrl = decodeURIComponent(urls.find((u) => u.includes('/api/gallery/facets')))
    expect(facetsUrl).toContain('platform=patreon')
    expect(facetsUrl).toContain('untagged=1')
    expect(s.facets.total).toBe(4)
  })

  it('similar_to routes applyFilterFromQuery to the /similar endpoint, not scroll', async () => {
    const s = useGalleryStore()
    const urls = []
    stubFetch((url) => {
      urls.push(url)
      if (url.includes('/api/gallery/similar')) {
        return { status: 200, body: { images: [{ id: 9 }], next_cursor: null, date_groups: [] } }
      }
      return { status: 200, body: EMPTY }
    })
    await s.applyFilterFromQuery({ similar_to: '5', tag_id: '3' })
    expect(s.filter.similar_to).toBe(5)
    const sim = decodeURIComponent(urls.find((u) => u.includes('/api/gallery/similar')))
    expect(sim).toContain('similar_to=5')
    expect(sim).toContain('limit=100')
    expect(sim).toContain('tag_id=3')          // scope composes
    expect(s.images.map((i) => i.id)).toEqual([9])
    expect(s.hasMore).toBe(false)              // ranked + bounded → no pages
    expect(urls.some((u) => u.includes('/api/gallery/scroll'))).toBe(false)
    expect(urls.some((u) => u.includes('/api/gallery/timeline'))).toBe(false)
  })

  it('parses tag_or OR-groups + tag_not and forwards them (incl. repeated tag_or)', async () => {
    const s = useGalleryStore()
    const urls = []
    stubFetch((url) => {
      urls.push(url)
      if (url.includes('/api/tags/')) {
        return { status: 200, body: { id: 1, name: 'X', kind: 'general' } }
      }
      return { status: 200, body: EMPTY }
    })
    // tag_or arrives as an array (two groups); tag_not as a csv exclude list.
    await s.applyFilterFromQuery({ tag_or: ['1,2', '3'], tag_not: '4,5' })
    expect(s.filter.tag_or).toEqual([[1, 2], [3]])
    expect(s.filter.tag_exclude).toEqual([4, 5])
    const scroll = decodeURIComponent(urls.find((u) => u.includes('/api/gallery/scroll')))
    expect(scroll).toContain('tag_or=1,2')   // first OR-group
    expect(scroll).toContain('tag_or=3')     // second OR-group (repeated key)
    expect(scroll).toContain('tag_not=4,5')
  })

  it('normalizes a single-string tag_or and drops empty groups', async () => {
    const s = useGalleryStore()
    stubFetch((url) => {
      if (url.includes('/api/tags/')) {
        return { status: 200, body: { id: 1, name: 'X', kind: 'general' } }
      }
      return { status: 200, body: EMPTY }
    })
    await s.applyFilterFromQuery({ tag_or: '7,8' })   // single string → one group
    expect(s.filter.tag_or).toEqual([[7, 8]])
    await s.applyFilterFromQuery({ tag_or: ['', '9'] })  // empty group dropped
    expect(s.filter.tag_or).toEqual([[9]])
  })

  it('loadInitial issues exactly one scroll request at the initial limit', async () => {
    const s = useGalleryStore()
    const urls = []
    // Non-null cursor: the old 10×serial-batch loop would have fired ten
    // requests here. The single-fetch path must fire exactly one.
    stubFetch((url) => {
      urls.push(url)
      return { status: 200, body: { images: [], date_groups: [], next_cursor: 'c1' } }
    })
    await s.loadInitial()
    const scrollCalls = urls.filter((u) => u.includes('/api/gallery/scroll'))
    expect(scrollCalls).toHaveLength(1)
    expect(scrollCalls[0]).toContain('limit=50')
  })
})

describe('filterToQuery / cloneFilter', () => {
  it('serializes faceted params incl. platform sentinel and flags', () => {
    const q = filterToQuery({
      tag_ids: [3], artist_id: null, media_type: null, sort: 'posted_new',
      platform: '__unsourced__', untagged: true, no_artist: false,
      date_from: '2024-01-01', date_to: null,
    })
    expect(q.tag_id).toBe('3')
    expect(q.platform).toBe('__unsourced__')
    expect(q.untagged).toBe('1')
    expect(q.no_artist).toBeUndefined()
    expect(q.date_from).toBe('2024-01-01')
    expect(q.date_to).toBeUndefined()
    expect(q.sort).toBeUndefined()  // posted_new is the default → omitted
  })

  it('serializes similar_to', () => {
    expect(filterToQuery({ tag_ids: [], similar_to: 42 }).similar_to).toBe('42')
    expect(filterToQuery({ tag_ids: [], similar_to: null }).similar_to).toBeUndefined()
  })

  it('serializes tag_or OR-groups (repeated key) + tag_not, dropping empties', () => {
    const q = filterToQuery({
      tag_ids: [], tag_or: [[1, 2], [3], []], tag_exclude: [4, 5],
    })
    expect(q.tag_or).toEqual(['1,2', '3'])  // empty group dropped → array
    expect(q.tag_not).toBe('4,5')
    const empty = filterToQuery({ tag_ids: [], tag_or: [], tag_exclude: [] })
    expect(empty.tag_or).toBeUndefined()
    expect(empty.tag_not).toBeUndefined()
  })

  it('cloneFilter deep-clones OR-groups + exclude', () => {
    const orig = { tag_ids: [], tag_or: [[1, 2]], tag_exclude: [3] }
    const c = cloneFilter(orig)
    c.tag_or[0].push(9)
    c.tag_exclude.push(8)
    expect(orig.tag_or).toEqual([[1, 2]])   // group detached
    expect(orig.tag_exclude).toEqual([3])   // exclude detached
  })

  it('cloneFilter copies refine fields and detaches tag_ids', () => {
    const orig = {
      tag_ids: [1], artist_id: 2, media_type: 'image', sort: 'oldest',
      platform: 'patreon', untagged: true, no_artist: true,
      date_from: '2024-01-01', date_to: '2024-02-01',
    }
    const c = cloneFilter(orig)
    c.tag_ids.push(9)
    expect(orig.tag_ids).toEqual([1])   // detached
    expect(c.platform).toBe('patreon')
    expect(c.untagged).toBe(true)
    expect(c.date_to).toBe('2024-02-01')
  })
})
