import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
import { I18nProvider } from '@/lib/i18n-context'
import {
    persistScratchlist,
    readScratchlist,
    type ScratchlistEntry,
} from '@/lib/scratchlist'
import { ScratchlistPanel } from './ScratchlistPanel'

const SID = 'session-test'

function renderPanel(props?: {
    onPromoteToComposer?: (text: string) => void
    onPromoteToQueue?: (text: string) => Promise<boolean>
    sessionId?: string
}) {
    const onPromoteToComposer = props?.onPromoteToComposer ?? vi.fn()
    const onPromoteToQueue = props?.onPromoteToQueue ?? vi.fn(async () => true)
    return {
        onPromoteToComposer,
        onPromoteToQueue,
        ...render(
            <I18nProvider>
                <ScratchlistPanel
                    sessionId={props?.sessionId ?? SID}
                    onPromoteToComposer={onPromoteToComposer}
                    onPromoteToQueue={onPromoteToQueue}
                />
            </I18nProvider>
        ),
    }
}

function makeEntry(overrides: Partial<ScratchlistEntry> & { id: string }): ScratchlistEntry {
    return {
        text: 'note',
        createdAt: 1000,
        ...overrides,
    }
}

function expandPanel(): void {
    fireEvent.click(screen.getByRole('button', { name: /Scratchlist/ }))
}

afterEach(() => {
    cleanup()
})

beforeEach(() => {
    localStorage.clear()
})

