import { describe, it, expect, vi, beforeEach } from 'vitest'
import { mount, flushPromises } from '@vue/test-utils'
import { reactive } from 'vue'
import { setActivePinia, createPinia } from 'pinia'
import { useImagesStore } from '@/stores/images'
import { useDevicesStore } from '@/stores/devices'
import LibraryView from '@/views/LibraryView.vue'
import type { Image, Device } from '@/types'

const routerPush = vi.fn()

// Stub complex child components
vi.mock('@/components/BaseBottomSheet.vue', () => ({
  default: {
    name: 'BaseBottomSheet',
    template: '<div class="bottom-sheet-stub"><slot /></div>',
    props: ['modelValue', 'label'],
    emits: ['update:modelValue'],
  },
}))
vi.mock('@/components/BaseButton.vue', () => ({
  default: {
    name: 'BaseButton',
    template: '<button :disabled="disabled" @click="$emit(\'click\')"><slot /></button>',
    props: ['variant', 'disabled'],
    emits: ['click'],
  },
}))
vi.mock('@/components/ApproveCard.vue', () => ({
  default: {
    name: 'ApproveCard',
    template: '<div class="approve-card-stub" />',
    props: ['item'],
    emits: ['updated'],
  },
}))
vi.mock('@/components/ShareSheet.vue', () => ({
  default: {
    name: 'ShareSheet',
    template: '<div class="share-sheet-stub" />',
    props: ['modelValue', 'imageId'],
    emits: ['update:modelValue'],
  },
}))

// Stub vue-router with a reactive route so watchers fire when query changes
const mockRoute = reactive<{ query: Record<string, string | undefined> }>({ query: {} })
vi.mock('vue-router', () => ({
  useRouter: () => ({ push: routerPush }),
  useRoute: () => mockRoute,
}))

// Stable mocks for toast + upload so individual tests can spy on them
const toastShow = vi.fn()
vi.mock('@/stores/toast', () => ({
  useToastStore: () => ({ show: toastShow }),
}))
const uploadInitEdit = vi.fn()
const uploadInit     = vi.fn()
vi.mock('@/stores/upload', () => ({
  useUploadStore: () => ({ initEdit: uploadInitEdit, init: uploadInit }),
}))

const makeImage = (overrides: Partial<Image> = {}): Image => ({
  id: 1,
  originalFilename: 'photo.jpg',
  thumbnailUrl: '/thumb/1.jpg',
  originalUrl: '/orig/1.jpg',
  uploadedAt: '2026-01-01T00:00:00Z',
  approvedDeviceIds: [],
  cropParams: null,
  stickerState: null,
  cropOrientation: null,
  ...overrides,
})

const makeDevice = (overrides: Partial<Device> = {}): Device => ({
  id: 1,
  mac: 'AA:BB:CC:DD:EE:FF',
  name: 'Living Room',
  orientation: 'landscape',
  model: 'v1',
  rotationIntervalMinutes: 60,
  wakeTimes: [],
  timezone: 'America/Chicago',
  uniquenessWindow: 30,
  rotationMode: 'oldest_upload',
  prioritizeNeverShown: false,
  linkedAt: '2026-01-01T00:00:00Z',
  lastSeenAt: null,
  nextPollExpectedAt: null,
  lockedImageId: null,
  currentImageId: null,
  ...overrides,
})

