// Tapestry write-path tests. Covers the per-instance MMKV queue, the
// debounced fire-and-forget flush, and the Halflight gate that keeps
// non-tapestry instances at zero outbound traffic.

import type { AccountSummary } from 'src/state/accountStore'

// Shared in-memory state. Prefix-`mock` so the variable can be referenced
// inside `jest.mock` factories (which are hoisted above other declarations).
const mockState = {
  storage: new Map<string, string>(),
  postRaw: jest.fn() as jest.Mock,
  appStateListener: null as ((s: string) => void) | null,
  switchListener: null as (() => void) | null,
  activeAccount: null as AccountSummary | null,
  allAccounts: [] as AccountSummary[],
  featuresEnabled: true,
}

jest.mock('src/state/storage', () => ({
  appStorage: {
    getString: (k: string) => mockState.storage.get(k),
    set: (k: string, v: string) => {
      mockState.storage.set(k, v)
    },
    remove: (k: string) => {
      mockState.storage.delete(k)
    },
  },
  getFeatures: () =>
    mockState.featuresEnabled ? { tapestry: true } : { tapestry: false },
}))

jest.mock('src/state/accountStore', () => ({
  AccountStore: {
    activeAccount: () => mockState.activeAccount,
    list: () => mockState.allAccounts,
    subscribeSwitch: (fn: () => void) => {
      mockState.switchListener = fn
      return () => {
        mockState.switchListener = null
      }
    },
  },
}))

jest.mock('src/lib/api-context', () => ({
  ContextForAccount: () => ({ postRaw: mockState.postRaw }),
}))

jest.mock('react-native', () => ({
  AppState: {
    addEventListener: (_: string, fn: (s: string) => void) => {
      mockState.appStateListener = fn
      return { remove: () => {} }
    },
  },
}))

const HOST = 'example.test'
const QUEUE_KEY = `tap.queue.${HOST}`

function makeAccount(): AccountSummary {
  return {
    accountId: 'acct-1',
    type: 'pixelfed',
    instance: HOST,
    userId: 'u1',
    username: 'u1',
    acct: 'u1@example.test',
    displayName: 'u1',
    avatarUrl: null,
    addedAt: 0,
    lastActiveAt: 0,
    needsReauth: false,
    tokenExpiresAt: null,
    pushRegisteredAt: null,
    pushUnavailable: false,
    apiVersionsMastodon: null,
    mastodonForkSoftware: null,
    accentVariant: 1,
    signedOut: false,
  }
}

type EmitModule = typeof import('src/lib/tapestry/emit')

function loadEmit(): EmitModule {
  // The module holds module-level state (flush timer, listener-attached
  // flags). Reset between tests so each scenario starts clean.
  let mod: EmitModule
  jest.isolateModules(() => {
    mod = require('src/lib/tapestry/emit') as EmitModule
  })
  return mod!
}

describe('tapestry emit', () => {
  beforeEach(() => {
    jest.useFakeTimers()
    mockState.storage.clear()
    mockState.postRaw.mockReset().mockResolvedValue(undefined as unknown as Response)
    mockState.activeAccount = makeAccount()
    mockState.allAccounts = [mockState.activeAccount]
    mockState.featuresEnabled = true
    mockState.appStateListener = null
    mockState.switchListener = null
  })

  afterEach(() => {
    jest.useRealTimers()
  })

  it('is a no-op when the active instance has no tapestry flag', () => {
    mockState.featuresEnabled = false
    const { emit } = loadEmit()
    emit('L')
    emit('R')
    expect(mockState.storage.has(QUEUE_KEY)).toBe(false)
    expect(mockState.postRaw).not.toHaveBeenCalled()
  })

  it('is a no-op when there is no active account', () => {
    mockState.activeAccount = null
    const { emit } = loadEmit()
    emit('L')
    expect(mockState.storage.has(QUEUE_KEY)).toBe(false)
    expect(mockState.postRaw).not.toHaveBeenCalled()
  })

  it('appends letters to the per-instance MMKV queue', () => {
    const { emit } = loadEmit()
    emit('L')
    emit('R')
    emit('B')
    expect(mockState.storage.get(QUEUE_KEY)).toBe('LRB')
    expect(mockState.postRaw).not.toHaveBeenCalled()
  })

  it('flushes immediately at 50 letters', () => {
    const { emit } = loadEmit()
    for (let i = 0; i < 49; i++) emit('L')
    expect(mockState.postRaw).not.toHaveBeenCalled()
    emit('L')
    expect(mockState.postRaw).toHaveBeenCalledTimes(1)
    expect(mockState.postRaw).toHaveBeenCalledWith('/api/v1/tapestry', 'L'.repeat(50))
    expect(mockState.storage.has(QUEUE_KEY)).toBe(false)
  })

  it('flushes after 5 s of no further emits', () => {
    const { emit } = loadEmit()
    emit('L')
    expect(mockState.postRaw).not.toHaveBeenCalled()
    jest.advanceTimersByTime(4_999)
    expect(mockState.postRaw).not.toHaveBeenCalled()
    jest.advanceTimersByTime(1)
    expect(mockState.postRaw).toHaveBeenCalledTimes(1)
    expect(mockState.postRaw).toHaveBeenCalledWith('/api/v1/tapestry', 'L')
    expect(mockState.storage.has(QUEUE_KEY)).toBe(false)
  })

  it('drops the queue on POST failure (no retry, no requeue)', async () => {
    mockState.postRaw.mockReset().mockRejectedValue(new Error('network down'))
    const { emit } = loadEmit()
    emit('L')
    jest.advanceTimersByTime(5_000)
    expect(mockState.postRaw).toHaveBeenCalledTimes(1)
    expect(mockState.storage.has(QUEUE_KEY)).toBe(false)
    // Let the rejected promise settle so the .catch(() => {}) inside flushQueue
    // resolves without surfacing as an unhandled rejection.
    await Promise.resolve()
    await Promise.resolve()
  })

  it('chunks bodies > 256 bytes into multiple POSTs', () => {
    // Pre-populate the queue with 600 letters — past the 256-byte server
    // cap and past two whole chunks. Trigger a flush via the AppState
    // background listener so we don't have to call emit() 600 times.
    mockState.storage.set(QUEUE_KEY, 'L'.repeat(600))
    const { attachAppStateListener } = loadEmit()
    attachAppStateListener()
    expect(mockState.appStateListener).not.toBeNull()
    mockState.appStateListener!('background')
    expect(mockState.postRaw).toHaveBeenCalledTimes(3)
    expect(mockState.postRaw.mock.calls[0]).toEqual([
      '/api/v1/tapestry',
      'L'.repeat(256),
    ])
    expect(mockState.postRaw.mock.calls[1]).toEqual([
      '/api/v1/tapestry',
      'L'.repeat(256),
    ])
    expect(mockState.postRaw.mock.calls[2]).toEqual([
      '/api/v1/tapestry',
      'L'.repeat(88),
    ])
    expect(mockState.storage.has(QUEUE_KEY)).toBe(false)
  })

  it('AppState background flush is a no-op when the active account is not tapestry-enabled', () => {
    mockState.featuresEnabled = false
    mockState.storage.set(QUEUE_KEY, 'L'.repeat(10))
    const { attachAppStateListener } = loadEmit()
    attachAppStateListener()
    mockState.appStateListener!('background')
    expect(mockState.postRaw).not.toHaveBeenCalled()
    // Queue is left in place — nothing was sent.
    expect(mockState.storage.get(QUEUE_KEY)).toBe('L'.repeat(10))
  })
})
