import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
import { mount, flushPromises } from '@vue/test-utils'
import { setActivePinia, createPinia } from 'pinia'
import SettingsView from '@/views/SettingsView.vue'
import { useAuthStore } from '@/stores/auth'
import { THEMES } from '@/composables/useTheme'
import { __resetPwaInstallForTests } from '@/composables/usePwaInstall'

// Fires a synthetic `beforeinstallprompt` so the usePwaInstall singleton
// caches an event and exposes canPromptInstall=true. The composable
// registers its listener on first import (registerOnce), but
// __resetPwaInstallForTests flips `registered` back to false so the next
// usePwaInstall() call re-registers — meaning every mount in this file
// re-arms the listener for that test.
function fireBeforeInstallPrompt(opts: { accepted?: boolean } = {}) {
  const { accepted = true } = opts
  const ev = new Event('beforeinstallprompt') as Event & {
    prompt: () => Promise<void>
    userChoice: Promise<{ outcome: 'accepted' | 'dismissed'; platform: string }>
  }
  ev.prompt = vi.fn().mockResolvedValue(undefined)
  ev.userChoice = Promise.resolve({
    outcome: accepted ? 'accepted' : 'dismissed',
    platform: 'web',
  })
  window.dispatchEvent(ev)
  return ev
}

describe('SettingsView', () => {
  beforeEach(() => {
    setActivePinia(createPinia())
    __resetPwaInstallForTests()
  })

  afterEach(() => {
    vi.unstubAllGlobals()
    vi.useRealTimers()
  })

  it('renders one swatch per theme and the user email', () => {
    const auth = useAuthStore()
    auth.setUser({ id: 1, email: 'matt@example.com', roles: [], theme: 'warm-craft', designVersion: 'v1', timezone: 'UTC' })
    const wrapper = mount(SettingsView)
    expect(wrapper.findAll('.theme-swatch')).toHaveLength(THEMES.length)
    expect(wrapper.text()).toContain('matt@example.com')
  })

  it('marks the user current theme as the active swatch', () => {
    const auth = useAuthStore()
    auth.setUser({ id: 1, email: 'm@e', roles: [], theme: 'ocean-dusk', designVersion: 'v1', timezone: 'UTC' })
    const wrapper = mount(SettingsView)
    const active = wrapper.find('.theme-swatch--active')
    expect(active.attributes('aria-label')).toBe('Ocean Dusk')
    expect(active.attributes('aria-checked')).toBe('true')
  })

  it('falls back to warm-craft as active when user has no theme set', () => {
    const auth = useAuthStore()
    auth.setUser({ id: 1, email: 'm@e', roles: [], theme: null, designVersion: 'v1', timezone: 'UTC' })
    const wrapper = mount(SettingsView)
    expect(wrapper.find('.theme-swatch--active').attributes('aria-label')).toBe('Warm Craft')
  })

  it('clicking a swatch saves the theme via PATCH /api/user/theme', async () => {
    const auth = useAuthStore()
    auth.setUser({ id: 1, email: 'm@e', roles: [], theme: 'warm-craft', designVersion: 'v1', timezone: 'UTC' })
    const fetchMock = vi.fn().mockResolvedValue({ ok: true })
    vi.stubGlobal('fetch', fetchMock)

    const wrapper = mount(SettingsView)
    const swatch = wrapper.findAll('.theme-swatch').find(s => s.attributes('aria-label') === 'Sage & Cream')!
    await swatch.trigger('click')

    expect(fetchMock).toHaveBeenCalledWith(
      '/api/user/theme',
      expect.objectContaining({
        method: 'PATCH',
        body: JSON.stringify({ theme: 'sage-cream' }),
      }),
    )
    vi.unstubAllGlobals()
  })

  it('renders a Sign out link to /logout', () => {
    const auth = useAuthStore()
    auth.setUser({ id: 1, email: 'm@e', roles: [], theme: 'warm-craft', designVersion: 'v1', timezone: 'UTC' })
    const wrapper = mount(SettingsView)
    const logout = wrapper.find('a.settings__logout')
    expect(logout.text()).toBe('Sign out')
    expect(logout.attributes('href')).toBe('/logout')
  })

  it('shows the install button by default (no native prompt yet, not standalone)', () => {
    const auth = useAuthStore()
    auth.setUser({ id: 1, email: 'm@e', roles: [], theme: 'warm-craft', designVersion: 'v1', timezone: 'UTC' })
    const wrapper = mount(SettingsView)
    const btn = wrapper.find('.settings__install')
    expect(btn.exists()).toBe(true)
    expect(btn.text()).toBe('Add to Home Screen')
  })

  it('opens the iOS-style instructions modal when install button is clicked without a native prompt', async () => {
    const auth = useAuthStore()
    auth.setUser({ id: 1, email: 'm@e', roles: [], theme: 'warm-craft', designVersion: 'v1', timezone: 'UTC' })
    const wrapper = mount(SettingsView)
    expect(wrapper.find('.install-modal').exists()).toBe(false)
    await wrapper.find('.settings__install').trigger('click')
    expect(wrapper.find('.install-modal').exists()).toBe(true)
    expect(wrapper.find('.install-modal__title').text()).toMatch(/home screen/i)
  })

  it('closes the modal via the close button', async () => {
    const auth = useAuthStore()
    auth.setUser({ id: 1, email: 'm@e', roles: [], theme: 'warm-craft', designVersion: 'v1', timezone: 'UTC' })
    const wrapper = mount(SettingsView)
    await wrapper.find('.settings__install').trigger('click')
    await wrapper.find('.install-modal__close').trigger('click')
    expect(wrapper.find('.install-modal').exists()).toBe(false)
  })

  it('clicking the iOS-instructions backdrop closes the modal', async () => {
    const auth = useAuthStore()
    auth.setUser({ id: 1, email: 'm@e', roles: [], theme: 'warm-craft', designVersion: 'v1', timezone: 'UTC' })
    const wrapper = mount(SettingsView)
    await wrapper.find('.settings__install').trigger('click')
    expect(wrapper.find('.install-modal').exists()).toBe(true)

    // @click.self requires the event target to actually be the backdrop.
    const backdrop = wrapper.find('.install-modal').element as HTMLElement
    const ev = new MouseEvent('click', { bubbles: true })
    Object.defineProperty(ev, 'target', { value: backdrop, configurable: true })
    backdrop.dispatchEvent(ev)
    await flushPromises()
    expect(wrapper.find('.install-modal').exists()).toBe(false)
  })

  // ── Install: native-prompt branch ──────────────────────────────────────────

  it('shows the native Install button when a beforeinstallprompt event is cached', async () => {
    const auth = useAuthStore()
    auth.setUser({ id: 1, email: 'm@e', roles: [], theme: 'warm-craft', designVersion: 'v1', timezone: 'UTC' })
    fireBeforeInstallPrompt()
    const wrapper = mount(SettingsView)
    await flushPromises()
    expect(wrapper.find('.settings__install').text()).toBe('Install WeVisto')
  })

  it('clicking the native Install button invokes prompt() and resolves accepted', async () => {
    const auth = useAuthStore()
    auth.setUser({ id: 1, email: 'm@e', roles: [], theme: 'warm-craft', designVersion: 'v1', timezone: 'UTC' })
    const ev = fireBeforeInstallPrompt({ accepted: true })
    const wrapper = mount(SettingsView)
    await flushPromises()

    await wrapper.find('.settings__install').trigger('click')
    await flushPromises()

    expect(ev.prompt).toHaveBeenCalled()
    // Accepted path → the iOS modal should NOT open.
    expect(wrapper.find('.install-modal').exists()).toBe(false)
  })

  it('falls back to the iOS modal when the native install is dismissed and event is gone', async () => {
    // This covers lines 187-192: install() returns false (user dismissed) AND
    // canPromptInstall has flipped false (event consumed) → showIosInstructions.
    const auth = useAuthStore()
    auth.setUser({ id: 1, email: 'm@e', roles: [], theme: 'warm-craft', designVersion: 'v1', timezone: 'UTC' })
    fireBeforeInstallPrompt({ accepted: false })
    const wrapper = mount(SettingsView)
    await flushPromises()

    await wrapper.find('.settings__install').trigger('click')
    await flushPromises()

    expect(wrapper.find('.install-modal').exists()).toBe(true)
    expect(wrapper.find('.install-modal__title').text()).toMatch(/home screen/i)
  })

  // ── Change password modal ──────────────────────────────────────────────────

  it('opens the change-password modal when the Change password action is clicked', async () => {
    const auth = useAuthStore()
    auth.setUser({ id: 1, email: 'm@e', roles: [], theme: 'warm-craft', designVersion: 'v1', timezone: 'UTC' })
    const wrapper = mount(SettingsView)

    expect(wrapper.find('.pw-form').exists()).toBe(false)
    await wrapper.find('.settings__action-link').trigger('click')
    expect(wrapper.find('.pw-form').exists()).toBe(true)
    expect(wrapper.find('#pw-modal-title').text()).toBe('Change password')
  })

  it('shows a mismatch error when confirm differs from new password', async () => {
    const auth = useAuthStore()
    auth.setUser({ id: 1, email: 'm@e', roles: [], theme: 'warm-craft', designVersion: 'v1', timezone: 'UTC' })
    const wrapper = mount(SettingsView)
    await wrapper.find('.settings__action-link').trigger('click')

    const inputs = wrapper.findAll('.pw-form__input')
    await inputs[0].setValue('oldpw')
    await inputs[1].setValue('newpassword1')
    await inputs[2].setValue('different')

    // Mismatch surfaces an inline error and aria-invalid on the confirm input.
    expect(wrapper.text()).toContain("Passwords don't match")
    expect(inputs[2].attributes('aria-invalid')).toBe('true')

    // Submit button is disabled while the mismatch stands.
    const submit = wrapper.find('button[type="submit"]')
    expect((submit.element as HTMLButtonElement).disabled).toBe(true)
  })

  it('does not submit when fields are empty (button disabled)', async () => {
    const auth = useAuthStore()
    auth.setUser({ id: 1, email: 'm@e', roles: [], theme: 'warm-craft', designVersion: 'v1', timezone: 'UTC' })
    const wrapper = mount(SettingsView)
    await wrapper.find('.settings__action-link').trigger('click')

    const submit = wrapper.find('button[type="submit"]')
    expect((submit.element as HTMLButtonElement).disabled).toBe(true)
  })

  it('submitPasswordChange is a no-op while in mismatch state (form.submit early return)', async () => {
    // Hits the `if (pwConfirmMismatch.value) return` guard at the top of
    // submitPasswordChange. Even if the form gets force-submitted with a
    // mismatch, no fetch fires.
    const auth = useAuthStore()
    auth.setUser({ id: 1, email: 'm@e', roles: [], theme: 'warm-craft', designVersion: 'v1', timezone: 'UTC' })
    const fetchMock = vi.fn()
    vi.stubGlobal('fetch', fetchMock)

    const wrapper = mount(SettingsView)
    await wrapper.find('.settings__action-link').trigger('click')
    const inputs = wrapper.findAll('.pw-form__input')
    await inputs[0].setValue('oldpw')
    await inputs[1].setValue('newpassword1')
    await inputs[2].setValue('different')

    // The submit button is disabled while in mismatch state, so trigger the
    // form's submit event directly to exercise the early-return guard.
    await wrapper.find('form.pw-form').trigger('submit.prevent')
    await flushPromises()
    expect(fetchMock).not.toHaveBeenCalled()
  })

  it('submits the new password, shows success, clears fields, and auto-closes', async () => {
    vi.useFakeTimers()
    const auth = useAuthStore()
    auth.setUser({ id: 1, email: 'm@e', roles: [], theme: 'warm-craft', designVersion: 'v1', timezone: 'UTC' })
    const fetchMock = vi.fn().mockResolvedValue({ status: 204, json: () => Promise.resolve({}) })
    vi.stubGlobal('fetch', fetchMock)

    const wrapper = mount(SettingsView)
    await wrapper.find('.settings__action-link').trigger('click')

    const inputs = wrapper.findAll('.pw-form__input')
    await inputs[0].setValue('oldpw')
    await inputs[1].setValue('newpassword1')
    await inputs[2].setValue('newpassword1')

    await wrapper.find('form.pw-form').trigger('submit.prevent')
    await flushPromises()

    expect(fetchMock).toHaveBeenCalledWith(
      '/api/user/password',
      expect.objectContaining({
        method: 'PATCH',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({
          currentPassword: 'oldpw',
          newPassword: 'newpassword1',
        }),
      }),
    )
    // Success message renders.
    expect(wrapper.find('.pw-form__success').exists()).toBe(true)

    // The 1500ms auto-close timer fires → resetPasswordForm + closePasswordModal.
    vi.advanceTimersByTime(1500)
    await flushPromises()
    expect(wrapper.find('.pw-form').exists()).toBe(false)
  })

  it('renders the server-provided error message on a 4xx response', async () => {
    const auth = useAuthStore()
    auth.setUser({ id: 1, email: 'm@e', roles: [], theme: 'warm-craft', designVersion: 'v1', timezone: 'UTC' })
    const fetchMock = vi.fn().mockResolvedValue({
      status: 400,
      json: () => Promise.resolve({ error: 'Current password is wrong.' }),
    })
    vi.stubGlobal('fetch', fetchMock)

    const wrapper = mount(SettingsView)
    await wrapper.find('.settings__action-link').trigger('click')
    const inputs = wrapper.findAll('.pw-form__input')
    await inputs[0].setValue('badpw')
    await inputs[1].setValue('newpassword1')
    await inputs[2].setValue('newpassword1')

    await wrapper.find('form.pw-form').trigger('submit.prevent')
    await flushPromises()

    expect(wrapper.find('.pw-form__error').text()).toBe('Current password is wrong.')
    // Modal stays open so the user can retry.
    expect(wrapper.find('.pw-form').exists()).toBe(true)
  })

  it('uses a generic error when the server returns no error message', async () => {
    const auth = useAuthStore()
    auth.setUser({ id: 1, email: 'm@e', roles: [], theme: 'warm-craft', designVersion: 'v1', timezone: 'UTC' })
    const fetchMock = vi.fn().mockResolvedValue({
      status: 500,
      json: () => Promise.resolve({}),
    })
    vi.stubGlobal('fetch', fetchMock)

    const wrapper = mount(SettingsView)
    await wrapper.find('.settings__action-link').trigger('click')
    const inputs = wrapper.findAll('.pw-form__input')
    await inputs[0].setValue('oldpw')
    await inputs[1].setValue('newpassword1')
    await inputs[2].setValue('newpassword1')

    await wrapper.find('form.pw-form').trigger('submit.prevent')
    await flushPromises()

    expect(wrapper.find('.pw-form__error').text()).toBe('Could not update password.')
  })

  it('falls back to the generic error when the response body is unparseable JSON', async () => {
    // res.json() rejecting exercises the `.catch(() => ({}))` branch.
    const auth = useAuthStore()
    auth.setUser({ id: 1, email: 'm@e', roles: [], theme: 'warm-craft', designVersion: 'v1', timezone: 'UTC' })
    const fetchMock = vi.fn().mockResolvedValue({
      status: 400,
      json: () => Promise.reject(new Error('not json')),
    })
    vi.stubGlobal('fetch', fetchMock)

    const wrapper = mount(SettingsView)
    await wrapper.find('.settings__action-link').trigger('click')
    const inputs = wrapper.findAll('.pw-form__input')
    await inputs[0].setValue('oldpw')
    await inputs[1].setValue('newpassword1')
    await inputs[2].setValue('newpassword1')

    await wrapper.find('form.pw-form').trigger('submit.prevent')
    await flushPromises()

    expect(wrapper.find('.pw-form__error').text()).toBe('Could not update password.')
  })

  it('renders a network-error message when fetch rejects', async () => {
    const auth = useAuthStore()
    auth.setUser({ id: 1, email: 'm@e', roles: [], theme: 'warm-craft', designVersion: 'v1', timezone: 'UTC' })
    const fetchMock = vi.fn().mockRejectedValue(new TypeError('network down'))
    vi.stubGlobal('fetch', fetchMock)

    const wrapper = mount(SettingsView)
    await wrapper.find('.settings__action-link').trigger('click')
    const inputs = wrapper.findAll('.pw-form__input')
    await inputs[0].setValue('oldpw')
    await inputs[1].setValue('newpassword1')
    await inputs[2].setValue('newpassword1')

    await wrapper.find('form.pw-form').trigger('submit.prevent')
    await flushPromises()

    expect(wrapper.find('.pw-form__error').text()).toBe('Network error. Try again.')
  })

  it('toggles the submit button into a saving state during the request', async () => {
    const auth = useAuthStore()
    auth.setUser({ id: 1, email: 'm@e', roles: [], theme: 'warm-craft', designVersion: 'v1', timezone: 'UTC' })
    // Pending fetch keeps pwSubmitting=true so the saving label is visible.
    let resolveFetch!: (v: { status: number; json: () => Promise<unknown> }) => void
    const fetchMock = vi.fn(() => new Promise(res => { resolveFetch = res }))
    vi.stubGlobal('fetch', fetchMock)

    const wrapper = mount(SettingsView)
    await wrapper.find('.settings__action-link').trigger('click')
    const inputs = wrapper.findAll('.pw-form__input')
    await inputs[0].setValue('oldpw')
    await inputs[1].setValue('newpassword1')
    await inputs[2].setValue('newpassword1')

    wrapper.find('form.pw-form').trigger('submit.prevent')
    await flushPromises()

    const submit = wrapper.find('button[type="submit"]')
    expect(submit.text()).toBe('Saving…')
    expect((submit.element as HTMLButtonElement).disabled).toBe(true)

    // Resolve so the finally{} branch runs and pwSubmitting flips back.
    resolveFetch({ status: 204, json: () => Promise.resolve({}) })
    await flushPromises()
  })

  it('closes the modal via the × button and clears entered fields (resetPasswordForm)', async () => {
    const auth = useAuthStore()
    auth.setUser({ id: 1, email: 'm@e', roles: [], theme: 'warm-craft', designVersion: 'v1', timezone: 'UTC' })
    const wrapper = mount(SettingsView)
    await wrapper.find('.settings__action-link').trigger('click')

    const inputs = wrapper.findAll('.pw-form__input')
    await inputs[0].setValue('something')
    await inputs[1].setValue('newpassword1')
    await inputs[2].setValue('newpassword1')

    // Click the modal's close (×) button.
    const closeBtns = wrapper.findAll('.install-modal__close')
    // The password modal is the first install-modal in the DOM order
    // (it's rendered before the iOS modal in the template).
    await closeBtns[0].trigger('click')
    await flushPromises()
    expect(wrapper.find('.pw-form').exists()).toBe(false)

    // Reopen — fields should be empty again because resetPasswordForm ran.
    await wrapper.find('.settings__action-link').trigger('click')
    const reopenedInputs = wrapper.findAll('.pw-form__input')
    expect((reopenedInputs[0].element as HTMLInputElement).value).toBe('')
    expect((reopenedInputs[1].element as HTMLInputElement).value).toBe('')
    expect((reopenedInputs[2].element as HTMLInputElement).value).toBe('')
  })

  it('renders the iPhone-specific copy in the install modal when the UA is iOS', async () => {
    const auth = useAuthStore()
    auth.setUser({ id: 1, email: 'm@e', roles: [], theme: 'warm-craft', designVersion: 'v1', timezone: 'UTC' })

    // usePwaInstall calls detectIOS() per-component-instance from
    // navigator.userAgent, so spoofing the UA before mount picks the iOS branch.
    const realUA = Object.getOwnPropertyDescriptor(navigator, 'userAgent')
    Object.defineProperty(navigator, 'userAgent', {
      value: 'Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) AppleWebKit Mobile Safari',
      configurable: true,
    })

    try {
      const wrapper = mount(SettingsView)
      await wrapper.find('.settings__install').trigger('click')
      expect(wrapper.find('.install-modal__title').text()).toBe('Add to your iPhone home screen')

      const modalText = wrapper.find('.install-modal').text()
      // The iOS-only "Share icon" step renders; the non-iOS three-dots step does not.
      expect(modalText).toMatch(/Share/)
      expect(modalText).not.toMatch(/three dots/)
      // The "or Install app" suffix (v-if="!isIOS") is NOT inside the modal on iOS.
      expect(modalText).not.toMatch(/or Install app/)
    } finally {
      if (realUA) Object.defineProperty(navigator, 'userAgent', realUA)
    }
  })

  it('closes the password modal when the backdrop is clicked', async () => {
    const auth = useAuthStore()
    auth.setUser({ id: 1, email: 'm@e', roles: [], theme: 'warm-craft', designVersion: 'v1', timezone: 'UTC' })
    const wrapper = mount(SettingsView)
    await wrapper.find('.settings__action-link').trigger('click')
    expect(wrapper.find('.pw-form').exists()).toBe(true)

    // First install-modal in the DOM is the password modal.
    const backdrop = wrapper.findAll('.install-modal')[0].element as HTMLElement
    const ev = new MouseEvent('click', { bubbles: true })
    Object.defineProperty(ev, 'target', { value: backdrop, configurable: true })
    backdrop.dispatchEvent(ev)
    await flushPromises()
    expect(wrapper.find('.pw-form').exists()).toBe(false)
  })
})