describe('LibraryView', () => {
  let pinia: ReturnType<typeof createPinia>

  beforeEach(() => {
    vi.unstubAllGlobals()
    vi.restoreAllMocks()
    pinia = createPinia()
    setActivePinia(pinia)
    mockRoute.query = {}
    toastShow.mockClear()
    uploadInitEdit.mockClear()
    uploadInit.mockClear()
    routerPush.mockClear()

    // Default fetch stub — returns empty lists so onMounted doesn't error
    vi.stubGlobal('fetch', vi.fn().mockResolvedValue({
      ok: true,
      json: () => Promise.resolve([]),
    }))
  })

  function mountView() {
    return mount(LibraryView, {
      global: {
        plugins: [pinia],
      },
    })
  }

  // LV-00: Add Photo button at the top
  it('renders an Add Photo button at the top of the page', async () => {
    const imagesStore = useImagesStore()
    imagesStore.images = []
    imagesStore.loading = false
    vi.spyOn(imagesStore, 'fetchImages').mockResolvedValue()
    vi.spyOn(imagesStore, 'fetchPendingCount').mockResolvedValue()
    vi.spyOn(useDevicesStore(), 'fetchDevices').mockResolvedValue()

    const wrapper = mountView()
    await flushPromises()

    const addBtn = wrapper.find('.library__add-btn')
    expect(addBtn.exists()).toBe(true)
    expect(addBtn.text()).toContain('Add Photo')
  })

  it('clicking the Add Photo button primes the upload store and routes to /upload', async () => {
    const imagesStore = useImagesStore()
    imagesStore.images = []
    imagesStore.loading = false
    vi.spyOn(imagesStore, 'fetchImages').mockResolvedValue()
    vi.spyOn(imagesStore, 'fetchPendingCount').mockResolvedValue()
    vi.spyOn(useDevicesStore(), 'fetchDevices').mockResolvedValue()

    const wrapper = mountView()
    await flushPromises()

    // The file <input> is persistent in the template (see LibraryView).
    const fileInput = wrapper.find('input[type="file"]')
    expect(fileInput.exists()).toBe(true)
    ;(fileInput.element as HTMLInputElement).click = vi.fn()

    await wrapper.find('.library__add-btn').trigger('click')

    const file = new File(['x'], 'photo.jpg', { type: 'image/jpeg' })
    Object.defineProperty(fileInput.element, 'files', { value: [file], configurable: true })
    await fileInput.trigger('change')

    expect(uploadInit).toHaveBeenCalledWith(file)
    expect(routerPush).toHaveBeenCalledWith('/upload')
  })

  // LV-01: Default tab shows "All" tab active
  it('renders the All tab as active by default', async () => {
    const imagesStore = useImagesStore()
    imagesStore.images = [makeImage({ id: 1 }), makeImage({ id: 2 })]
    imagesStore.loading = false
    vi.spyOn(imagesStore, 'fetchImages').mockResolvedValue()
    vi.spyOn(imagesStore, 'fetchPendingCount').mockResolvedValue()
    const devicesStore = useDevicesStore()
    vi.spyOn(devicesStore, 'fetchDevices').mockResolvedValue()

    const wrapper = mountView()
    await wrapper.vm.$nextTick()

    // The "All" tab button should have aria-selected=true
    const tabs = wrapper.findAll('[role="tab"]')
    const allTab = tabs.find(t => t.text() === 'All')
    expect(allTab?.attributes('aria-selected')).toBe('true')
  })

  // LV-01b: Images from imagesStore are rendered in the grid
  it('renders image grid when images exist', async () => {
    const imagesStore = useImagesStore()
    imagesStore.images = [makeImage({ id: 1 }), makeImage({ id: 2 }), makeImage({ id: 3 })]
    imagesStore.loading = false
    vi.spyOn(imagesStore, 'fetchImages').mockResolvedValue()
    vi.spyOn(imagesStore, 'fetchPendingCount').mockResolvedValue()
    const devicesStore = useDevicesStore()
    vi.spyOn(devicesStore, 'fetchDevices').mockResolvedValue()

    const wrapper = mountView()
    await wrapper.vm.$nextTick()

    const grid = wrapper.find('.library__grid')
    expect(grid.exists()).toBe(true)
    expect(wrapper.findAll('.library__item')).toHaveLength(3)
  })

  // LV-02: Switching to Shared tab shows the shared sub-tabs UI
  it('switching to Shared tab shows shared sub-tabs and triggers a fetch', async () => {
    const sharedPage = { items: [], total: 0, page: 1, limit: 20, totalPages: 1 }
    const imagesStore = useImagesStore()
    vi.spyOn(imagesStore, 'fetchImages').mockResolvedValue()
    vi.spyOn(imagesStore, 'fetchPendingCount').mockResolvedValue()
    const devicesStore = useDevicesStore()
    vi.spyOn(devicesStore, 'fetchDevices').mockResolvedValue()

    // Set up fetch so fetchSharedImages network call resolves
    vi.stubGlobal('fetch', vi.fn().mockResolvedValue({
      ok: true,
      json: () => Promise.resolve(sharedPage),
    }))

    const wrapper = mountView()
    await wrapper.vm.$nextTick()

    const tabs = wrapper.findAll('[role="tab"]')
    const sharedTab = tabs.find(t => t.text() === 'Shared')
    await sharedTab?.trigger('click')
    await wrapper.vm.$nextTick()
    await wrapper.vm.$nextTick()

    // After clicking Shared, the sub-tabs (Pending/Approved/Declined) should appear
    expect(wrapper.find('.library__subtabs').exists()).toBe(true)
  })

  // LV-03: Lock chip shown for device when image is approved for it
  // Corner-lock badge appears on the photo thumb when ANY device has the
  // image locked. Replaces the old per-device lock-chip row.
  it('renders the corner-lock badge when the image is locked on any device', async () => {
    const imagesStore = useImagesStore()
    const devicesStore = useDevicesStore()

    devicesStore.devices = [makeDevice({ id: 10, name: 'Bedroom', lockedImageId: 1 })]
    imagesStore.images = [makeImage({ id: 1, approvedDeviceIds: [10] })]
    imagesStore.loading = false
    vi.spyOn(imagesStore, 'fetchImages').mockResolvedValue()
    vi.spyOn(imagesStore, 'fetchPendingCount').mockResolvedValue()
    vi.spyOn(devicesStore, 'fetchDevices').mockResolvedValue()

    const wrapper = mountView()
    await wrapper.vm.$nextTick()

    expect(wrapper.find('.library__thumb-lock').exists()).toBe(true)
  })

  it('does NOT render the corner-lock badge when image is approved but unlocked', async () => {
    const imagesStore = useImagesStore()
    const devicesStore = useDevicesStore()

    devicesStore.devices = [makeDevice({ id: 10, name: 'Bedroom', lockedImageId: null })]
    imagesStore.images = [makeImage({ id: 1, approvedDeviceIds: [10] })]
    imagesStore.loading = false
    vi.spyOn(imagesStore, 'fetchImages').mockResolvedValue()
    vi.spyOn(imagesStore, 'fetchPendingCount').mockResolvedValue()
    vi.spyOn(devicesStore, 'fetchDevices').mockResolvedValue()

    const wrapper = mountView()
    await wrapper.vm.$nextTick()

    expect(wrapper.find('.library__thumb-lock').exists()).toBe(false)
  })

  // LV-06: Share button click renders the ShareSheet
  it('clicking share button renders the ShareSheet', async () => {
    const imagesStore = useImagesStore()
    imagesStore.images = [makeImage({ id: 5 })]
    imagesStore.loading = false
    vi.spyOn(imagesStore, 'fetchImages').mockResolvedValue()
    vi.spyOn(imagesStore, 'fetchPendingCount').mockResolvedValue()
    const devicesStore = useDevicesStore()
    vi.spyOn(devicesStore, 'fetchDevices').mockResolvedValue()

    const wrapper = mountView()
    await wrapper.vm.$nextTick()

    // Find the share action button (aria-label contains "Share")
    const shareBtn = wrapper.findAll('.library__action-btn')
      .find(b => b.attributes('aria-label')?.includes('Share'))
    expect(shareBtn).toBeTruthy()
    await shareBtn!.trigger('click')
    await wrapper.vm.$nextTick()

    // After clicking, the ShareSheet stub should be rendered
    expect(wrapper.find('.share-sheet-stub').exists()).toBe(true)
  })

  // LV-07: Empty state shown when no images (All tab)
  it('shows empty state when no images exist', async () => {
    const imagesStore = useImagesStore()
    imagesStore.images = []
    imagesStore.loading = false
    vi.spyOn(imagesStore, 'fetchImages').mockResolvedValue()
    vi.spyOn(imagesStore, 'fetchPendingCount').mockResolvedValue()
    const devicesStore = useDevicesStore()
    vi.spyOn(devicesStore, 'fetchDevices').mockResolvedValue()

    const wrapper = mountView()
    await wrapper.vm.$nextTick()

    expect(wrapper.find('.library__empty').exists()).toBe(true)
    expect(wrapper.text()).toContain('No photos yet')
  })

  // LV-08: Deep-link via ?tab=shared — Shared sub-tab is initially active
  it('starts on Shared tab when route enters with ?tab=shared', async () => {
    mockRoute.query = { tab: 'shared' }
    const sharedPage = { items: [], total: 0, page: 1, limit: 20, totalPages: 1 }
    const imagesStore = useImagesStore()
    vi.spyOn(imagesStore, 'fetchImages').mockResolvedValue()
    vi.spyOn(imagesStore, 'fetchPendingCount').mockResolvedValue()
    vi.spyOn(imagesStore, 'fetchSharedImages').mockResolvedValue(sharedPage)
    vi.spyOn(useDevicesStore(), 'fetchDevices').mockResolvedValue()

    const wrapper = mountView()
    await flushPromises()

    const tabs = wrapper.findAll('[role="tab"]')
    const sharedTab = tabs.find(t => t.text() === 'Shared')
    expect(sharedTab?.attributes('aria-selected')).toBe('true')
    expect(wrapper.find('.library__subtabs').exists()).toBe(true)
  })

  // LV-09: Watcher — switching the route query swaps the active tab and loads shared
  it('switches active tab when route.query.tab changes', async () => {
    const sharedPage = { items: [], total: 0, page: 1, limit: 20, totalPages: 1 }
    const imagesStore = useImagesStore()
    imagesStore.images = []
    imagesStore.loading = false
    vi.spyOn(imagesStore, 'fetchImages').mockResolvedValue()
    vi.spyOn(imagesStore, 'fetchPendingCount').mockResolvedValue()
    const fetchSharedSpy = vi.spyOn(imagesStore, 'fetchSharedImages').mockResolvedValue(sharedPage)
    vi.spyOn(useDevicesStore(), 'fetchDevices').mockResolvedValue()

    const wrapper = mountView()
    await flushPromises()

    const initialAll = wrapper.findAll('[role="tab"]').find(t => t.text() === 'All')
    expect(initialAll?.attributes('aria-selected')).toBe('true')
    expect(fetchSharedSpy).not.toHaveBeenCalled()

    mockRoute.query = { tab: 'shared' }
    await flushPromises()

    const sharedTab = wrapper.findAll('[role="tab"]').find(t => t.text() === 'Shared')
    expect(sharedTab?.attributes('aria-selected')).toBe('true')
    expect(fetchSharedSpy).toHaveBeenCalled()
  })

  // LV-10: Unknown tab values fall back to 'all'
  it('falls back to All when route.query.tab is an unknown value', async () => {
    mockRoute.query = { tab: 'bogus' }
    const imagesStore = useImagesStore()
    vi.spyOn(imagesStore, 'fetchImages').mockResolvedValue()
    vi.spyOn(imagesStore, 'fetchPendingCount').mockResolvedValue()
    vi.spyOn(useDevicesStore(), 'fetchDevices').mockResolvedValue()

    const wrapper = mountView()
    await flushPromises()

    const allTab = wrapper.findAll('[role="tab"]').find(t => t.text() === 'All')
    expect(allTab?.attributes('aria-selected')).toBe('true')
  })

  // LV-07b: Empty state on shared sub-tab (pending)
  it('shows shared empty state when no shared items exist', async () => {
    const sharedPage = { items: [], total: 0, page: 1, limit: 20, totalPages: 1 }
    const imagesStore = useImagesStore()
    vi.spyOn(imagesStore, 'fetchImages').mockResolvedValue()
    vi.spyOn(imagesStore, 'fetchPendingCount').mockResolvedValue()
    vi.spyOn(imagesStore, 'fetchSharedImages').mockResolvedValue(sharedPage)
    const devicesStore = useDevicesStore()
    vi.spyOn(devicesStore, 'fetchDevices').mockResolvedValue()

    const wrapper = mountView()
    await wrapper.vm.$nextTick()

    // Switch to Shared tab
    const tabs = wrapper.findAll('[role="tab"]')
    const sharedTab = tabs.find(t => t.text() === 'Shared')
    await sharedTab?.trigger('click')
    // Wait for async loadShared to complete
    await wrapper.vm.$nextTick()
    await wrapper.vm.$nextTick()

    expect(wrapper.find('.library__shared-empty').exists()).toBe(true)
  })

  // LV-11: Edit (loadShared cropped image) routes to /upload after initEdit
  it('clicking edit calls upload.initEdit and navigates to /upload', async () => {
    uploadInitEdit.mockResolvedValue(undefined)
    const imagesStore = useImagesStore()
    imagesStore.images = [makeImage({ id: 7 })]
    imagesStore.loading = false
    vi.spyOn(imagesStore, 'fetchImages').mockResolvedValue()
    vi.spyOn(imagesStore, 'fetchPendingCount').mockResolvedValue()
    vi.spyOn(useDevicesStore(), 'fetchDevices').mockResolvedValue()

    const wrapper = mountView()
    await flushPromises()

    const editBtn = wrapper.findAll('.library__action-btn')
      .find(b => b.attributes('aria-label')?.startsWith('Edit'))!
    await editBtn.trigger('click')
    await flushPromises()

    expect(uploadInitEdit).toHaveBeenCalled()
    expect(routerPush).toHaveBeenCalledWith('/upload')
  })

  it('shows a toast when edit fails to load', async () => {
    uploadInitEdit.mockRejectedValue(new Error('boom'))
    const imagesStore = useImagesStore()
    imagesStore.images = [makeImage({ id: 7 })]
    imagesStore.loading = false
    vi.spyOn(imagesStore, 'fetchImages').mockResolvedValue()
    vi.spyOn(imagesStore, 'fetchPendingCount').mockResolvedValue()
    vi.spyOn(useDevicesStore(), 'fetchDevices').mockResolvedValue()

    const wrapper = mountView()
    await flushPromises()
    await wrapper.findAll('.library__action-btn')
      .find(b => b.attributes('aria-label')?.startsWith('Edit'))!.trigger('click')
    await flushPromises()

    expect(toastShow).toHaveBeenCalledWith(expect.stringContaining('Could not load'), 'error')
  })

  it('ignores a second edit click while another edit is already in flight', async () => {
    let resolveEdit: () => void = () => {}
    uploadInitEdit.mockImplementation(() => new Promise<void>(r => { resolveEdit = r }))
    const imagesStore = useImagesStore()
    imagesStore.images = [makeImage({ id: 1 }), makeImage({ id: 2 })]
    imagesStore.loading = false
    vi.spyOn(imagesStore, 'fetchImages').mockResolvedValue()
    vi.spyOn(imagesStore, 'fetchPendingCount').mockResolvedValue()
    vi.spyOn(useDevicesStore(), 'fetchDevices').mockResolvedValue()

    const wrapper = mountView()
    await flushPromises()
    const editButtons = wrapper.findAll('.library__action-btn')
      .filter(b => b.attributes('aria-label')?.startsWith('Edit'))
    expect(editButtons).toHaveLength(2)
    await editButtons[0].trigger('click')
    // Second click on a *different* photo's edit button — startEdit should
    // see editingId is set and early-return without invoking initEdit again.
    await editButtons[1].trigger('click')
    expect(uploadInitEdit).toHaveBeenCalledTimes(1)
    resolveEdit()
    await flushPromises()
  })

  // LV-12: Delete confirmation flow (success + failure)
  it('confirming delete calls deleteImage and shows success toast', async () => {
    const imagesStore = useImagesStore()
    imagesStore.images = [makeImage({ id: 9 })]
    imagesStore.loading = false
    vi.spyOn(imagesStore, 'fetchImages').mockResolvedValue()
    vi.spyOn(imagesStore, 'fetchPendingCount').mockResolvedValue()
    const deleteSpy = vi.spyOn(imagesStore, 'deleteImage').mockResolvedValue()
    vi.spyOn(useDevicesStore(), 'fetchDevices').mockResolvedValue()

    const wrapper = mountView()
    await flushPromises()
    const deleteBtn = wrapper.findAll('.library__action-btn')
      .find(b => b.attributes('aria-label') === 'Delete photo')!
    await deleteBtn.trigger('click')
    await flushPromises()

    // Sheet now open with a Delete button
    const sheetDelete = wrapper.findAll('button').find(b => b.text() === 'Delete')!
    await sheetDelete.trigger('click')
    await flushPromises()

    expect(deleteSpy).toHaveBeenCalledWith(9)
    expect(toastShow).toHaveBeenCalledWith('Photo deleted', 'success')
  })

  it('shows an error toast when delete fails', async () => {
    const imagesStore = useImagesStore()
    imagesStore.images = [makeImage({ id: 9 })]
    imagesStore.loading = false
    vi.spyOn(imagesStore, 'fetchImages').mockResolvedValue()
    vi.spyOn(imagesStore, 'fetchPendingCount').mockResolvedValue()
    vi.spyOn(imagesStore, 'deleteImage').mockRejectedValue(new Error('nope'))
    vi.spyOn(useDevicesStore(), 'fetchDevices').mockResolvedValue()

    const wrapper = mountView()
    await flushPromises()
    await wrapper.findAll('.library__action-btn')
      .find(b => b.attributes('aria-label') === 'Delete photo')!.trigger('click')
    await wrapper.findAll('button').find(b => b.text() === 'Delete')!.trigger('click')
    await flushPromises()

    expect(toastShow).toHaveBeenCalledWith('Delete failed', 'error')
  })

  // LV-13/14: Curation now happens through ManageImageSheet (Concept A in
  // _bmad-output/.../library-many-frames-design-ideas.md). LibraryView's
  // only responsibility is opening the sheet and forwarding its events to
  // the right store mutation; the sheet's own toggle interactions are
  // covered in ManageImageSheet.test.ts.

  it('clicking Manage opens the ManageImageSheet for that image', async () => {
    const imagesStore = useImagesStore()
    const devicesStore = useDevicesStore()
    devicesStore.devices = [makeDevice({ id: 4, name: 'Hall' })]
    imagesStore.images = [makeImage({ id: 1, approvedDeviceIds: [] })]
    imagesStore.loading = false
    vi.spyOn(imagesStore, 'fetchImages').mockResolvedValue()
    vi.spyOn(imagesStore, 'fetchPendingCount').mockResolvedValue()
    vi.spyOn(devicesStore, 'fetchDevices').mockResolvedValue()

    const wrapper = mountView()
    await flushPromises()

    const sheet = wrapper.findComponent({ name: 'ManageImageSheet' })
    expect(sheet.props('modelValue')).toBe(false)
    await wrapper.find('.library__manage').trigger('click')
    expect(sheet.props('modelValue')).toBe(true)
    expect(sheet.props('image')!.id).toBe(1)
  })

  it('forwards an approval event from the sheet to the store', async () => {
    const imagesStore = useImagesStore()
    const devicesStore = useDevicesStore()
    devicesStore.devices = [makeDevice({ id: 4, name: 'Hall' })]
    imagesStore.images = [makeImage({ id: 1, approvedDeviceIds: [] })]
    imagesStore.loading = false
    vi.spyOn(imagesStore, 'fetchImages').mockResolvedValue()
    vi.spyOn(imagesStore, 'fetchPendingCount').mockResolvedValue()
    const setApproval = vi.spyOn(imagesStore, 'setApproval').mockResolvedValue()
    vi.spyOn(devicesStore, 'fetchDevices').mockResolvedValue()

    const wrapper = mountView()
    await flushPromises()
    await wrapper.find('.library__manage').trigger('click')

    const sheet = wrapper.findComponent({ name: 'ManageImageSheet' })
    await sheet.vm.$emit('approval', { imageId: 1, deviceId: 4, approved: true })
    await flushPromises()
    expect(setApproval).toHaveBeenCalledWith(1, 4, true)
  })

  it('forwards a lock event from the sheet to devicesStore.lockImage', async () => {
    const imagesStore = useImagesStore()
    const devicesStore = useDevicesStore()
    devicesStore.devices = [makeDevice({ id: 4, name: 'Hall' })]
    imagesStore.images = [makeImage({ id: 1, approvedDeviceIds: [4] })]
    imagesStore.loading = false
    vi.spyOn(imagesStore, 'fetchImages').mockResolvedValue()
    vi.spyOn(imagesStore, 'fetchPendingCount').mockResolvedValue()
    const lockSpy = vi.spyOn(devicesStore, 'lockImage').mockResolvedValue(makeDevice({ id: 4, lockedImageId: 1 }))
    vi.spyOn(devicesStore, 'fetchDevices').mockResolvedValue()

    const wrapper = mountView()
    await flushPromises()
    await wrapper.find('.library__manage').trigger('click')
    const sheet = wrapper.findComponent({ name: 'ManageImageSheet' })
    await sheet.vm.$emit('lock', { imageId: 1, deviceId: 4, locked: true })
    await flushPromises()
    expect(lockSpy).toHaveBeenCalledWith(4, 1)
  })

  it('forwards an unlock event from the sheet to devicesStore.unlockImage', async () => {
    const imagesStore = useImagesStore()
    const devicesStore = useDevicesStore()
    devicesStore.devices = [makeDevice({ id: 4, lockedImageId: 1 })]
    imagesStore.images = [makeImage({ id: 1, approvedDeviceIds: [4] })]
    imagesStore.loading = false
    vi.spyOn(imagesStore, 'fetchImages').mockResolvedValue()
    vi.spyOn(imagesStore, 'fetchPendingCount').mockResolvedValue()
    const unlockSpy = vi.spyOn(devicesStore, 'unlockImage').mockResolvedValue(makeDevice({ id: 4, lockedImageId: null }))
    vi.spyOn(devicesStore, 'fetchDevices').mockResolvedValue()

    const wrapper = mountView()
    await flushPromises()
    await wrapper.find('.library__manage').trigger('click')
    const sheet = wrapper.findComponent({ name: 'ManageImageSheet' })
    await sheet.vm.$emit('lock', { imageId: 1, deviceId: 4, locked: false })
    await flushPromises()
    expect(unlockSpy).toHaveBeenCalledWith(4)
  })

  it('toasts when a lock-store call from the sheet fails', async () => {
    const imagesStore = useImagesStore()
    const devicesStore = useDevicesStore()
    devicesStore.devices = [makeDevice({ id: 4 })]
    imagesStore.images = [makeImage({ id: 1, approvedDeviceIds: [4] })]
    imagesStore.loading = false
    vi.spyOn(imagesStore, 'fetchImages').mockResolvedValue()
    vi.spyOn(imagesStore, 'fetchPendingCount').mockResolvedValue()
    vi.spyOn(devicesStore, 'lockImage').mockRejectedValue(new Error('nope'))
    vi.spyOn(devicesStore, 'fetchDevices').mockResolvedValue()

    const wrapper = mountView()
    await flushPromises()
    await wrapper.find('.library__manage').trigger('click')
    const sheet = wrapper.findComponent({ name: 'ManageImageSheet' })
    await sheet.vm.$emit('lock', { imageId: 1, deviceId: 4, locked: true })
    await flushPromises()
    expect(toastShow).toHaveBeenCalledWith('Failed to update lock', 'error')
  })

  // LV-15: Mismatch warning shown when photo orientation differs from device
  it('shows the mismatch warning when an approved device orientation differs', async () => {
    const imagesStore = useImagesStore()
    const devicesStore = useDevicesStore()
    devicesStore.devices = [makeDevice({ id: 4, orientation: 'portrait' })]
    imagesStore.images = [makeImage({
      id: 1,
      approvedDeviceIds: [4],
      cropOrientation: 'landscape',
    })]
    imagesStore.loading = false
    vi.spyOn(imagesStore, 'fetchImages').mockResolvedValue()
    vi.spyOn(imagesStore, 'fetchPendingCount').mockResolvedValue()
    vi.spyOn(devicesStore, 'fetchDevices').mockResolvedValue()

    const wrapper = mountView()
    await flushPromises()
    expect(wrapper.find('.library__action-btn--warn').exists()).toBe(true)
  })

  it('infers landscape from cropParams when natW >= natH', async () => {
    const imagesStore = useImagesStore()
    const devicesStore = useDevicesStore()
    devicesStore.devices = [makeDevice({ id: 4, orientation: 'portrait' })]
    imagesStore.images = [makeImage({
      id: 1,
      approvedDeviceIds: [4],
      cropOrientation: null,
      cropParams: { natX: 0, natY: 0, natW: 200, natH: 100 } as any,
    })]
    imagesStore.loading = false
    vi.spyOn(imagesStore, 'fetchImages').mockResolvedValue()
    vi.spyOn(imagesStore, 'fetchPendingCount').mockResolvedValue()
    vi.spyOn(devicesStore, 'fetchDevices').mockResolvedValue()

    const wrapper = mountView()
    await flushPromises()
    // photo inferred landscape, device portrait → mismatch warn
    expect(wrapper.find('.library__action-btn--warn').exists()).toBe(true)
  })

  it('infers crop orientation from cropParams when cropOrientation is missing', async () => {
    const imagesStore = useImagesStore()
    const devicesStore = useDevicesStore()
    devicesStore.devices = [makeDevice({ id: 4, orientation: 'landscape' })]
    imagesStore.images = [makeImage({
      id: 1,
      approvedDeviceIds: [4],
      cropOrientation: null,
      cropParams: { natX: 0, natY: 0, natW: 100, natH: 200 } as any,
    })]
    imagesStore.loading = false
    vi.spyOn(imagesStore, 'fetchImages').mockResolvedValue()
    vi.spyOn(imagesStore, 'fetchPendingCount').mockResolvedValue()
    vi.spyOn(devicesStore, 'fetchDevices').mockResolvedValue()

    const wrapper = mountView()
    await flushPromises()
    expect(wrapper.find('.library__action-btn--warn').exists()).toBe(true)
  })

  // LV-16: Pagination buttons in the shared tab
  it('shared pagination next button requests the following page', async () => {
    const page1: any = { items: [{ id: 1, sharedBy: 'a@b', sharedAt: '2026-01-01T00:00:00Z', status: 'pending', thumbnailUrl: '/t/1.jpg' }], total: 30, page: 1, limit: 1, totalPages: 30 }
    const imagesStore = useImagesStore()
    vi.spyOn(imagesStore, 'fetchImages').mockResolvedValue()
    vi.spyOn(imagesStore, 'fetchPendingCount').mockResolvedValue()
    const fetchSharedSpy = vi.spyOn(imagesStore, 'fetchSharedImages').mockResolvedValue(page1)
    vi.spyOn(useDevicesStore(), 'fetchDevices').mockResolvedValue()

    mockRoute.query = { tab: 'shared' }
    const wrapper = mountView()
    await flushPromises()

    const next = wrapper.findAll('.library__page-btn').find(b => b.text().includes('Next'))!
    await next.trigger('click')
    expect(fetchSharedSpy).toHaveBeenLastCalledWith('pending', 2)
  })

  it('shared pagination prev button requests the previous page when not on page 1', async () => {
    const page2: any = { items: [{ id: 1, sharedBy: 'a@b', sharedAt: '2026-01-01T00:00:00Z', status: 'pending', thumbnailUrl: '/t/1.jpg' }], total: 30, page: 2, limit: 1, totalPages: 30 }
    const imagesStore = useImagesStore()
    vi.spyOn(imagesStore, 'fetchImages').mockResolvedValue()
    vi.spyOn(imagesStore, 'fetchPendingCount').mockResolvedValue()
    const fetchSharedSpy = vi.spyOn(imagesStore, 'fetchSharedImages').mockResolvedValue(page2)
    vi.spyOn(useDevicesStore(), 'fetchDevices').mockResolvedValue()

    mockRoute.query = { tab: 'shared' }
    const wrapper = mountView()
    await flushPromises()

    const prev = wrapper.findAll('.library__page-btn').find(b => b.text().includes('Prev'))!
    expect(prev.attributes('disabled')).toBeUndefined()
    await prev.trigger('click')
    expect(fetchSharedSpy).toHaveBeenLastCalledWith('pending', 1)
  })

  // LV-17: Switching between Pending/Approved/Declined sub-tabs reloads with new status
  it('switching shared sub-tabs reloads with the new status', async () => {
    const empty = { items: [], total: 0, page: 1, limit: 20, totalPages: 1 }
    const imagesStore = useImagesStore()
    vi.spyOn(imagesStore, 'fetchImages').mockResolvedValue()
    vi.spyOn(imagesStore, 'fetchPendingCount').mockResolvedValue()
    const fetchSharedSpy = vi.spyOn(imagesStore, 'fetchSharedImages').mockResolvedValue(empty)
    vi.spyOn(useDevicesStore(), 'fetchDevices').mockResolvedValue()

    mockRoute.query = { tab: 'shared' }
    const wrapper = mountView()
    await flushPromises()

    const approved = wrapper.findAll('.library__subtab').find(b => b.text() === 'Approved')!
    await approved.trigger('click')
    expect(fetchSharedSpy).toHaveBeenLastCalledWith('approved', 1)

    const declined = wrapper.findAll('.library__subtab').find(b => b.text() === 'Declined')!
    await declined.trigger('click')
    expect(fetchSharedSpy).toHaveBeenLastCalledWith('declined', 1)
  })

  // LV-18: onSharedUpdated patches the matching item in the rendered list
  it('updated event from ApproveCard replaces the matching shared item', async () => {
    const original: any = { id: 5, sharedBy: 'a@b', sharedAt: '2026-01-01T00:00:00Z', status: 'pending', thumbnailUrl: '/t/5.jpg' }
    const updated: any  = { ...original, status: 'approved' }

    const imagesStore = useImagesStore()
    vi.spyOn(imagesStore, 'fetchImages').mockResolvedValue()
    vi.spyOn(imagesStore, 'fetchPendingCount').mockResolvedValue()
    vi.spyOn(imagesStore, 'fetchSharedImages').mockResolvedValue({ items: [original], total: 1, page: 1, limit: 20, totalPages: 1 })
    vi.spyOn(useDevicesStore(), 'fetchDevices').mockResolvedValue()

    mockRoute.query = { tab: 'shared' }
    const wrapper = mountView()
    await flushPromises()

    const approveCard = wrapper.findComponent({ name: 'ApproveCard' })
    expect(approveCard.props('item')).toEqual(original)
    await approveCard.vm.$emit('updated', updated)
    await flushPromises()
    expect(wrapper.findComponent({ name: 'ApproveCard' }).props('item')).toEqual(updated)
  })

  // LV-19: empty-state copy reflects the active sub-tab
  it('renders the approved empty-state copy when on the approved sub-tab', async () => {
    const empty = { items: [], total: 0, page: 1, limit: 20, totalPages: 1 }
    const imagesStore = useImagesStore()
    vi.spyOn(imagesStore, 'fetchImages').mockResolvedValue()
    vi.spyOn(imagesStore, 'fetchPendingCount').mockResolvedValue()
    vi.spyOn(imagesStore, 'fetchSharedImages').mockResolvedValue(empty)
    vi.spyOn(useDevicesStore(), 'fetchDevices').mockResolvedValue()

    mockRoute.query = { tab: 'shared' }
    const wrapper = mountView()
    await flushPromises()
    const approved = wrapper.findAll('.library__subtab').find(b => b.text() === 'Approved')!
    await approved.trigger('click')
    await flushPromises()

    expect(wrapper.find('.library__shared-empty').text()).toContain('No approved photos')
    expect(wrapper.find('.library__shared-empty').text()).toContain('added to a frame')
  })

  it('renders the declined empty-state copy when on the declined sub-tab', async () => {
    const empty = { items: [], total: 0, page: 1, limit: 20, totalPages: 1 }
    const imagesStore = useImagesStore()
    vi.spyOn(imagesStore, 'fetchImages').mockResolvedValue()
    vi.spyOn(imagesStore, 'fetchPendingCount').mockResolvedValue()
    vi.spyOn(imagesStore, 'fetchSharedImages').mockResolvedValue(empty)
    vi.spyOn(useDevicesStore(), 'fetchDevices').mockResolvedValue()

    mockRoute.query = { tab: 'shared' }
    const wrapper = mountView()
    await flushPromises()
    const declined = wrapper.findAll('.library__subtab').find(b => b.text() === 'Declined')!
    await declined.trigger('click')
    await flushPromises()

    expect(wrapper.find('.library__shared-empty').text()).toContain('No declined photos')
  })

  // LV-29: main loading state
  it('shows the main loading row when imagesStore.loading is true', async () => {
    const imagesStore = useImagesStore()
    imagesStore.loading = true
    vi.spyOn(imagesStore, 'fetchImages').mockReturnValue(new Promise(() => {}))
    vi.spyOn(imagesStore, 'fetchPendingCount').mockResolvedValue()
    vi.spyOn(useDevicesStore(), 'fetchDevices').mockResolvedValue()

    const wrapper = mountView()
    await flushPromises()
    expect(wrapper.find('.library__loading').exists()).toBe(true)
    expect(wrapper.text()).toContain('Loading')
  })

  // LV-30: route.query.tab transitions FROM shared back to all — watcher's tab !== 'shared' branch
  it('switching the route query off "shared" updates active tab without reloading shared', async () => {
    const empty = { items: [], total: 0, page: 1, limit: 20, totalPages: 1 }
    const imagesStore = useImagesStore()
    vi.spyOn(imagesStore, 'fetchImages').mockResolvedValue()
    vi.spyOn(imagesStore, 'fetchPendingCount').mockResolvedValue()
    const fetchSharedSpy = vi.spyOn(imagesStore, 'fetchSharedImages').mockResolvedValue(empty)
    vi.spyOn(useDevicesStore(), 'fetchDevices').mockResolvedValue()

    mockRoute.query = { tab: 'shared' }
    const wrapper = mountView()
    await flushPromises()
    expect(fetchSharedSpy).toHaveBeenCalledTimes(1)

    mockRoute.query = { tab: 'mine' }
    await flushPromises()
    // Query change: was 'shared', now 'mine' — watcher runs, activeTab is updated but no extra reload of shared
    expect(fetchSharedSpy).toHaveBeenCalledTimes(1)
    const mineTab = wrapper.findAll('[role="tab"]').find(t => t.text() === 'Mine')!
    expect(mineTab.attributes('aria-selected')).toBe('true')
  })

  // LV-25b: query.tab change that resolves to the same active tab is a no-op
  it('does not reload shared when route.query.tab changes but resolves to the same tab', async () => {
    const empty = { items: [], total: 0, page: 1, limit: 20, totalPages: 1 }
    const imagesStore = useImagesStore()
    vi.spyOn(imagesStore, 'fetchImages').mockResolvedValue()
    vi.spyOn(imagesStore, 'fetchPendingCount').mockResolvedValue()
    const fetchSharedSpy = vi.spyOn(imagesStore, 'fetchSharedImages').mockResolvedValue(empty)
    vi.spyOn(useDevicesStore(), 'fetchDevices').mockResolvedValue()

    const wrapper = mountView()
    await flushPromises()
    expect(fetchSharedSpy).not.toHaveBeenCalled() // active tab is 'all'
    // bogus and missing both resolve to 'all' — no reload, watcher returns early
    mockRoute.query = { tab: 'bogus' }
    await flushPromises()
    expect(fetchSharedSpy).not.toHaveBeenCalled()
    expect(wrapper.findAll('[role="tab"]').find(t => t.text() === 'All')!.attributes('aria-selected')).toBe('true')
  })

  // LV-26: ApproveCard updates with an unknown id are ignored
  it('updated event with an unknown id leaves the shared list untouched', async () => {
    const item: any = { id: 5, sharedBy: 'a@b', sharedAt: '2026-01-01T00:00:00Z', status: 'pending', thumbnailUrl: '/t/5.jpg' }
    const imagesStore = useImagesStore()
    vi.spyOn(imagesStore, 'fetchImages').mockResolvedValue()
    vi.spyOn(imagesStore, 'fetchPendingCount').mockResolvedValue()
    vi.spyOn(imagesStore, 'fetchSharedImages').mockResolvedValue({ items: [item], total: 1, page: 1, limit: 20, totalPages: 1 })
    vi.spyOn(useDevicesStore(), 'fetchDevices').mockResolvedValue()

    mockRoute.query = { tab: 'shared' }
    const wrapper = mountView()
    await flushPromises()

    const approveCard = wrapper.findComponent({ name: 'ApproveCard' })
    await approveCard.vm.$emit('updated', { ...item, id: 999, status: 'approved' })
    await flushPromises()
    expect(wrapper.findAllComponents({ name: 'ApproveCard' })).toHaveLength(1)
    expect(wrapper.findComponent({ name: 'ApproveCard' }).props('item').id).toBe(5)
  })

  // LV-27: photoCropOrientation falls through to null when cropParams has zero dims
  it('does not render warn when cropOrientation is null and cropParams has zero dims', async () => {
    const imagesStore = useImagesStore()
    const devicesStore = useDevicesStore()
    devicesStore.devices = [makeDevice({ id: 4, orientation: 'landscape' })]
    imagesStore.images = [makeImage({
      id: 1,
      approvedDeviceIds: [4],
      cropOrientation: null,
      cropParams: { natX: 0, natY: 0, natW: 0, natH: 0 } as any,
    })]
    imagesStore.loading = false
    vi.spyOn(imagesStore, 'fetchImages').mockResolvedValue()
    vi.spyOn(imagesStore, 'fetchPendingCount').mockResolvedValue()
    vi.spyOn(devicesStore, 'fetchDevices').mockResolvedValue()

    const wrapper = mountView()
    await flushPromises()
    expect(wrapper.find('.library__action-btn--warn').exists()).toBe(false)
  })

  // LV-25c: defensive guard — clicking the sheet Delete button before confirmDelete is a no-op
  it('clicking sheet Delete with no pending id is a no-op (defensive guard)', async () => {
    const imagesStore = useImagesStore()
    imagesStore.images = [makeImage({ id: 9 })]
    imagesStore.loading = false
    vi.spyOn(imagesStore, 'fetchImages').mockResolvedValue()
    vi.spyOn(imagesStore, 'fetchPendingCount').mockResolvedValue()
    const deleteSpy = vi.spyOn(imagesStore, 'deleteImage').mockResolvedValue()
    vi.spyOn(useDevicesStore(), 'fetchDevices').mockResolvedValue()

    const wrapper = mountView()
    await flushPromises()
    // The BaseBottomSheet stub always renders its slot, so the sheet Delete
    // button is in the DOM. Click it without first triggering confirmDelete
    // — exercises the !deletingId.value guard in doDelete.
    const sheetDelete = wrapper.findAll('button').find(b => b.text() === 'Delete')!
    await sheetDelete.trigger('click')
    await flushPromises()
    expect(deleteSpy).not.toHaveBeenCalled()
  })

  // LV-25: closing the delete sheet via the underlying bottom-sheet
  it('closes the delete sheet when the bottom-sheet emits update:modelValue=false', async () => {
    const imagesStore = useImagesStore()
    imagesStore.images = [makeImage({ id: 9 })]
    imagesStore.loading = false
    vi.spyOn(imagesStore, 'fetchImages').mockResolvedValue()
    vi.spyOn(imagesStore, 'fetchPendingCount').mockResolvedValue()
    vi.spyOn(useDevicesStore(), 'fetchDevices').mockResolvedValue()

    const wrapper = mountView()
    await flushPromises()
    await wrapper.findAll('.library__action-btn')
      .find(b => b.attributes('aria-label') === 'Delete photo')!.trigger('click')
    await flushPromises()
    // The view has multiple bottom sheets (manage, delete). Find the one
    // with the delete label so we don't accidentally toggle the manage sheet.
    const sheet = wrapper.findAllComponents({ name: 'BaseBottomSheet' })
      .find(c => c.props('label') === 'Delete photo')!
    expect(sheet.props('modelValue')).toBe(true)
    await sheet.vm.$emit('update:modelValue', false)
    await wrapper.vm.$nextTick()
    const after = wrapper.findAllComponents({ name: 'BaseBottomSheet' })
      .find(c => c.props('label') === 'Delete photo')!
    expect(after.props('modelValue')).toBe(false)
  })

  // LV-21: clicking the orientation-mismatch warning starts an edit for that device
  it('clicking the warn button calls initEdit with the mismatched device id', async () => {
    uploadInitEdit.mockResolvedValue(undefined)
    const imagesStore = useImagesStore()
    const devicesStore = useDevicesStore()
    devicesStore.devices = [makeDevice({ id: 4, name: 'Hall', orientation: 'portrait' })]
    imagesStore.images = [makeImage({ id: 1, approvedDeviceIds: [4], cropOrientation: 'landscape' })]
    imagesStore.loading = false
    vi.spyOn(imagesStore, 'fetchImages').mockResolvedValue()
    vi.spyOn(imagesStore, 'fetchPendingCount').mockResolvedValue()
    vi.spyOn(devicesStore, 'fetchDevices').mockResolvedValue()

    const wrapper = mountView()
    await flushPromises()
    const warn = wrapper.find('.library__action-btn--warn')
    await warn.trigger('click')
    await flushPromises()
    expect(uploadInitEdit).toHaveBeenCalledWith(expect.objectContaining({ id: 1 }), 4)
    expect(routerPush).toHaveBeenCalledWith('/upload')
  })

  // LV-22: ShareSheet emits close — v-model setter runs
  it('closes the share sheet when ShareSheet emits update:modelValue=false', async () => {
    const imagesStore = useImagesStore()
    imagesStore.images = [makeImage({ id: 5 })]
    imagesStore.loading = false
    vi.spyOn(imagesStore, 'fetchImages').mockResolvedValue()
    vi.spyOn(imagesStore, 'fetchPendingCount').mockResolvedValue()
    vi.spyOn(useDevicesStore(), 'fetchDevices').mockResolvedValue()

    const wrapper = mountView()
    await flushPromises()
    const shareBtn = wrapper.findAll('.library__action-btn').find(b => b.attributes('aria-label')?.includes('Share'))!
    await shareBtn.trigger('click')
    await flushPromises()
    const sheet = wrapper.findComponent({ name: 'ShareSheet' })
    expect(sheet.exists()).toBe(true)
    await sheet.vm.$emit('update:modelValue', false)
    await flushPromises()
    // shareSheetOpen flips back to false; image id remains, so the component is still mounted
    expect(wrapper.findComponent({ name: 'ShareSheet' }).props('modelValue')).toBe(false)
  })

  // LV-23: photo whose approved devices all have matching orientation — no warn
  it('does not render the warn button when all approved devices match the photo orientation', async () => {
    const imagesStore = useImagesStore()
    const devicesStore = useDevicesStore()
    devicesStore.devices = [
      makeDevice({ id: 4, orientation: 'landscape' }),
      makeDevice({ id: 5, orientation: 'landscape' }),
    ]
    imagesStore.images = [makeImage({
      id: 1,
      approvedDeviceIds: [4, 5],
      cropOrientation: 'landscape',
    })]
    imagesStore.loading = false
    vi.spyOn(imagesStore, 'fetchImages').mockResolvedValue()
    vi.spyOn(imagesStore, 'fetchPendingCount').mockResolvedValue()
    vi.spyOn(devicesStore, 'fetchDevices').mockResolvedValue()

    const wrapper = mountView()
    await flushPromises()
    expect(wrapper.find('.library__action-btn--warn').exists()).toBe(false)
  })

  // LV-24: image with no crop info → mismatchedDevice returns null up front
  it('does not render the warn button when the image has no crop info', async () => {
    const imagesStore = useImagesStore()
    const devicesStore = useDevicesStore()
    devicesStore.devices = [makeDevice({ id: 4 })]
    imagesStore.images = [makeImage({
      id: 1,
      approvedDeviceIds: [4],
      cropOrientation: null,
      cropParams: null,
    })]
    imagesStore.loading = false
    vi.spyOn(imagesStore, 'fetchImages').mockResolvedValue()
    vi.spyOn(imagesStore, 'fetchPendingCount').mockResolvedValue()
    vi.spyOn(devicesStore, 'fetchDevices').mockResolvedValue()

    const wrapper = mountView()
    await flushPromises()
    expect(wrapper.find('.library__action-btn--warn').exists()).toBe(false)
  })

  // LV-20: shared-loading state
  it('shows a loading row while shared items are being fetched', async () => {
    const imagesStore = useImagesStore()
    vi.spyOn(imagesStore, 'fetchImages').mockResolvedValue()
    vi.spyOn(imagesStore, 'fetchPendingCount').mockResolvedValue()
    vi.spyOn(imagesStore, 'fetchSharedImages').mockReturnValue(new Promise(() => {}))
    vi.spyOn(useDevicesStore(), 'fetchDevices').mockResolvedValue()

    mockRoute.query = { tab: 'shared' }
    const wrapper = mountView()
    await flushPromises()
    expect(wrapper.find('.library__loading').exists()).toBe(true)
  })
})
