import { describe, it, expect, vi, beforeEach } from 'vitest'
import { mount, flushPromises } from '@vue/test-utils'
import { setActivePinia, createPinia } from 'pinia'
import { useDevicesStore } from '@/stores/devices'
import { useUploadStore } from '@/stores/upload'
import HomeView from '@/views/HomeView.vue'
import type { Device } from '@/types'

const routerPush = vi.fn()

// Stub heavy child components so tests focus on HomeView logic
vi.mock('@/components/FrameCard.vue', () => ({
  default: {
    name: 'FrameCard',
    template: '<div class="frame-card-stub" :data-device-id="deviceId" :data-name="name" />',
    props: ['deviceId', 'name', 'size', 'status', 'orientation', 'thumbnailUrl', 'lastSync', 'nextSync'],
    emits: ['add-photo', 'edit'],
  },
}))
vi.mock('@/components/BaseBottomSheet.vue', () => ({
  default: {
    name: 'BaseBottomSheet',
    template: '<div><slot /></div>',
    props: ['modelValue', 'label'],
    emits: ['update:modelValue'],
  },
}))
vi.mock('@/components/BaseButton.vue', () => ({
  default: {
    name: 'BaseButton',
    template: '<button><slot /></button>',
    props: ['variant', 'disabled'],
  },
}))
vi.mock('@/components/BaseInput.vue', () => ({
  default: {
    name: 'BaseInput',
    template: '<input />',
    props: ['modelValue', 'label', 'maxlength'],
    emits: ['update:modelValue'],
  },
}))
vi.mock('@/components/OrientationPicker.vue', () => ({
  default: {
    name: 'OrientationPicker',
    template: '<div />',
    props: ['modelValue'],
    emits: ['update:modelValue'],
  },
}))

// Stub vue-router so HomeView can call useRouter() without a real router.
// `mockRoute` is mutated per-test where needed (e.g. to surface ?setup=<id>).
const mockRoute: { query: Record<string, string | undefined> } = { query: {} }
const routerReplace = vi.fn()
vi.mock('vue-router', () => ({
  useRouter: () => ({ push: routerPush, replace: routerReplace }),
  useRoute:  () => mockRoute,
}))

