// @vitest-environment happy-dom
import { describe, expect, it, beforeEach, vi } from 'vitest'
import React from 'react'
import { act, fireEvent, render, screen, waitFor } from '@testing-library/react'

import { CommentSidePanel, type CommentsApi } from '../../components/CommentSidePanel.js'
import type { Comment, CommentOutcome } from '../../lib/comments-client.js'

function ok<T>(value: T): CommentOutcome<T> {
  return { ok: true, result: value }
}

function err(error = 'boom', status = 500): CommentOutcome<never> {
  return { ok: false, error, status }
}

function comment(overrides: Partial<Comment> = {}): Comment {
  return {
    id: 'c-1',
    documentId: 'doc-1',
    parentId: null,
    userId: 'user-1',
    body: 'Hello there',
    anchor: null,
    resolvedAt: null,
    resolvedById: null,
    deletedAt: null,
    createdAt: '2026-01-01T00:00:00.000Z',
    updatedAt: '2026-01-01T00:00:00.000Z',
    isResolved: false,
    ...overrides,
  }
}

/**
 * Build a fresh in-memory CommentsApi stub for each test. We expose
 * the `list` payload as `state.rows` so tests can poke server state
 * directly and verify the panel re-fetches after every mutation.
 */
function createFakeApi() {
  const rows: Comment[] = []
  const calls: { name: string; args: unknown[] }[] = []

  const stub: CommentsApi = {
    list: vi.fn(async (_documentId: string, opts?: { includeResolved?: boolean }) => {
      calls.push({ name: 'list', args: [opts] })
      const visible = rows.filter(
        (c) => (opts?.includeResolved ? true : !c.isResolved) && c.deletedAt === null,
      )
      return ok(visible)
    }),
    create: vi.fn(async (input) => {
      calls.push({ name: 'create', args: [input] })
      const created = comment({
        id: `c-${rows.length + 1}`,
        body: input.body,
        anchor: input.anchor ?? null,
        parentId: input.parentId ?? null,
        userId: 'current',
      })
      rows.push(created)
      return ok(created)
    }),
    update: vi.fn(async (id, body) => {
      calls.push({ name: 'update', args: [id, body] })
      const row = rows.find((r) => r.id === id)
      if (!row) return err('not found', 404)
      row.body = body
      return ok(row)
    }),
    resolve: vi.fn(async (id) => {
      calls.push({ name: 'resolve', args: [id] })
      const row = rows.find((r) => r.id === id)
      if (!row) return err('not found', 404)
      row.isResolved = true
      row.resolvedAt = '2026-01-02T00:00:00.000Z'
      return ok(row)
    }),
    reopen: vi.fn(async (id) => {
      calls.push({ name: 'reopen', args: [id] })
      const row = rows.find((r) => r.id === id)
      if (!row) return err('not found', 404)
      row.isResolved = false
      row.resolvedAt = null
      return ok(row)
    }),
    remove: vi.fn(async (id) => {
      calls.push({ name: 'remove', args: [id] })
      const row = rows.find((r) => r.id === id)
      if (!row) return err('not found', 404)
      row.deletedAt = '2026-01-02T00:00:00.000Z'
      return ok(row)
    }),
  }

  return { api: stub, rows, calls }
}

beforeEach(() => {
  vi.clearAllMocks()
})