describe('ScratchlistPanel', () => {
    it('renders the held / not-sent label so users distinguish it from the queue', () => {
        renderPanel()
        // The held-label is rendered inside the toggle button as visual chrome
        // (aria-hidden) so use textContent rather than a name match.
        const toggle = screen.getByRole('button', { name: /Scratchlist/ })
        expect(toggle.textContent).toContain('held')
    })

    it('uses the chat user surface for the panel background and keeps a subtle amber border (regression guard for #812)', () => {
        // The amber chrome was too loud as an always-visible scroll element
        // (#812). The fix swaps the warning *fill* for the chat-user-surface
        // tone but keeps the warning *border* as a soft accent so the panel
        // still reads as a different destination from a normal user message.
        // The strong amber destination signal lives on the composer Send
        // button, not here. See PR 827 (swear01) for the styling note this
        // test guards.
        renderPanel()
        const panel = screen.getByTestId('scratchlist-panel')
        expect(panel.className).toContain('bg-[var(--app-chat-user-surface-bg)]')
        expect(panel.className).not.toContain('bg-[var(--app-badge-warning-bg)]')
        expect(panel.className).toContain('border-[var(--app-badge-warning-border)]')
    })

    it('starts collapsed by default; clicking the header expands it', () => {
        renderPanel()
        const toggle = screen.getByRole('button', { name: /Scratchlist/ })
        expect(toggle.getAttribute('aria-expanded')).toBe('false')
        expandPanel()
        expect(toggle.getAttribute('aria-expanded')).toBe('true')
    })

    it('marks the inner content `inert` while collapsed so hidden controls are not focusable', () => {
        // Regression guard: upstream PR review flagged that under the
        // CSS-only collapse the textarea + action buttons were still in
        // the focus / a11y tree. The fix is `inert` on the inner; this
        // test fails if anyone reverts that.
        const { container } = renderPanel()
        const inner = container.querySelector('.collapsible-inner')
        expect(inner).not.toBeNull()
        expect(inner!.hasAttribute('inert')).toBe(true)

        expandPanel()
        // jsdom doesn't always reflect the React `inert={false}` prop as
        // an attribute removal — accept either "absent" or empty string,
        // which both indicate non-inert per the HTML spec.
        const value = inner!.getAttribute('inert')
        expect(value === null || value === 'false' || value === '').toBe(true)
    })

    it('hydrates entries that were persisted before mount', () => {
        persistScratchlist(SID, [
            makeEntry({ id: 'persisted-1', text: 'persisted note' }),
        ])
        renderPanel()
        expandPanel()
        expect(screen.getByText('persisted note')).toBeTruthy()
    })

    it('adds a new entry via the add button and persists it', () => {
        renderPanel()
        expandPanel()
        const input = screen.getByLabelText('Add scratchlist entry') as HTMLTextAreaElement
        fireEvent.change(input, { target: { value: 'first thought' } })
        fireEvent.click(screen.getByRole('button', { name: 'Add' }))
        expect(screen.getByText('first thought')).toBeTruthy()
        const stored = readScratchlist(SID)
        expect(stored.map((e) => e.text)).toEqual(['first thought'])
    })

    it('adds a new entry on Enter; Shift+Enter does not add (preserves newline)', () => {
        renderPanel()
        expandPanel()
        const input = screen.getByLabelText('Add scratchlist entry') as HTMLTextAreaElement
        fireEvent.change(input, { target: { value: 'enter add' } })
        fireEvent.keyDown(input, { key: 'Enter' })
        expect(screen.getByText('enter add')).toBeTruthy()
        expect(readScratchlist(SID).map((e) => e.text)).toEqual(['enter add'])

        // Shift+Enter must not promote to a new entry (it falls through to
        // textarea default newline behavior); the stored list stays unchanged.
        fireEvent.change(input, { target: { value: 'with newline' } })
        fireEvent.keyDown(input, { key: 'Enter', shiftKey: true })
        expect(readScratchlist(SID).map((e) => e.text)).toEqual(['enter add'])
    })

    it('deletes an entry without a confirm prompt for short entries', () => {
        persistScratchlist(SID, [makeEntry({ id: 'a', text: 'short' })])
        const confirmSpy = vi.spyOn(window, 'confirm').mockReturnValue(true)
        renderPanel()
        expandPanel()
        fireEvent.click(screen.getByRole('button', { name: 'Delete entry' }))
        expect(confirmSpy).not.toHaveBeenCalled()
        expect(screen.queryByText('short')).toBeNull()
        expect(readScratchlist(SID)).toEqual([])
        confirmSpy.mockRestore()
    })

    it('asks for confirmation before deleting long entries (>100 chars)', () => {
        const longText = 'x'.repeat(150)
        persistScratchlist(SID, [makeEntry({ id: 'a', text: longText })])
        const confirmSpy = vi.spyOn(window, 'confirm').mockReturnValue(false)

        renderPanel()
        expandPanel()
        fireEvent.click(screen.getByRole('button', { name: 'Delete entry' }))

        expect(confirmSpy).toHaveBeenCalled()
        // Confirm rejected — entry stays.
        expect(readScratchlist(SID).map((e) => e.id)).toEqual(['a'])

        confirmSpy.mockReturnValue(true)
        fireEvent.click(screen.getByRole('button', { name: 'Delete entry' }))
        expect(readScratchlist(SID)).toEqual([])
        confirmSpy.mockRestore()
    })

    it('reorders entries via the up / down arrow buttons', () => {
        persistScratchlist(SID, [
            makeEntry({ id: 'top', text: 'top entry' }),
            makeEntry({ id: 'bot', text: 'bot entry' }),
        ])
        renderPanel()
        expandPanel()

        // First entry is at index 0 — its up-button should be disabled.
        const upButtons = screen.getAllByRole('button', { name: 'Move entry up' })
        const downButtons = screen.getAllByRole('button', { name: 'Move entry down' })
        expect(upButtons[0]?.hasAttribute('disabled')).toBe(true)
        expect(downButtons[downButtons.length - 1]?.hasAttribute('disabled')).toBe(true)

        // Move bottom row up -> swaps order.
        fireEvent.click(upButtons[1] as HTMLButtonElement)
        const stored = readScratchlist(SID)
        expect(stored.map((e) => e.id)).toEqual(['bot', 'top'])
    })

    it('promote-to-composer copies text via the callback and keeps the entry', () => {
        persistScratchlist(SID, [makeEntry({ id: 'a', text: 'compose me' })])
        const onPromoteToComposer = vi.fn()
        renderPanel({ onPromoteToComposer })
        expandPanel()
        fireEvent.click(screen.getByRole('button', { name: 'Copy into composer' }))
        expect(onPromoteToComposer).toHaveBeenCalledWith('compose me')
        // Entry remains: promote-to-composer is a copy, not a move.
        expect(readScratchlist(SID).map((e) => e.id)).toEqual(['a'])
    })

    it('promote-to-queue calls onSend and removes the entry on accepted send', async () => {
        persistScratchlist(SID, [makeEntry({ id: 'a', text: 'queue me' })])
        const onPromoteToQueue = vi.fn(async () => true)
        renderPanel({ onPromoteToQueue })
        expandPanel()
        fireEvent.click(screen.getByRole('button', { name: 'Send to queue' }))
        await waitFor(() => expect(onPromoteToQueue).toHaveBeenCalledWith('queue me'))
        await waitFor(() => expect(screen.queryByText('queue me')).toBeNull())
        expect(readScratchlist(SID)).toEqual([])
    })

    it('promote-to-queue keeps the entry when the send is rejected', async () => {
        persistScratchlist(SID, [makeEntry({ id: 'a', text: 'queue me' })])
        const onPromoteToQueue = vi.fn(async () => false)
        renderPanel({ onPromoteToQueue })
        expandPanel()
        fireEvent.click(screen.getByRole('button', { name: 'Send to queue' }))
        await waitFor(() => expect(onPromoteToQueue).toHaveBeenCalledWith('queue me'))
        // Entry remains because the queue rejected the promotion.
        expect(screen.getByText('queue me')).toBeTruthy()
        expect(readScratchlist(SID).map((e) => e.id)).toEqual(['a'])
    })

    it('copy button writes the entry text to clipboard and shows briefly the "Copied" tooltip', async () => {
        // Clipboard API isn't implemented in jsdom; install a mock that
        // captures the writeText call. (web/src/lib/clipboard.ts already
        // tries navigator.clipboard first, then falls back to execCommand.)
        const writeText = vi.fn().mockResolvedValue(undefined)
        Object.defineProperty(navigator, 'clipboard', {
            value: { writeText },
            configurable: true,
        })

        persistScratchlist(SID, [makeEntry({ id: 'a', text: 'copy me' })])
        renderPanel()
        expandPanel()

        const copyBtn = screen.getByRole('button', { name: 'Copy text to clipboard (not images)' })
        fireEvent.click(copyBtn)

        await waitFor(() => expect(writeText).toHaveBeenCalledWith('copy me'))

        // After the async copy resolves, the same button (it stays in the
        // DOM, only its label/icon flip) should advertise the success.
        await waitFor(() =>
            expect(screen.getByRole('button', { name: 'Copied!' })).toBeTruthy(),
        )
        // Entry is preserved — copy is non-destructive.
        expect(readScratchlist(SID).map((e) => e.id)).toEqual(['a'])
    })

    it('clipboard write failure leaves the icon in the default state (no false success)', async () => {
        // Force navigator.clipboard.writeText to reject AND make the
        // execCommand fallback fail too, so safeCopyToClipboard throws.
        const writeText = vi.fn().mockRejectedValue(new Error('denied'))
        Object.defineProperty(navigator, 'clipboard', {
            value: { writeText },
            configurable: true,
        })
        // jsdom doesn't implement document.execCommand. Define a stub
        // that returns false so safeCopyToClipboard's fallback path
        // also fails (covering the "everything failed" branch).
        Object.defineProperty(document, 'execCommand', {
            value: () => false,
            configurable: true,
            writable: true,
        })

        persistScratchlist(SID, [makeEntry({ id: 'a', text: 'try copy' })])
        renderPanel()
        expandPanel()

        fireEvent.click(screen.getByRole('button', { name: 'Copy text to clipboard (not images)' }))

        await waitFor(() => expect(writeText).toHaveBeenCalled())
        // Should NOT flip to "Copied!" because the copy failed.
        expect(screen.queryByRole('button', { name: 'Copied!' })).toBeNull()
        expect(screen.getByRole('button', { name: 'Copy text to clipboard (not images)' })).toBeTruthy()
    })

    it('persists collapse state across mounts for the same session', () => {
        const { unmount } = renderPanel()
        expandPanel()
        unmount()

        // Re-mount with the same session id; should remain expanded.
        const second = renderPanel()
        const toggle = second.getByRole('button', { name: /Scratchlist/ })
        expect(toggle.getAttribute('aria-expanded')).toBe('true')
    })

    it('isolates entries between sessions', () => {
        persistScratchlist('session-A', [makeEntry({ id: 'a1', text: 'A note' })])
        persistScratchlist('session-B', [makeEntry({ id: 'b1', text: 'B note' })])

        const a = renderPanel({ sessionId: 'session-A' })
        fireEvent.click(a.getByRole('button', { name: /Scratchlist/ }))
        expect(a.getByText('A note')).toBeTruthy()
        expect(a.queryByText('B note')).toBeNull()
        a.unmount()

        const b = renderPanel({ sessionId: 'session-B' })
        fireEvent.click(b.getByRole('button', { name: /Scratchlist/ }))
        expect(b.getByText('B note')).toBeTruthy()
        expect(b.queryByText('A note')).toBeNull()
    })

    it('renders the per-entry age indicator with a tooltip showing the smart-relative time', () => {
        // 5 minutes ago, deterministic via fake timers below.
        vi.useFakeTimers()
        const now = new Date('2026-06-13T17:00:00Z').getTime()
        vi.setSystemTime(new Date(now))
        try {
            persistScratchlist(SID, [
                makeEntry({
                    id: 'aged',
                    text: 'aged note',
                    createdAt: now - 10 * 60_000,
                    updatedAt: now - 5 * 60_000,
                }),
            ])
            renderPanel()
            expandPanel()
            const indicator = screen.getByTestId('scratchlist-entry-age')
            // Tooltip carries the smart-relative time + an absolute
            // timestamp; aria-label carries the relative time only.
            const title = indicator.getAttribute('title') ?? ''
            expect(title).toContain('5m ago')
            expect(title).toContain('Saved')
            const aria = indicator.getAttribute('aria-label') ?? ''
            expect(aria).toContain('5m ago')
            // data-entry-age mirrors the relative bucket so a future
            // assertion can target it without scraping the title.
            expect(indicator.getAttribute('data-entry-age')).toBe('5m ago')
        } finally {
            vi.useRealTimers()
        }
    })

    it('falls back to createdAt when updatedAt is absent (legacy v1 row)', () => {
        vi.useFakeTimers()
        const now = new Date('2026-06-13T17:00:00Z').getTime()
        vi.setSystemTime(new Date(now))
        try {
            persistScratchlist(SID, [
                makeEntry({
                    id: 'legacy',
                    text: 'legacy v1 note',
                    createdAt: now - 2 * 60 * 60_000,
                    // updatedAt deliberately omitted - simulates a
                    // localStorage row written by v1 before the v2 hub
                    // sync work added the column.
                }),
            ])
            renderPanel()
            expandPanel()
            const indicator = screen.getByTestId('scratchlist-entry-age')
            expect(indicator.getAttribute('data-entry-age')).toBe('2h ago')
        } finally {
            vi.useRealTimers()
        }
    })

    it('renders no age indicator when both timestamps are unusable', () => {
        // The schema validator (`isEntry`) rejects rows with a
        // non-finite `createdAt`, so the only realistic path to a
        // missing-stamp entry is rendering an in-memory entry directly.
        // We simulate that by writing a row with a sentinel `0`
        // createdAt - the indicator returns null per its guard.
        persistScratchlist(SID, [makeEntry({ id: 'no-stamp', text: 'note', createdAt: 0 })])
        renderPanel()
        expandPanel()
        expect(screen.queryByTestId('scratchlist-entry-age')).toBeNull()
    })
})