// Stub URL.createObjectURL used by upload store
vi.stubGlobal('URL', {
  createObjectURL: vi.fn(() => 'blob:mock-url'),
  revokeObjectURL: vi.fn(),
})

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('HomeView', () => {
  let pinia: ReturnType<typeof createPinia>

  beforeEach(() => {
    vi.unstubAllGlobals()
    vi.restoreAllMocks()
    pinia = createPinia()
    setActivePinia(pinia)

    // Re-stub URL after unstubAllGlobals
    vi.stubGlobal('URL', {
      createObjectURL: vi.fn(() => 'blob:mock-url'),
      revokeObjectURL: vi.fn(),
    })

    // Stub fetch so onMounted fetchDevices doesn't fail
    vi.stubGlobal('fetch', vi.fn().mockResolvedValue({
      ok: true,
      json: () => Promise.resolve([]),
    }))
  })

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

  // HV-01: N devices renders a vertical stack of N large FrameCard stubs
  it('renders one FrameCard per device in a vertical stack when multiple devices present', async () => {
    const devicesStore = useDevicesStore()
    devicesStore.devices = [
      makeDevice({ id: 1, name: 'Frame A' }),
      makeDevice({ id: 2, name: 'Frame B' }),
      makeDevice({ id: 3, name: 'Frame C' }),
    ]
    vi.spyOn(devicesStore, 'fetchDevices').mockResolvedValue()

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

    expect(wrapper.find('.home-view__stack').exists()).toBe(true)
    expect(wrapper.findAll('.home-view__slide')).toHaveLength(3)
    expect(wrapper.findAll('.frame-card-stub')).toHaveLength(3)
    // All cards should be the large variant (no compact / no carousel)
    const cards = wrapper.findAllComponents({ name: 'FrameCard' })
    for (const c of cards) expect(c.props('size')).toBe('large')
  })

  it('labels each slide with the device name for accessibility', async () => {
    const devicesStore = useDevicesStore()
    devicesStore.devices = [
      makeDevice({ id: 1, name: 'Living Room' }),
      makeDevice({ id: 2, name: 'Bedroom' }),
    ]
    vi.spyOn(devicesStore, 'fetchDevices').mockResolvedValue()

    const wrapper = mountView()
    await wrapper.vm.$nextTick()
    const slides = wrapper.findAll('.home-view__slide')
    expect(slides[0].attributes('aria-label')).toBe('Living Room')
    expect(slides[1].attributes('aria-label')).toBe('Bedroom')
  })

  // HV-01b: single device still renders one FrameCard (large variant branch)
  it('renders one FrameCard for a single device', async () => {
    const devicesStore = useDevicesStore()
    devicesStore.devices = [makeDevice({ id: 1, name: 'Solo Frame' })]
    vi.spyOn(devicesStore, 'fetchDevices').mockResolvedValue()

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

    const cards = wrapper.findAll('.frame-card-stub')
    expect(cards).toHaveLength(1)
  })

  // HV-02: empty state shown when no devices
  it('shows empty state when devices list is empty', async () => {
    const devicesStore = useDevicesStore()
    devicesStore.devices = []
    devicesStore.loading = false
    vi.spyOn(devicesStore, 'fetchDevices').mockResolvedValue()

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

    expect(wrapper.find('.home-view__empty').exists()).toBe(true)
    expect(wrapper.text()).toContain('Set up your first frame')
  })

  // HV-03: loading state shown while fetching
  it('shows loading indicator when store is loading', async () => {
    const devicesStore = useDevicesStore()
    devicesStore.loading = true
    // Keep fetchDevices pending so loading stays true
    vi.spyOn(devicesStore, 'fetchDevices').mockReturnValue(new Promise(() => {}))

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

    expect(wrapper.find('.home-view__loading').exists()).toBe(true)
    expect(wrapper.text()).toContain('Loading')
  })

  // HV-04: add-photo opens a file picker, primes the upload store, and navigates.
  // The file input lives in the template (persistent across renders) so iOS
  // PWA cold launches don't drop the first change event on a detached node.
  it('add-photo from a FrameCard primes upload state and routes to /upload', async () => {
    const devicesStore = useDevicesStore()
    devicesStore.devices = [makeDevice({ id: 7, name: 'Hall' })]
    vi.spyOn(devicesStore, 'fetchDevices').mockResolvedValue()
    routerPush.mockClear()

    const wrapper = mountView()
    await flushPromises()

    const fileInput = wrapper.find('input[type="file"]')
    expect(fileInput.exists()).toBe(true)
    // Stub click so jsdom doesn't try to open a real picker.
    const clickSpy = vi.fn()
    ;(fileInput.element as HTMLInputElement).click = clickSpy

    const card = wrapper.findComponent({ name: 'FrameCard' })
    await card.vm.$emit('add-photo', 7)
    expect(clickSpy).toHaveBeenCalled()

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

    const upload = useUploadStore()
    expect(upload.originalFile).toStrictEqual(file)
    expect(upload.contextDeviceId).toBe(7)
    expect(routerPush).toHaveBeenCalledWith('/upload')
  })

  it('add-photo without a chosen file does not navigate', async () => {
    const devicesStore = useDevicesStore()
    devicesStore.devices = [makeDevice({ id: 7 })]
    vi.spyOn(devicesStore, 'fetchDevices').mockResolvedValue()
    routerPush.mockClear()

    const realCreate = document.createElement.bind(document)
    let capturedInput: HTMLInputElement | null = null
    vi.spyOn(document, 'createElement').mockImplementation((tag: string) => {
      const el = realCreate(tag)
      if (tag === 'input') {
        capturedInput = el as HTMLInputElement
        ;(el as HTMLInputElement).click = vi.fn()
      }
      return el
    })

    const wrapper = mountView()
    await flushPromises()
    const card = wrapper.findComponent({ name: 'FrameCard' })
    await card.vm.$emit('add-photo', 7)

    Object.defineProperty(capturedInput, 'files', { value: [], configurable: true })
    capturedInput!.onchange?.(new Event('change'))

    expect(routerPush).not.toHaveBeenCalled()
  })

  // HV-05: edit opens the settings sheet pre-filled from the device record
  it('edit emits open the settings sheet pre-populated from the device', async () => {
    const devicesStore = useDevicesStore()
    devicesStore.devices = [makeDevice({ id: 9, name: 'Den', wakeTimes: [22 * 60], timezone: 'America/Chicago' })]
    vi.spyOn(devicesStore, 'fetchDevices').mockResolvedValue()

    const wrapper = mountView()
    await flushPromises()

    const card = wrapper.findComponent({ name: 'FrameCard' })
    await card.vm.$emit('edit', 9)
    await flushPromises()

    const sheet = wrapper.findComponent({ name: 'BaseBottomSheet' })
    expect(sheet.props('modelValue')).toBe(true)
    // The name input is the first BaseInput stub
    const nameInput = wrapper.findComponent({ name: 'BaseInput' })
    expect(nameInput.props('modelValue')).toBe('Den')
  })

  it('edit for an unknown device id is a no-op', async () => {
    const devicesStore = useDevicesStore()
    devicesStore.devices = [makeDevice({ id: 1 })]
    vi.spyOn(devicesStore, 'fetchDevices').mockResolvedValue()

    const wrapper = mountView()
    await flushPromises()
    const card = wrapper.findComponent({ name: 'FrameCard' })
    await card.vm.$emit('edit', 999)
    await flushPromises()
    const sheet = wrapper.findComponent({ name: 'BaseBottomSheet' })
    expect(sheet.props('modelValue')).toBe(false)
  })

  // HV-06: saving the sheet calls updateDevice and closes it
  it('saving the settings sheet PATCHes via the store and closes', async () => {
    const devicesStore = useDevicesStore()
    devicesStore.devices = [makeDevice({ id: 5, name: 'Old', wakeTimes: [4 * 60], timezone: 'UTC' })]
    vi.spyOn(devicesStore, 'fetchDevices').mockResolvedValue()
    const updateSpy = vi.spyOn(devicesStore, 'updateDevice').mockResolvedValue(makeDevice({ id: 5 }))

    const wrapper = mountView()
    await flushPromises()
    await wrapper.findComponent({ name: 'FrameCard' }).vm.$emit('edit', 5)
    await flushPromises()

    // Click save (the only button in the sheet stub for now)
    const saveBtn = wrapper.findAllComponents({ name: 'BaseButton' })
      .find(b => b.text().toLowerCase().includes('sav'))!
    await saveBtn.trigger('click')
    await flushPromises()

    expect(updateSpy).toHaveBeenCalledWith(5, expect.objectContaining({
      orientation: 'landscape',
      wakeTimes: [4 * 60],
      timezone: 'UTC',
    }))
    const sheet = wrapper.findComponent({ name: 'BaseBottomSheet' })
    expect(sheet.props('modelValue')).toBe(false)
  })

  it('passes status="ok" to the FrameCard when lastSeenAt is recent', async () => {
    const devicesStore = useDevicesStore()
    devicesStore.devices = [makeDevice({ id: 1, lastSeenAt: new Date().toISOString() })]
    vi.spyOn(devicesStore, 'fetchDevices').mockResolvedValue()
    const wrapper = mountView()
    await flushPromises()
    expect(wrapper.findComponent({ name: 'FrameCard' }).props('status')).toBe('ok')
  })

  it('passes status="offline" when lastSeenAt is older than the rotation window', async () => {
    const devicesStore = useDevicesStore()
    devicesStore.devices = [makeDevice({
      id: 1,
      rotationIntervalMinutes: 60,
      lastSeenAt: new Date(Date.now() - 1000 * 60 * 60 * 24).toISOString(),
    })]
    vi.spyOn(devicesStore, 'fetchDevices').mockResolvedValue()
    const wrapper = mountView()
    await flushPromises()
    expect(wrapper.findComponent({ name: 'FrameCard' }).props('status')).toBe('offline')
  })

  it('passes status="sync-fail" when one sync window has been missed but not two', async () => {
    const devicesStore = useDevicesStore()
    // 90 minutes since last seen, interval = 60 — between 1× and 2× → sync-fail
    devicesStore.devices = [makeDevice({
      id: 1,
      rotationIntervalMinutes: 60,
      lastSeenAt: new Date(Date.now() - 1000 * 60 * 90).toISOString(),
    })]
    vi.spyOn(devicesStore, 'fetchDevices').mockResolvedValue()
    const wrapper = mountView()
    await flushPromises()
    expect(wrapper.findComponent({ name: 'FrameCard' }).props('status')).toBe('sync-fail')
  })

  it('uses a 24h window for devices configured with explicit wake times', async () => {
    const devicesStore = useDevicesStore()
    // wakeTimes set, last seen 30h ago — between 1×24h and 2×24h → sync-fail
    devicesStore.devices = [makeDevice({
      id: 1,
      wakeTimes: [4 * 60],
      rotationIntervalMinutes: 5, // ignored when wakeTimes is non-empty
      lastSeenAt: new Date(Date.now() - 1000 * 60 * 60 * 30).toISOString(),
    })]
    vi.spyOn(devicesStore, 'fetchDevices').mockResolvedValue()
    const wrapper = mountView()
    await flushPromises()
    expect(wrapper.findComponent({ name: 'FrameCard' }).props('status')).toBe('sync-fail')
  })

  it('passes a relative lastSync label and a nextSync label to the FrameCard', async () => {
    const devicesStore = useDevicesStore()
    devicesStore.devices = [makeDevice({
      id: 1,
      rotationIntervalMinutes: 60,
      lastSeenAt: new Date(Date.now() - 1000 * 60 * 30).toISOString(), // 30m ago
    })]
    vi.spyOn(devicesStore, 'fetchDevices').mockResolvedValue()
    const wrapper = mountView()
    await flushPromises()
    const props = wrapper.findComponent({ name: 'FrameCard' }).props()
    expect(props.lastSync).toMatch(/m ago/)
    expect(props.nextSync).toMatch(/next sync in/)
  })

  it('passes a wakeTimes-based nextSync label when the device has explicit wake times', async () => {
    const devicesStore = useDevicesStore()
    devicesStore.devices = [makeDevice({
      id: 1,
      wakeTimes: [4 * 60],
      timezone: 'UTC',
      lastSeenAt: new Date(Date.now() - 1000 * 60 * 60).toISOString(),
    })]
    vi.spyOn(devicesStore, 'fetchDevices').mockResolvedValue()
    const wrapper = mountView()
    await flushPromises()
    expect(wrapper.findComponent({ name: 'FrameCard' }).props('nextSync')).toMatch(/4 AM/)
  })

  it('formats lastSync as "yesterday" / "N days ago" / "just now"', async () => {
    const devicesStore = useDevicesStore()
    devicesStore.devices = [makeDevice({
      id: 1,
      lastSeenAt: new Date(Date.now() - 1000 * 60 * 60 * 26).toISOString(), // ~26h
    })]
    vi.spyOn(devicesStore, 'fetchDevices').mockResolvedValue()
    let wrapper = mountView()
    await flushPromises()
    expect(wrapper.findComponent({ name: 'FrameCard' }).props('lastSync')).toBe('yesterday')

    devicesStore.devices = [makeDevice({
      id: 1,
      lastSeenAt: new Date(Date.now() - 1000 * 60 * 60 * 24 * 4).toISOString(), // ~4 days
    })]
    wrapper = mountView()
    await flushPromises()
    expect(wrapper.findComponent({ name: 'FrameCard' }).props('lastSync')).toMatch(/4 days ago/)

    devicesStore.devices = [makeDevice({
      id: 1,
      lastSeenAt: new Date(Date.now() - 5_000).toISOString(), // 5 seconds
    })]
    wrapper = mountView()
    await flushPromises()
    expect(wrapper.findComponent({ name: 'FrameCard' }).props('lastSync')).toBe('just now')
  })

  it('omits nextSync when an interval-based device is already past its next expected sync', async () => {
    const devicesStore = useDevicesStore()
    devicesStore.devices = [makeDevice({
      id: 1,
      rotationIntervalMinutes: 60,
      lastSeenAt: new Date(Date.now() - 1000 * 60 * 90).toISOString(), // 90m ago, already late
    })]
    vi.spyOn(devicesStore, 'fetchDevices').mockResolvedValue()
    const wrapper = mountView()
    await flushPromises()
    expect(wrapper.findComponent({ name: 'FrameCard' }).props('nextSync')).toBeNull()
  })

  it('formats wake times 12 PM, 12 AM, 8 PM, and 6:30 AM correctly', async () => {
    const devicesStore = useDevicesStore()
    vi.spyOn(devicesStore, 'fetchDevices').mockResolvedValue()

    devicesStore.devices = [makeDevice({ id: 1, wakeTimes: [12 * 60], timezone: 'UTC' })]
    let wrapper = mountView()
    await flushPromises()
    expect(wrapper.findComponent({ name: 'FrameCard' }).props('nextSync')).toMatch(/12 PM/)

    devicesStore.devices = [makeDevice({ id: 1, wakeTimes: [0], timezone: 'UTC' })]
    wrapper = mountView()
    await flushPromises()
    expect(wrapper.findComponent({ name: 'FrameCard' }).props('nextSync')).toMatch(/12 AM/)

    devicesStore.devices = [makeDevice({ id: 1, wakeTimes: [20 * 60], timezone: 'UTC' })]
    wrapper = mountView()
    await flushPromises()
    expect(wrapper.findComponent({ name: 'FrameCard' }).props('nextSync')).toMatch(/8 PM/)

    devicesStore.devices = [makeDevice({ id: 1, wakeTimes: [6 * 60 + 30], timezone: 'UTC' })]
    wrapper = mountView()
    await flushPromises()
    expect(wrapper.findComponent({ name: 'FrameCard' }).props('nextSync')).toMatch(/6:30 AM/)
  })

  it('returns null lastSync when the device has no recorded last-seen time', async () => {
    const devicesStore = useDevicesStore()
    devicesStore.devices = [makeDevice({ id: 1, lastSeenAt: null })]
    vi.spyOn(devicesStore, 'fetchDevices').mockResolvedValue()
    const wrapper = mountView()
    await flushPromises()
    const props = wrapper.findComponent({ name: 'FrameCard' }).props()
    expect(props.lastSync).toBeNull()
    expect(props.nextSync).toBeNull()
  })

  it('builds a thumbnail URL when the device has a current image', async () => {
    const devicesStore = useDevicesStore()
    devicesStore.devices = [makeDevice({ id: 1, currentImageId: 42 })]
    vi.spyOn(devicesStore, 'fetchDevices').mockResolvedValue()
    const wrapper = mountView()
    await flushPromises()
    expect(wrapper.findComponent({ name: 'FrameCard' }).props('thumbnailUrl')).toBe('/api/devices/1/preview?v=42')
  })

  it('always uses currentImageId for the thumbnail — lockedImageId is ignored', async () => {
    // Locked-but-not-yet-pulled is the bug we explicitly fixed: the home
    // preview must reflect what the frame is actually showing, not what's
    // queued for the next poll.
    const devicesStore = useDevicesStore()
    devicesStore.devices = [makeDevice({ id: 1, lockedImageId: 99, currentImageId: 42 })]
    vi.spyOn(devicesStore, 'fetchDevices').mockResolvedValue()
    const wrapper = mountView()
    await flushPromises()
    expect(wrapper.findComponent({ name: 'FrameCard' }).props('thumbnailUrl')).toBe('/api/devices/1/preview?v=42')
  })

  it('omits the thumbnail when the device has no currentImageId, even if a lock is queued', async () => {
    const devicesStore = useDevicesStore()
    devicesStore.devices = [makeDevice({ id: 1, lockedImageId: 99, currentImageId: null })]
    vi.spyOn(devicesStore, 'fetchDevices').mockResolvedValue()
    const wrapper = mountView()
    await flushPromises()
    expect(wrapper.findComponent({ name: 'FrameCard' }).props('thumbnailUrl')).toBeUndefined()
  })

  it('+ Add time appends a new wake time and saves it', async () => {
    const devicesStore = useDevicesStore()
    devicesStore.devices = [makeDevice({ id: 5, wakeTimes: [4 * 60] })]
    vi.spyOn(devicesStore, 'fetchDevices').mockResolvedValue()
    vi.spyOn(devicesStore, 'updateDevice').mockResolvedValue(makeDevice({ id: 5 }))

    const wrapper = mountView()
    await flushPromises()
    await wrapper.findComponent({ name: 'FrameCard' }).vm.$emit('edit', 5)
    await flushPromises()

    // Sheet opens in 'times' mode (because device.wakeTimes is non-empty).
    // Click the + Add time button — it should add 9:00 AM (first default
    // candidate not already in the list).
    const addBtn = wrapper.find('.home-view__time-add')
    await addBtn.trigger('click')
    await flushPromises()
    expect(wrapper.findAll('.home-view__time-row')).toHaveLength(2)

    const saveBtn = wrapper.findAllComponents({ name: 'BaseButton' })
      .find(b => b.text().toLowerCase().includes('sav'))!
    await saveBtn.trigger('click')
    await flushPromises()
    expect(devicesStore.updateDevice).toHaveBeenCalledWith(5, expect.objectContaining({
      wakeTimes: [4 * 60, 9 * 60],
    }))
  })

  // Locks the UX choice that newly-added entries land at the END of the
  // list and stay there during editing. Sorting only happens at save time
  // (server-side via setWakeTimes), so the user can always see "the one I
  // just added" without it jumping around.
  // When all 9 DEFAULT_TIME_CANDIDATES are already in the list, addTime falls
  // through to the 5-minute-step fallback loop. Pre-fill with all 9 defaults
  // so the next +Add hits that branch.
  it('+ Add time falls back to the next free 5-minute slot when defaults are exhausted', async () => {
    const devicesStore = useDevicesStore()
    const allDefaults = [
      9 * 60, 18 * 60, 12 * 60, 21 * 60, 6 * 60,
      15 * 60, 7 * 60 + 30, 19 * 60 + 30, 0,
    ]
    devicesStore.devices = [makeDevice({ id: 5, wakeTimes: allDefaults })]
    vi.spyOn(devicesStore, 'fetchDevices').mockResolvedValue()
    vi.spyOn(devicesStore, 'updateDevice').mockResolvedValue(makeDevice({ id: 5 }))

    const wrapper = mountView()
    await flushPromises()
    await wrapper.findComponent({ name: 'FrameCard' }).vm.$emit('edit', 5)
    await flushPromises()

    const before = wrapper.findAll('.home-view__time-row').length
    await wrapper.find('.home-view__time-add').trigger('click')
    await flushPromises()

    // Fallback added one more (the next free 5-min slot — first one not in
    // the defaults set, which is `5` minutes since 0 is already there).
    expect(wrapper.findAll('.home-view__time-row')).toHaveLength(before + 1)

    const saveBtn = wrapper.findAllComponents({ name: 'BaseButton' })
      .find(b => b.text().toLowerCase().includes('sav'))!
    await saveBtn.trigger('click')
    await flushPromises()
    expect(devicesStore.updateDevice).toHaveBeenCalledWith(5, expect.objectContaining({
      wakeTimes: [...allDefaults, 5],
    }))
  })

  it('+ Add time appends to the end and does NOT reorder existing entries', async () => {
    const devicesStore = useDevicesStore()
    // 8 PM is later than the first default candidate (9 AM). If the list
    // sorted on add, 9 AM would land first; we want to verify it doesn't.
    devicesStore.devices = [makeDevice({ id: 5, wakeTimes: [20 * 60] })]
    vi.spyOn(devicesStore, 'fetchDevices').mockResolvedValue()
    vi.spyOn(devicesStore, 'updateDevice').mockResolvedValue(makeDevice({ id: 5 }))

    const wrapper = mountView()
    await flushPromises()
    await wrapper.findComponent({ name: 'FrameCard' }).vm.$emit('edit', 5)
    await flushPromises()

    const addBtn = wrapper.find('.home-view__time-add')
    await addBtn.trigger('click')
    await flushPromises()

    // Row order in the DOM: the existing 8 PM stays first, the new 9 AM
    // appears second (at the end), even though 9 AM < 8 PM.
    const rows = wrapper.findAll('.home-view__time-row')
    expect(rows).toHaveLength(2)
    const firstHour = (rows[0].find('select[aria-label="Hour"]').element as HTMLSelectElement).value
    const firstAmpm = (rows[0].find('select[aria-label="AM or PM"]').element as HTMLSelectElement).value
    const secondHour = (rows[1].find('select[aria-label="Hour"]').element as HTMLSelectElement).value
    const secondAmpm = (rows[1].find('select[aria-label="AM or PM"]').element as HTMLSelectElement).value
    expect({ h: firstHour, p: firstAmpm }).toEqual({ h: '8', p: 'PM' })
    expect({ h: secondHour, p: secondAmpm }).toEqual({ h: '9', p: 'AM' })

    // Save sends the order as-edited; backend's setWakeTimes() sorts on
    // persist. The PWA payload itself isn't pre-sorted.
    const saveBtn = wrapper.findAllComponents({ name: 'BaseButton' })
      .find(b => b.text().toLowerCase().includes('sav'))!
    await saveBtn.trigger('click')
    await flushPromises()
    expect(devicesStore.updateDevice).toHaveBeenCalledWith(5, expect.objectContaining({
      wakeTimes: [20 * 60, 9 * 60],
    }))
  })

  // Editing a row's hour/minute/AM-PM dropdown must not move the row.
  // Reordering on every keystroke would yank the focus and confuse the user
  // about which row they're editing.
  it('editing a wake time does NOT reorder the row mid-edit', async () => {
    const devicesStore = useDevicesStore()
    // Two rows: 6 AM, 9 AM. Editing the second to "1 AM" would normally
    // sort it to position 0; we want it to stay at position 1.
    devicesStore.devices = [makeDevice({ id: 5, wakeTimes: [6 * 60, 9 * 60] })]
    vi.spyOn(devicesStore, 'fetchDevices').mockResolvedValue()
    vi.spyOn(devicesStore, 'updateDevice').mockResolvedValue(makeDevice({ id: 5 }))

    const wrapper = mountView()
    await flushPromises()
    await wrapper.findComponent({ name: 'FrameCard' }).vm.$emit('edit', 5)
    await flushPromises()

    // Change the second row's hour from 9 to 1.
    const rows = wrapper.findAll('.home-view__time-row')
    const secondHourSelect = rows[1].find('select[aria-label="Hour"]')
    ;(secondHourSelect.element as HTMLSelectElement).value = '1'
    await secondHourSelect.trigger('change')
    await flushPromises()

    const rowsAfter = wrapper.findAll('.home-view__time-row')
    const firstHour  = (rowsAfter[0].find('select[aria-label="Hour"]').element as HTMLSelectElement).value
    const secondHour = (rowsAfter[1].find('select[aria-label="Hour"]').element as HTMLSelectElement).value
    expect(firstHour).toBe('6')   // still the 6 AM row, unchanged
    expect(secondHour).toBe('1')  // edited row stays at position 1, NOT moved to 0
  })

  it('trash button removes a wake time from the list', async () => {
    const devicesStore = useDevicesStore()
    devicesStore.devices = [makeDevice({ id: 5, wakeTimes: [6 * 60, 18 * 60] })]
    vi.spyOn(devicesStore, 'fetchDevices').mockResolvedValue()
    vi.spyOn(devicesStore, 'updateDevice').mockResolvedValue(makeDevice({ id: 5 }))

    const wrapper = mountView()
    await flushPromises()
    await wrapper.findComponent({ name: 'FrameCard' }).vm.$emit('edit', 5)
    await flushPromises()

    const removeButtons = wrapper.findAll('.home-view__time-remove')
    expect(removeButtons).toHaveLength(2)
    // Remove the first row (6 AM)
    await removeButtons[0].trigger('click')
    await flushPromises()
    expect(wrapper.findAll('.home-view__time-row')).toHaveLength(1)

    const saveBtn = wrapper.findAllComponents({ name: 'BaseButton' })
      .find(b => b.text().toLowerCase().includes('sav'))!
    await saveBtn.trigger('click')
    await flushPromises()
    expect(devicesStore.updateDevice).toHaveBeenCalledWith(5, expect.objectContaining({
      wakeTimes: [18 * 60],
    }))
  })

  // Editing the AM/PM dropdown for a wake time exercises onTimePart's
  // 'p' branch — flips the stored minutes-since-midnight by 12 hours.
  it('switching AM to PM on a wake time updates the stored time by +12h', async () => {
    const devicesStore = useDevicesStore()
    // Start with 6 AM (= 360 min). Flipping AM→PM should make it 6 PM (= 1080).
    devicesStore.devices = [makeDevice({ id: 5, wakeTimes: [6 * 60] })]
    vi.spyOn(devicesStore, 'fetchDevices').mockResolvedValue()
    vi.spyOn(devicesStore, 'updateDevice').mockResolvedValue(makeDevice({ id: 5 }))

    const wrapper = mountView()
    await flushPromises()
    await wrapper.findComponent({ name: 'FrameCard' }).vm.$emit('edit', 5)
    await flushPromises()

    const ampmSelect = wrapper.find('select[aria-label="AM or PM"]')
    ;(ampmSelect.element as HTMLSelectElement).value = 'PM'
    await ampmSelect.trigger('change')

    const saveBtn = wrapper.findAllComponents({ name: 'BaseButton' })
      .find(b => b.text().toLowerCase().includes('sav'))!
    await saveBtn.trigger('click')
    await flushPromises()
    expect(devicesStore.updateDevice).toHaveBeenCalledWith(5, expect.objectContaining({
      wakeTimes: [18 * 60],
    }))
  })

  // Editing the minute dropdown for a wake time exercises onTimePart's
  // 'mm' branch — preserves the hour and AM/PM, only minutes change.
  it('changing the minutes dropdown updates the stored time without changing the hour', async () => {
    const devicesStore = useDevicesStore()
    // Start at 6:00 AM. Set minute to 30 → 6:30 AM = 390.
    devicesStore.devices = [makeDevice({ id: 5, wakeTimes: [6 * 60] })]
    vi.spyOn(devicesStore, 'fetchDevices').mockResolvedValue()
    vi.spyOn(devicesStore, 'updateDevice').mockResolvedValue(makeDevice({ id: 5 }))

    const wrapper = mountView()
    await flushPromises()
    await wrapper.findComponent({ name: 'FrameCard' }).vm.$emit('edit', 5)
    await flushPromises()

    const minuteSelect = wrapper.find('select[aria-label="Minutes"]')
    ;(minuteSelect.element as HTMLSelectElement).value = '30'
    await minuteSelect.trigger('change')

    const saveBtn = wrapper.findAllComponents({ name: 'BaseButton' })
      .find(b => b.text().toLowerCase().includes('sav'))!
    await saveBtn.trigger('click')
    await flushPromises()
    expect(devicesStore.updateDevice).toHaveBeenCalledWith(5, expect.objectContaining({
      wakeTimes: [6 * 60 + 30],
    }))
  })

  // The "next update" preview's legacy fallback path: device has lastSeenAt
  // but no nextPollExpectedAt yet (e.g. polled once before that column was
  // added). Should still produce a sensible label using lastSeenAt + interval.
  // Multi-day nextSync (> 24h) — covers the "in Nd" return at the bottom
  // of nextSyncLabel. Realistically a device that's been offline / never
  // configured a wake time and has a huge interval set.
  it('nextSync formats multi-day delays as "in Nd"', async () => {
    const devicesStore = useDevicesStore()
    const threeDaysOut = new Date(Date.now() + 3 * 24 * 60 * 60_000).toISOString()
    devicesStore.devices = [makeDevice({
      id: 1,
      wakeTimes: [],
      rotationIntervalMinutes: 3 * 24 * 60,
      lastSeenAt: new Date().toISOString(),
      nextPollExpectedAt: threeDaysOut,
    })]
    vi.spyOn(devicesStore, 'fetchDevices').mockResolvedValue()

    const wrapper = mountView()
    await flushPromises()
    expect(wrapper.findComponent({ name: 'FrameCard' }).props('nextSync')).toMatch(/in 3d/)
  })

  // Long-horizon nextSync: 5 hours away — covers the 1h-to-24h branch in
  // nextSyncLabel that picks clock-time formatting over "in 5h".
  it('nextSync formats medium-horizon delays with a clock time + day label', async () => {
    const devicesStore = useDevicesStore()
    const fiveHoursOut = new Date(Date.now() + 5 * 60 * 60_000).toISOString()
    devicesStore.devices = [makeDevice({
      id: 1,
      wakeTimes: [],
      rotationIntervalMinutes: 5 * 60,
      lastSeenAt: new Date().toISOString(),
      nextPollExpectedAt: fiveHoursOut,
      timezone: 'UTC',
    })]
    vi.spyOn(devicesStore, 'fetchDevices').mockResolvedValue()

    const wrapper = mountView()
    await flushPromises()

    const next = wrapper.findComponent({ name: 'FrameCard' }).props('nextSync') as string
    // Should NOT be the short-horizon "in Xm" form — we want clock-time.
    expect(next).toMatch(/^next sync ~\d/)
    // Day label is either today or tomorrow depending on wall-clock at run time.
    expect(next).toMatch(/(today|tomorrow)/)
  })

  // Visibility-change handler triggers a silent fetchDevices when the PWA
  // returns to the foreground.
  it('foreground visibility-change re-fetches devices silently', async () => {
    const devicesStore = useDevicesStore()
    devicesStore.devices = [makeDevice({ id: 1 })]
    const fetchSpy = vi.spyOn(devicesStore, 'fetchDevices').mockResolvedValue()

    mountView()
    await flushPromises()
    fetchSpy.mockClear()  // ignore the onMounted call

    Object.defineProperty(document, 'visibilityState', { value: 'visible', configurable: true })
    document.dispatchEvent(new Event('visibilitychange'))
    await flushPromises()

    expect(fetchSpy).toHaveBeenCalledWith({ silent: true })
  })

  // Add-photo handler clicks the template's persistent hidden file input
  // and (on file pick) navigates to /upload with the staged file.
  it('add-photo opens a file picker and navigates after a file is chosen', async () => {
    const devicesStore = useDevicesStore()
    devicesStore.devices = [makeDevice({ id: 7 })]
    vi.spyOn(devicesStore, 'fetchDevices').mockResolvedValue()

    const wrapper = mountView()
    await flushPromises()

    const fileInput = wrapper.find('input[type="file"]')
    expect(fileInput.exists()).toBe(true)
    ;(fileInput.element as HTMLInputElement).click = vi.fn()

    await wrapper.findComponent({ name: 'FrameCard' }).vm.$emit('add-photo', 7)

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

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

  it('next-update preview falls back to lastSeenAt+interval when nextPollExpectedAt is null', async () => {
    const devicesStore = useDevicesStore()
    devicesStore.devices = [makeDevice({
      id: 5,
      wakeTimes: [],
      rotationIntervalMinutes: 5,
      // 1 minute ago — fallback path computes lastSeen+5min, so ~4 min away.
      lastSeenAt: new Date(Date.now() - 60_000).toISOString(),
      nextPollExpectedAt: null,
    })]
    vi.spyOn(devicesStore, 'fetchDevices').mockResolvedValue()

    const wrapper = mountView()
    await flushPromises()
    await wrapper.findComponent({ name: 'FrameCard' }).vm.$emit('edit', 5)
    await flushPromises()

    const preview = wrapper.find('.home-view__next-update').text()
    expect(preview).toMatch(/in ~?[34] min/)
  })

  // Same fallback for wakeTimes-mode devices on the legacy path.
  it('next-update preview falls back to wakeTimes-after-lastSeen when nextPollExpectedAt is null', async () => {
    const devicesStore = useDevicesStore()
    devicesStore.devices = [makeDevice({
      id: 5,
      wakeTimes: [4 * 60],
      timezone: 'UTC',
      // 1h ago means "today's 4 AM has passed" (most days), so next is tomorrow's 4 AM.
      lastSeenAt: new Date(Date.now() - 60 * 60_000).toISOString(),
      nextPollExpectedAt: null,
    })]
    vi.spyOn(devicesStore, 'fetchDevices').mockResolvedValue()

    const wrapper = mountView()
    await flushPromises()
    await wrapper.findComponent({ name: 'FrameCard' }).vm.$emit('edit', 5)
    await flushPromises()

    const preview = wrapper.find('.home-view__next-update').text()
    expect(preview).toMatch(/4 AM/)
  })

  it('switching the mode dropdown to interval saves rotationIntervalMinutes and clears wakeTimes', async () => {
    const devicesStore = useDevicesStore()
    devicesStore.devices = [makeDevice({
      id: 5,
      wakeTimes: [4 * 60],
      rotationIntervalMinutes: 60,
    })]
    vi.spyOn(devicesStore, 'fetchDevices').mockResolvedValue()
    vi.spyOn(devicesStore, 'updateDevice').mockResolvedValue(makeDevice({ id: 5 }))

    const wrapper = mountView()
    await flushPromises()
    await wrapper.findComponent({ name: 'FrameCard' }).vm.$emit('edit', 5)
    await flushPromises()

    const modeSelect = wrapper.find('.home-view__mode-select')
    ;(modeSelect.element as HTMLSelectElement).value = 'interval'
    await modeSelect.trigger('change')

    const intervalInput = wrapper.find('.home-view__interval-input')
    ;(intervalInput.element as HTMLInputElement).value = '15'
    await intervalInput.trigger('input')

    const saveBtn = wrapper.findAllComponents({ name: 'BaseButton' })
      .find(b => b.text().toLowerCase().includes('sav'))!
    await saveBtn.trigger('click')
    await flushPromises()
    expect(devicesStore.updateDevice).toHaveBeenCalledWith(5, expect.objectContaining({
      wakeTimes: [],
      rotationIntervalMinutes: 15,
    }))
  })

  // The card's "next sync" must use the server-stamped nextPollExpectedAt
  // when present, ignoring the locally-saved (but not yet device-applied)
  // schedule. This is the bug Matt hit: changing 5 min → 3 min in the sheet
  // made the card jump to "in 3m" even though the device is still asleep
  // on the 5-min schedule and will wake at lastSeenAt + 5 min.
  it('nextSync uses server nextPollExpectedAt when present, not local schedule', async () => {
    const devicesStore = useDevicesStore()
    devicesStore.devices = [makeDevice({
      id: 1,
      // Locally we just saved every-3-min, but the device is still on
      // every-5-min until it next polls. The server's expected-next-poll
      // (set under the old schedule at the last poll) is 4 minutes out.
      rotationIntervalMinutes: 3,
      wakeTimes: [],
      lastSeenAt: new Date(Date.now() - 60_000).toISOString(),
      nextPollExpectedAt: new Date(Date.now() + 4 * 60_000).toISOString(),
    })]
    vi.spyOn(devicesStore, 'fetchDevices').mockResolvedValue()

    const wrapper = mountView()
    await flushPromises()
    const props = wrapper.findComponent({ name: 'FrameCard' }).props()
    // 4 minutes — what the server actually plans, NOT 2 min from
    // (lastSeenAt + 3 min - now), which would be the bug.
    expect(props.nextSync).toMatch(/in 4m/)
  })

  // The "next update" preview must reflect when the device will *actually*
  // next sync — that's when it picks up the new settings, not the first hit
  // of the new schedule. The device is asleep on its CURRENT schedule.
  it('next-update preview uses the current device schedule, not the proposed new one', async () => {
    const devicesStore = useDevicesStore()
    // Currently every 1 min, last seen 30s ago — next poll under current
    // schedule is ~30s away. User is editing to "at 4 AM only", but that
    // wake time is irrelevant until the device polls and learns about it.
    devicesStore.devices = [makeDevice({
      id: 5,
      wakeTimes: [],
      rotationIntervalMinutes: 1,
      lastSeenAt: new Date(Date.now() - 30_000).toISOString(),
    })]
    vi.spyOn(devicesStore, 'fetchDevices').mockResolvedValue()

    const wrapper = mountView()
    await flushPromises()
    await wrapper.findComponent({ name: 'FrameCard' }).vm.$emit('edit', 5)
    await flushPromises()

    // Switch the proposed new schedule to "at 4 AM only".
    const modeSelect = wrapper.find('.home-view__mode-select')
    ;(modeSelect.element as HTMLSelectElement).value = 'times'
    await modeSelect.trigger('change')
    await flushPromises()

    // Preview must still reflect the every-1-min current cadence — "<1 min" or
    // "any moment", NOT "4 AM today/tomorrow".
    const preview = wrapper.find('.home-view__next-update').text()
    expect(preview).toMatch(/(any moment|<1 min)/i)
    expect(preview).not.toMatch(/4 AM/)
  })

  it('next-update preview waits for the current daily wake time before showing the new schedule', async () => {
    const devicesStore = useDevicesStore()
    // Device is on "at 4 AM only" and last polled ~1h ago (so last poll was
    // today's 4 AM, sleeping till tomorrow's 4 AM). User is adding a 6 PM slot.
    // The preview should show "~4 AM tomorrow" — the device can't act on the
    // new 6 PM slot until it wakes at 4 AM tomorrow.
    devicesStore.devices = [makeDevice({
      id: 5,
      wakeTimes: [4 * 60],
      timezone: 'UTC',
      lastSeenAt: new Date(Date.now() - 60 * 60_000).toISOString(),
    })]
    vi.spyOn(devicesStore, 'fetchDevices').mockResolvedValue()

    const wrapper = mountView()
    await flushPromises()
    await wrapper.findComponent({ name: 'FrameCard' }).vm.$emit('edit', 5)
    await flushPromises()

    // Add a 6 PM slot to the proposed schedule.
    const addBtn = wrapper.find('.home-view__time-add')
    await addBtn.trigger('click')
    await flushPromises()

    const preview = wrapper.find('.home-view__next-update').text()
    expect(preview).toMatch(/4 AM/)
    expect(preview).not.toMatch(/6 PM/)
  })

  it('next-update preview falls back to a friendly note when the device has never connected', async () => {
    const devicesStore = useDevicesStore()
    devicesStore.devices = [makeDevice({ id: 5, lastSeenAt: null })]
    vi.spyOn(devicesStore, 'fetchDevices').mockResolvedValue()

    const wrapper = mountView()
    await flushPromises()
    await wrapper.findComponent({ name: 'FrameCard' }).vm.$emit('edit', 5)
    await flushPromises()

    expect(wrapper.find('.home-view__next-update').text())
      .toMatch(/never connect|when the frame next/i)
  })

  it('save sends rotationMode + prioritizeNeverShown along with the other fields', async () => {
    const devicesStore = useDevicesStore()
    devicesStore.devices = [makeDevice({ id: 5, rotationMode: 'oldest_upload', prioritizeNeverShown: false })]
    vi.spyOn(devicesStore, 'fetchDevices').mockResolvedValue()
    const updateSpy = vi.spyOn(devicesStore, 'updateDevice').mockResolvedValue(makeDevice({ id: 5 }))

    const wrapper = mountView()
    await flushPromises()
    await wrapper.findComponent({ name: 'FrameCard' }).vm.$emit('edit', 5)
    await flushPromises()

    // The image-selection mode select is the third <select.home-view__mode-select>
    // in the sheet (frequency mode + tz are the others); querying by aria-label
    // is more robust than position.
    const modeSelect = wrapper.find('select[aria-label="Image selection mode"]')
    ;(modeSelect.element as HTMLSelectElement).value = 'random'
    await modeSelect.trigger('change')

    const checkbox = wrapper.find('.home-view__rotation-checkbox input[type="checkbox"]')
    ;(checkbox.element as HTMLInputElement).checked = true
    await checkbox.trigger('change')

    const saveBtn = wrapper.findAllComponents({ name: 'BaseButton' })
      .find(b => b.text().toLowerCase().includes('sav'))!
    await saveBtn.trigger('click')
    await flushPromises()

    expect(updateSpy).toHaveBeenCalledWith(5, expect.objectContaining({
      rotationMode: 'random',
      prioritizeNeverShown: true,
    }))
  })

  // Two-step confirm: clicking "Remove this frame" opens the in-sheet
  // confirmation panel; clicking "Yes, remove" inside it actually fires
  // the API + closes the sheet.
  // The confirmation modal is rendered via <Teleport to="body">, so it lives
  // outside the wrapper's element tree. Tests have to query document
  // directly. The Remove button itself stays in the sheet; opening the
  // modal does NOT replace it.
  it('Remove this frame opens a popup modal teleported to body', async () => {
    const devicesStore = useDevicesStore()
    devicesStore.devices = [makeDevice({ id: 5, name: 'Den' })]
    vi.spyOn(devicesStore, 'fetchDevices').mockResolvedValue()
    vi.spyOn(devicesStore, 'removeDevice').mockResolvedValue()

    const wrapper = mountView()
    await flushPromises()
    await wrapper.findComponent({ name: 'FrameCard' }).vm.$emit('edit', 5)
    await flushPromises()

    expect(document.querySelector('.home-view__remove-modal')).toBeNull()

    await wrapper.find('.home-view__remove').trigger('click')
    await flushPromises()

    const modal = document.querySelector('.home-view__remove-modal')
    expect(modal).not.toBeNull()
    expect(modal!.querySelector('.home-view__remove-confirm-body')!.textContent).toContain('selling or giving away')
  })

  it('Yes, remove inside the modal calls the store and closes both', async () => {
    const devicesStore = useDevicesStore()
    devicesStore.devices = [makeDevice({ id: 5 })]
    vi.spyOn(devicesStore, 'fetchDevices').mockResolvedValue()
    const removeSpy = vi.spyOn(devicesStore, 'removeDevice').mockResolvedValue()

    const wrapper = mountView()
    await flushPromises()
    await wrapper.findComponent({ name: 'FrameCard' }).vm.$emit('edit', 5)
    await flushPromises()
    await wrapper.find('.home-view__remove').trigger('click')
    await flushPromises()

    const confirmBtn = document.querySelector('.home-view__remove-confirm-btn') as HTMLButtonElement
    confirmBtn.click()
    await flushPromises()

    expect(removeSpy).toHaveBeenCalledWith(5)
    const sheet = wrapper.findComponent({ name: 'BaseBottomSheet' })
    expect(sheet.props('modelValue')).toBe(false)
    expect(document.querySelector('.home-view__remove-modal')).toBeNull()
  })

  it('Cancel inside the modal closes it without calling removeDevice', async () => {
    const devicesStore = useDevicesStore()
    devicesStore.devices = [makeDevice({ id: 5 })]
    vi.spyOn(devicesStore, 'fetchDevices').mockResolvedValue()
    const removeSpy = vi.spyOn(devicesStore, 'removeDevice').mockResolvedValue()

    const wrapper = mountView()
    await flushPromises()
    await wrapper.findComponent({ name: 'FrameCard' }).vm.$emit('edit', 5)
    await flushPromises()
    await wrapper.find('.home-view__remove').trigger('click')
    await flushPromises()

    const cancelBtn = document.querySelector('.home-view__remove-cancel') as HTMLButtonElement
    cancelBtn.click()
    await flushPromises()

    expect(removeSpy).not.toHaveBeenCalled()
    expect(document.querySelector('.home-view__remove-modal')).toBeNull()
    // The settings sheet stays open and the Remove button still shows.
    expect(wrapper.find('.home-view__remove').exists()).toBe(true)
  })

  // Backdrop-click closes the modal — common modal expectation.
  it('clicking the backdrop closes the modal without removing', async () => {
    const devicesStore = useDevicesStore()
    devicesStore.devices = [makeDevice({ id: 5 })]
    vi.spyOn(devicesStore, 'fetchDevices').mockResolvedValue()
    const removeSpy = vi.spyOn(devicesStore, 'removeDevice').mockResolvedValue()

    const wrapper = mountView()
    await flushPromises()
    await wrapper.findComponent({ name: 'FrameCard' }).vm.$emit('edit', 5)
    await flushPromises()
    await wrapper.find('.home-view__remove').trigger('click')
    await flushPromises()

    const backdrop = document.querySelector('.home-view__remove-modal') as HTMLElement
    // @click.self on the backdrop fires the close — happy-dom needs a real
    // dispatchEvent on the element itself with target=backdrop.
    const ev = new MouseEvent('click', { bubbles: true })
    Object.defineProperty(ev, 'target', { value: backdrop, configurable: true })
    backdrop.dispatchEvent(ev)
    await flushPromises()

    expect(removeSpy).not.toHaveBeenCalled()
    expect(document.querySelector('.home-view__remove-modal')).toBeNull()
  })

  // First-time setup: the SetupController redirects new claim/register
  // flows to /?setup=<id>. The SPA must auto-open the per-frame settings
  // sheet for that device, with the standard pre-population, so the user
  // never has to learn a separate Twig configure form.
  it('auto-opens the per-frame settings sheet when ?setup=<id> is in the URL', async () => {
    const devicesStore = useDevicesStore()
    devicesStore.devices = [makeDevice({ id: 7, name: '' })]
    vi.spyOn(devicesStore, 'fetchDevices').mockResolvedValue()

    // Simulate landing on /?setup=7 (SetupController's post-link redirect).
    mockRoute.query = { setup: '7' }

    const wrapper = mountView()
    await flushPromises()

    const sheet = wrapper.findComponent({ name: 'BaseBottomSheet' })
    expect(sheet.props('modelValue')).toBe(true)
    expect(routerReplace).toHaveBeenCalled()  // query cleaned off the URL

    mockRoute.query = {}
  })

  it('does NOT open the sheet when no ?setup= param is present', async () => {
    const devicesStore = useDevicesStore()
    devicesStore.devices = [makeDevice({ id: 7 })]
    vi.spyOn(devicesStore, 'fetchDevices').mockResolvedValue()
    mockRoute.query = {}

    const wrapper = mountView()
    await flushPromises()

    const sheet = wrapper.findComponent({ name: 'BaseBottomSheet' })
    expect(sheet.props('modelValue')).toBe(false)
  })

  // Update the existing propagation-note text test to assert the new
  // "disconnect power" hint that lets users force an immediate refresh.
  it('propagation note mentions disconnecting power as an immediate-refresh gesture', async () => {
    const devicesStore = useDevicesStore()
    devicesStore.devices = [makeDevice({ id: 5 })]
    vi.spyOn(devicesStore, 'fetchDevices').mockResolvedValue()

    const wrapper = mountView()
    await flushPromises()
    await wrapper.findComponent({ name: 'FrameCard' }).vm.$emit('edit', 5)
    await flushPromises()

    const note = wrapper.find('.home-view__propagation-note').text()
    expect(note).toMatch(/next device update/)
    expect(note).toMatch(/disconnect.*reconnect.*power/i)
  })

  it('shows the propagation note in the settings sheet', async () => {
    const devicesStore = useDevicesStore()
    devicesStore.devices = [makeDevice({ id: 5 })]
    vi.spyOn(devicesStore, 'fetchDevices').mockResolvedValue()

    const wrapper = mountView()
    await flushPromises()
    await wrapper.findComponent({ name: 'FrameCard' }).vm.$emit('edit', 5)
    await flushPromises()

    expect(wrapper.find('.home-view__propagation-note').text())
      .toMatch(/take effect at the next device update/i)
  })

  it('saving while no device is being edited is a no-op (defensive guard)', async () => {
    const devicesStore = useDevicesStore()
    devicesStore.devices = [makeDevice({ id: 1 })]
    vi.spyOn(devicesStore, 'fetchDevices').mockResolvedValue()
    const updateSpy = vi.spyOn(devicesStore, 'updateDevice').mockResolvedValue(makeDevice())

    const wrapper = mountView()
    await flushPromises()
    // The BaseBottomSheet stub always renders its slot, so the Save button is in
    // the DOM even before onEdit is called. Clicking it now exercises the
    // editingDevice null-guard.
    const saveBtn = wrapper.findAllComponents({ name: 'BaseButton' })
      .find(b => b.text().toLowerCase().includes('sav'))!
    await saveBtn.trigger('click')
    await flushPromises()
    expect(updateSpy).not.toHaveBeenCalled()
  })

  it('updates editName/orientation/timezone when their components emit changes', async () => {
    const devicesStore = useDevicesStore()
    devicesStore.devices = [makeDevice({ id: 5, name: 'Original', wakeTimes: [4 * 60], timezone: 'UTC' })]
    vi.spyOn(devicesStore, 'fetchDevices').mockResolvedValue()
    const updateSpy = vi.spyOn(devicesStore, 'updateDevice').mockResolvedValue(makeDevice({ id: 5 }))

    const wrapper = mountView()
    await flushPromises()
    await wrapper.findComponent({ name: 'FrameCard' }).vm.$emit('edit', 5)
    await flushPromises()

    await wrapper.findComponent({ name: 'BaseInput' }).vm.$emit('update:modelValue', 'New Name')
    await wrapper.findComponent({ name: 'OrientationPicker' }).vm.$emit('update:modelValue', 'portrait')
    const select = wrapper.find('select.home-view__tz-select')
    ;(select.element as HTMLSelectElement).value = 'America/New_York'
    await select.trigger('change')

    const saveBtn = wrapper.findAllComponents({ name: 'BaseButton' })
      .find(b => b.text().toLowerCase().includes('sav'))!
    await saveBtn.trigger('click')
    await flushPromises()
    expect(updateSpy).toHaveBeenCalledWith(5, expect.objectContaining({
      name: 'New Name',
      orientation: 'portrait',
      timezone: 'America/New_York',
    }))
  })

  it('opens in interval mode and defaults timezone to UTC when device has empty wakeTimes', async () => {
    const devicesStore = useDevicesStore()
    devicesStore.devices = [makeDevice({
      id: 5,
      name: 'Den',
      wakeTimes: [],
      rotationIntervalMinutes: 60,
      timezone: null as any,
    })]
    vi.spyOn(devicesStore, 'fetchDevices').mockResolvedValue()
    const updateSpy = vi.spyOn(devicesStore, 'updateDevice').mockResolvedValue(makeDevice({ id: 5 }))

    const wrapper = mountView()
    await flushPromises()
    await wrapper.findComponent({ name: 'FrameCard' }).vm.$emit('edit', 5)
    await flushPromises()

    // Sheet opens in interval mode — interval input is shown, time-list is not.
    expect(wrapper.find('.home-view__interval-input').exists()).toBe(true)
    expect(wrapper.find('.home-view__time-add').exists()).toBe(false)

    const saveBtn = wrapper.findAllComponents({ name: 'BaseButton' })
      .find(b => b.text().toLowerCase().includes('sav'))!
    await saveBtn.trigger('click')
    await flushPromises()
    expect(updateSpy).toHaveBeenCalledWith(5, expect.objectContaining({
      wakeTimes: [],
      rotationIntervalMinutes: 60,
      timezone: 'UTC',
    }))
  })

  it('the settings sheet closes when the underlying bottom-sheet emits close', async () => {
    const devicesStore = useDevicesStore()
    devicesStore.devices = [makeDevice({ id: 5 })]
    vi.spyOn(devicesStore, 'fetchDevices').mockResolvedValue()

    const wrapper = mountView()
    await flushPromises()
    await wrapper.findComponent({ name: 'FrameCard' }).vm.$emit('edit', 5)
    await flushPromises()
    const sheet = wrapper.findComponent({ name: 'BaseBottomSheet' })
    expect(sheet.props('modelValue')).toBe(true)
    await sheet.vm.$emit('update:modelValue', false)
    await wrapper.vm.$nextTick()
    expect(wrapper.findComponent({ name: 'BaseBottomSheet' }).props('modelValue')).toBe(false)
  })

  it('saving falls back to the original name when the user clears the field', async () => {
    const devicesStore = useDevicesStore()
    devicesStore.devices = [makeDevice({ id: 5, name: 'Original' })]
    vi.spyOn(devicesStore, 'fetchDevices').mockResolvedValue()
    const updateSpy = vi.spyOn(devicesStore, 'updateDevice').mockResolvedValue(makeDevice({ id: 5 }))

    const wrapper = mountView()
    await flushPromises()
    await wrapper.findComponent({ name: 'FrameCard' }).vm.$emit('edit', 5)
    await flushPromises()
    await wrapper.findComponent({ name: 'BaseInput' }).vm.$emit('update:modelValue', '   ')

    const saveBtn = wrapper.findAllComponents({ name: 'BaseButton' })
      .find(b => b.text().toLowerCase().includes('sav'))!
    await saveBtn.trigger('click')
    await flushPromises()

    expect(updateSpy).toHaveBeenCalledWith(5, expect.objectContaining({ name: 'Original' }))
  })
})