describe('CommentSidePanel — loading & list', () => {
  it('renders an empty state once the list call resolves', async () => {
    const { api } = createFakeApi()
    render(<CommentSidePanel documentId="doc-1" currentUserId="current" api={api} />)
    expect(screen.getByText('Loading…')).toBeTruthy()
    await waitFor(() => expect(screen.getByText('No comments yet.')).toBeTruthy())
    expect(api.list).toHaveBeenCalledWith('doc-1', { includeResolved: false })
  })

  it('groups roots + replies into a single card', async () => {
    const { api, rows } = createFakeApi()
    rows.push(comment({ id: 'root', body: 'root' }))
    rows.push(
      comment({
        id: 'reply',
        parentId: 'root',
        body: 'reply',
        createdAt: '2026-01-01T00:00:01.000Z',
      }),
    )
    render(<CommentSidePanel documentId="doc-1" currentUserId="user-1" api={api} />)
    await waitFor(() => expect(screen.getByTestId('comment-thread-root')).toBeTruthy())
    expect(screen.getByTestId('comment-body-root').textContent).toBe('root')
    expect(screen.getByTestId('comment-body-reply').textContent).toBe('reply')
  })

  it('hides resolved threads until the toggle is flipped', async () => {
    const { api, rows } = createFakeApi()
    rows.push(comment({ id: 'open', body: 'open' }))
    rows.push(
      comment({
        id: 'closed',
        body: 'closed',
        isResolved: true,
        resolvedAt: '2026-01-02T00:00:00.000Z',
      }),
    )
    render(<CommentSidePanel documentId="doc-1" currentUserId="user-1" api={api} />)
    await waitFor(() => expect(screen.getByTestId('comment-thread-open')).toBeTruthy())
    expect(screen.queryByTestId('comment-thread-closed')).toBeNull()

    fireEvent.click(screen.getByTestId('include-resolved-toggle'))
    await waitFor(() => expect(screen.getByTestId('comment-thread-closed')).toBeTruthy())
    // Most-recent list call should have flipped `includeResolved`.
    expect(api.list).toHaveBeenLastCalledWith('doc-1', { includeResolved: true })
  })

  it('surfaces a server failure through onError', async () => {
    const { api } = createFakeApi()
    ;(api.list as ReturnType<typeof vi.fn>).mockResolvedValueOnce(err('CSRF', 403))
    const onError = vi.fn()
    render(
      <CommentSidePanel documentId="doc-1" currentUserId="user-1" api={api} onError={onError} />,
    )
    await waitFor(() => expect(onError).toHaveBeenCalledWith('CSRF'))
  })
})

describe('CommentSidePanel — compose', () => {
  it('disables the submit button while the draft is empty', async () => {
    const { api } = createFakeApi()
    render(<CommentSidePanel documentId="doc-1" currentUserId="user-1" api={api} />)
    await waitFor(() => expect(screen.getByText('No comments yet.')).toBeTruthy())
    const btn = screen.getByTestId('comment-submit') as HTMLButtonElement
    expect(btn.disabled).toBe(true)
    fireEvent.change(screen.getByTestId('comment-composer'), {
      target: { value: 'hello' },
    })
    expect((screen.getByTestId('comment-submit') as HTMLButtonElement).disabled).toBe(false)
  })

  it('posts a new comment, clears the draft, and refreshes', async () => {
    const { api, calls } = createFakeApi()
    render(<CommentSidePanel documentId="doc-1" currentUserId="user-1" api={api} />)
    await waitFor(() => expect(screen.getByText('No comments yet.')).toBeTruthy())

    fireEvent.change(screen.getByTestId('comment-composer'), {
      target: { value: '  fresh thought  ' },
    })
    await act(async () => {
      fireEvent.click(screen.getByTestId('comment-submit'))
    })

    expect(calls.some((c) => c.name === 'create')).toBe(true)
    const createCall = calls.find((c) => c.name === 'create')!
    expect((createCall.args[0] as { body: string }).body).toBe('fresh thought')
    expect((screen.getByTestId('comment-composer') as HTMLTextAreaElement).value).toBe('')
    expect(calls.filter((c) => c.name === 'list').length).toBeGreaterThanOrEqual(2)
  })

  it('forwards onComposeAnchor result into the create call', async () => {
    const { api, calls } = createFakeApi()
    const onComposeAnchor = vi.fn().mockReturnValue({ from: 'AQ==', to: 'Ag==' })
    render(
      <CommentSidePanel
        documentId="doc-1"
        currentUserId="user-1"
        api={api}
        onComposeAnchor={onComposeAnchor}
      />,
    )
    await waitFor(() => expect(screen.getByText('No comments yet.')).toBeTruthy())
    fireEvent.change(screen.getByTestId('comment-composer'), {
      target: { value: 'anchored' },
    })
    await act(async () => {
      fireEvent.click(screen.getByTestId('comment-submit'))
    })

    expect(onComposeAnchor).toHaveBeenCalledTimes(1)
    const createCall = calls.find((c) => c.name === 'create')!
    expect((createCall.args[0] as { anchor: unknown }).anchor).toEqual({
      from: 'AQ==',
      to: 'Ag==',
    })
  })
})