describe('ScratchlistDrawer disabled operations', () => {
    it('disables and synchronously ignores move, delete, and promote actions while the parent send is pending', async () => {
        const { ScratchlistDrawer } = await import('./ScratchlistPanel')
        const entry = makeEntry({ id: 'pending-entry', text: 'held message' })
        const onMove = vi.fn()
        const onDelete = vi.fn()
        const onPromoteToComposer = vi.fn()
        const onPromoteToQueue = vi.fn(async () => true)

        render(
            <I18nProvider>
                <ScratchlistDrawer
                    entries={[entry]}
                    sessionId={SID}
                    api={{} as never}
                    disabled
                    onMove={onMove}
                    onDelete={onDelete}
                    onPromoteToComposer={onPromoteToComposer}
                    onPromoteToQueue={onPromoteToQueue}
                />
            </I18nProvider>,
        )

        const mutationButtons = [
            ...screen.getAllByRole('button', { name: 'Move entry up' }),
            ...screen.getAllByRole('button', { name: 'Move entry down' }),
            screen.getByRole('button', { name: 'Copy into composer' }),
            screen.getByRole('button', { name: 'Send to queue' }),
            screen.getByRole('button', { name: 'Delete entry' }),
        ]
        for (const button of mutationButtons) {
            expect(button).toBeDisabled()
            fireEvent.click(button)
        }
        // Copy is read-only and remains available while a chat send is pending.
        expect(screen.getByRole('button', { name: 'Copy text to clipboard (not images)' })).not.toBeDisabled()

        await Promise.resolve()
        expect(onMove).not.toHaveBeenCalled()
        expect(onDelete).not.toHaveBeenCalled()
        expect(onPromoteToComposer).not.toHaveBeenCalled()
        expect(onPromoteToQueue).not.toHaveBeenCalled()
    })
})