describe('CommentSidePanel — reply, edit, resolve, delete', () => {
  async function setupWith(rowsInit: Comment[]) {
    const fake = createFakeApi()
    for (const row of rowsInit) fake.rows.push(row)
    const renderResult = render(
      <CommentSidePanel documentId="doc-1" currentUserId="user-1" isAdmin={false} api={fake.api} />,
    )
    await waitFor(() =>
      expect(screen.getByTestId(`comment-thread-${rowsInit[0]!.id}`)).toBeTruthy(),
    )
    return { ...fake, ...renderResult }
  }

  it('replies through the thread input + clears it on submit', async () => {
    const { api, calls } = await setupWith([
      comment({ id: 'root', userId: 'user-1', body: 'root' }),
    ])
    fireEvent.change(screen.getByTestId('reply-input-root'), {
      target: { value: 'hi back' },
    })
    await act(async () => {
      fireEvent.click(screen.getByTestId('reply-submit-root'))
    })

    const replyCall = calls.find(
      (c) => c.name === 'create' && (c.args[0] as { parentId?: string | null }).parentId === 'root',
    )
    expect(replyCall).toBeDefined()
    expect((screen.getByTestId('reply-input-root') as HTMLInputElement).value).toBe('')
    expect(api.list).toHaveBeenCalled()
  })

  it('shows edit + delete only on comments the user owns (non-admin)', async () => {
    await setupWith([
      comment({ id: 'mine', userId: 'user-1', body: 'mine' }),
      comment({
        id: 'theirs',
        userId: 'someone-else',
        body: 'theirs',
        createdAt: '2026-01-01T00:00:01.000Z',
      }),
    ])
    expect(screen.queryByTestId('edit-mine')).toBeTruthy()
    expect(screen.queryByTestId('delete-mine')).toBeTruthy()
    expect(screen.queryByTestId('edit-theirs')).toBeNull()
    expect(screen.queryByTestId('delete-theirs')).toBeNull()
  })

  it('admins see edit + delete on every comment', async () => {
    const fake = createFakeApi()
    fake.rows.push(comment({ id: 'theirs', userId: 'someone-else' }))
    render(<CommentSidePanel documentId="doc-1" currentUserId="user-1" isAdmin api={fake.api} />)
    await waitFor(() => expect(screen.getByTestId('comment-thread-theirs')).toBeTruthy())
    expect(screen.getByTestId('edit-theirs')).toBeTruthy()
    expect(screen.getByTestId('delete-theirs')).toBeTruthy()
  })

  it('edits a comment body in place', async () => {
    const { api } = await setupWith([comment({ id: 'c1', userId: 'user-1', body: 'before' })])
    fireEvent.click(screen.getByTestId('edit-c1'))
    fireEvent.change(screen.getByTestId('edit-textarea-c1'), {
      target: { value: 'after' },
    })
    await act(async () => {
      fireEvent.click(screen.getByTestId('edit-save-c1'))
    })
    expect(api.update).toHaveBeenCalledWith('c1', 'after')
    await waitFor(() => expect(screen.getByTestId('comment-body-c1').textContent).toBe('after'))
  })

  it('cancels an edit without writing', async () => {
    const { api } = await setupWith([comment({ id: 'c1', userId: 'user-1', body: 'before' })])
    fireEvent.click(screen.getByTestId('edit-c1'))
    fireEvent.change(screen.getByTestId('edit-textarea-c1'), {
      target: { value: 'never saved' },
    })
    fireEvent.click(screen.getByTestId('edit-cancel-c1'))
    expect(api.update).not.toHaveBeenCalled()
    expect(screen.getByTestId('comment-body-c1').textContent).toBe('before')
  })

  it('resolves an open thread and reopens a closed one', async () => {
    const { api, rows } = await setupWith([comment({ id: 'open', userId: 'user-1', body: 'open' })])
    await act(async () => {
      fireEvent.click(screen.getByTestId('resolve-open'))
    })
    expect(api.resolve).toHaveBeenCalledWith('open')
    // Flip the toggle so resolved comments stay in the list, then reopen.
    fireEvent.click(screen.getByTestId('include-resolved-toggle'))
    await waitFor(() => expect(screen.getByTestId('resolve-open')).toBeTruthy())
    await act(async () => {
      fireEvent.click(screen.getByTestId('resolve-open'))
    })
    expect(api.reopen).toHaveBeenCalledWith('open')
    expect(rows[0]!.isResolved).toBe(false)
  })

  it('deletes a comment and refreshes', async () => {
    const { api } = await setupWith([comment({ id: 'c1', userId: 'user-1' })])
    await act(async () => {
      fireEvent.click(screen.getByTestId('delete-c1'))
    })
    expect(api.remove).toHaveBeenCalledWith('c1')
    await waitFor(() => expect(screen.queryByTestId('comment-thread-c1')).toBeNull())
  })

  it('notifies onActiveCommentChange when a thread is clicked', async () => {
    const fake = createFakeApi()
    fake.rows.push(comment({ id: 'c1', userId: 'user-1' }))
    const onActive = vi.fn()
    render(
      <CommentSidePanel
        documentId="doc-1"
        currentUserId="user-1"
        api={fake.api}
        onActiveCommentChange={onActive}
      />,
    )
    await waitFor(() => expect(screen.getByTestId('comment-thread-c1')).toBeTruthy())
    fireEvent.click(screen.getByTestId('comment-thread-c1'))
    expect(onActive).toHaveBeenCalledWith('c1')
  })
})

// ---------------------------------------------------------------------------
// Mention picker integration — Phase 7 carry-over
// ---------------------------------------------------------------------------

describe('CommentSidePanel — mention picker integration', () => {
  /**
   * Drive a `<textarea>` the same way the picker test does. The
   * comment panel's three composer surfaces (new thread, reply,
   * edit) are all `<MentionableTextarea>` now, so this helper works
   * uniformly across them.
   */
  async function typeAt(el: HTMLTextAreaElement, value: string, caret = value.length) {
    await act(async () => {
      el.focus()
      const setter = Object.getOwnPropertyDescriptor(HTMLTextAreaElement.prototype, 'value')?.set
      if (setter) setter.call(el, value)
      else el.value = value
      el.setSelectionRange(caret, caret)
      el.dispatchEvent(new Event('input', { bubbles: true }))
    })
  }

  function fakeMentionSearch() {
    return vi.fn(async (q: string) => {
      const all = [
        { id: 'u_alice', name: 'Alice Anderson', email: 'alice@example.com' },
        { id: 'u_bob', name: 'Bob Brown', email: 'bob@example.com' },
      ]
      const ql = q.toLowerCase()
      return all.filter(
        (u) => u.name.toLowerCase().includes(ql) || u.email.toLowerCase().includes(ql),
      )
    })
  }

  it('opens the mention picker from the new-thread composer and submits @<userId>', async () => {
    const fake = createFakeApi()
    const mentionSearch = fakeMentionSearch()
    render(
      <CommentSidePanel
        documentId="doc-1"
        currentUserId="current"
        api={fake.api}
        mentionSearch={mentionSearch}
      />,
    )
    await waitFor(() => expect(screen.getByText('No comments yet.')).toBeTruthy())

    const composer = screen.getByTestId('comment-composer') as HTMLTextAreaElement
    await typeAt(composer, '@ali')
    await waitFor(() => expect(screen.getByTestId('comment-composer-picker')).toBeTruthy())
    await act(async () => {
      fireEvent.mouseDown(screen.getByTestId('comment-composer-picker-row-u_alice'))
    })
    expect((composer as HTMLTextAreaElement).value).toBe('@u_alice ')

    // Continue typing then submit — the resulting comment body must
    // carry the `@<userId>` token so `extractMentionedUserIds` picks
    // it up server-side.
    await typeAt(composer, '@u_alice please review')
    await act(async () => {
      fireEvent.click(screen.getByTestId('comment-submit'))
    })
    expect(fake.api.create).toHaveBeenCalledWith(
      expect.objectContaining({ body: '@u_alice please review' }),
    )
  })

  it('opens the mention picker from a reply input and inserts @<userId>', async () => {
    const fake = createFakeApi()
    fake.rows.push(comment({ id: 'root', body: 'root' }))
    const mentionSearch = fakeMentionSearch()
    render(
      <CommentSidePanel
        documentId="doc-1"
        currentUserId="current"
        api={fake.api}
        mentionSearch={mentionSearch}
      />,
    )
    await waitFor(() => expect(screen.getByTestId('comment-thread-root')).toBeTruthy())

    const reply = screen.getByTestId('reply-input-root') as HTMLTextAreaElement
    await typeAt(reply, '@bo')
    await waitFor(() => expect(screen.getByTestId('reply-input-root-picker')).toBeTruthy())
    fireEvent.keyDown(reply, { key: 'Enter' })
    await waitFor(() => expect(reply.value).toBe('@u_bob '))
  })

  it('opens the mention picker from the edit-textarea while editing a comment', async () => {
    const fake = createFakeApi()
    fake.rows.push(comment({ id: 'c1', userId: 'current', body: 'before' }))
    const mentionSearch = fakeMentionSearch()
    render(
      <CommentSidePanel
        documentId="doc-1"
        currentUserId="current"
        api={fake.api}
        mentionSearch={mentionSearch}
      />,
    )
    await waitFor(() => expect(screen.getByTestId('comment-thread-c1')).toBeTruthy())
    fireEvent.click(screen.getByTestId('edit-c1'))
    const edit = screen.getByTestId('edit-textarea-c1') as HTMLTextAreaElement
    await typeAt(edit, '@al')
    await waitFor(() => expect(screen.getByTestId('edit-textarea-c1-picker')).toBeTruthy())
    await act(async () => {
      fireEvent.mouseDown(screen.getByTestId('edit-textarea-c1-picker-row-u_alice'))
    })
    expect(edit.value).toBe('@u_alice ')
  })

  it('escape closes the picker without committing a selection', async () => {
    const fake = createFakeApi()
    const mentionSearch = fakeMentionSearch()
    render(
      <CommentSidePanel
        documentId="doc-1"
        currentUserId="current"
        api={fake.api}
        mentionSearch={mentionSearch}
      />,
    )
    await waitFor(() => expect(screen.getByText('No comments yet.')).toBeTruthy())

    const composer = screen.getByTestId('comment-composer') as HTMLTextAreaElement
    await typeAt(composer, '@al')
    await waitFor(() => expect(screen.getByTestId('comment-composer-picker')).toBeTruthy())
    fireEvent.keyDown(composer, { key: 'Escape' })
    expect(screen.queryByTestId('comment-composer-picker')).toBeNull()
    expect((composer as HTMLTextAreaElement).value).toBe('@al')
  })
})
