import { describe, it, expect, vi, beforeEach } from 'vitest'
import { render, screen, fireEvent, waitFor } from '@testing-library/react'
import { PulseView } from './PulseView'
import type { PulseCommit } from '../types'

const openExternalUrl = vi.fn()
const mockCommits: PulseCommit[] = [
  {
    hash: 'abc123def456',
    shortHash: 'abc123d',
    message: 'Update project notes',
    date: Math.floor(Date.now() / 1000) - 3600,
    githubUrl: 'https://github.com/owner/repo/commit/abc123def456',
    files: [
      { path: 'project/my-project.md', status: 'modified', title: 'my project' },
      { path: 'note/new-note.md', status: 'added', title: 'new note' },
    ],
    added: 1,
    modified: 1,
    deleted: 0,
  },
  {
    hash: 'def456abc789',
    shortHash: 'def456a',
    message: 'Remove old notes',
    date: Math.floor(Date.now() / 1000) - 86400,
    githubUrl: null,
    files: [
      { path: 'note/old.md', status: 'deleted', title: 'old' },
    ],
    added: 0,
    modified: 0,
    deleted: 1,
  },
]

const mockInvokeFn = vi.fn()
const dragRegionMouseDown = vi.fn()
const MAC_USER_AGENT = 'Mozilla/5.0 (Macintosh; Intel Mac OS X 14_7) AppleWebKit/605.1.15 Safari/605.1.15'
const WINDOWS_USER_AGENT = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 Chrome/125.0.0.0 Safari/537.36'

async function withUserAgent<T>(userAgent: string, callback: () => T | Promise<T>): Promise<T> {
  const originalUserAgent = navigator.userAgent
  Object.defineProperty(window.navigator, 'userAgent', { value: userAgent, configurable: true })
  try {
    return await callback()
  } finally {
    Object.defineProperty(window.navigator, 'userAgent', { value: originalUserAgent, configurable: true })
  }
}

vi.mock('@tauri-apps/api/core', () => ({
  invoke: (...args: unknown[]) => mockInvokeFn(...args),
}))
vi.mock('../mock-tauri', () => ({
  isTauri: () => false,
  mockInvoke: (...args: unknown[]) => mockInvokeFn(...args),
}))
vi.mock('../hooks/useDragRegion', () => ({
  useDragRegion: () => ({ onMouseDown: dragRegionMouseDown }),
}))
vi.mock('../utils/url', () => ({
  openExternalUrl: (...args: unknown[]) => openExternalUrl(...args),
}))

describe('PulseView', () => {
  beforeEach(() => {
    vi.clearAllMocks()
    dragRegionMouseDown.mockClear()
  })

  it('shows loading state initially', () => {
    mockInvokeFn.mockReturnValue(new Promise(() => {})) // never resolves
    render(<PulseView vaultPath="/test/vault" />)
    expect(screen.getByText('Loading activity…')).toBeInTheDocument()
  })

  it('renders commits grouped by day', async () => {
    mockInvokeFn.mockResolvedValue(mockCommits)

    render(<PulseView vaultPath="/test/vault" />)

    await waitFor(() => {
      expect(screen.getByText('Update project notes')).toBeInTheDocument()
    })

    expect(screen.getByText('Remove old notes')).toBeInTheDocument()
  })

  it('reloads commits when the refresh key changes after sync', async () => {
    mockInvokeFn
      .mockResolvedValueOnce([mockCommits[1]])
      .mockResolvedValueOnce([{ ...mockCommits[0], hash: 'fresh', message: 'Fresh remote commit' }])

    const { rerender } = render(<PulseView vaultPath="/test/vault" refreshKey={0} />)

    expect(await screen.findByText('Remove old notes')).toBeInTheDocument()

    rerender(<PulseView vaultPath="/test/vault" refreshKey={1} />)

    expect(await screen.findByText('Fresh remote commit')).toBeInTheDocument()
    expect(mockInvokeFn).toHaveBeenCalledTimes(2)
    expect(mockInvokeFn).toHaveBeenLastCalledWith('get_vault_pulse', { vaultPath: '/test/vault', limit: 20, skip: 0 })
  })

  it('shows summary badges for added/modified/deleted', async () => {
    mockInvokeFn.mockResolvedValue(mockCommits)

    render(<PulseView vaultPath="/test/vault" />)

    await waitFor(() => {
      expect(screen.getByText('+1')).toBeInTheDocument()
    })
    expect(screen.getByText('~1')).toBeInTheDocument()
    expect(screen.getByText('-1')).toBeInTheDocument()
  })

  it('shows commit hashes', async () => {
    mockInvokeFn.mockResolvedValue(mockCommits)

    render(<PulseView vaultPath="/test/vault" />)

    await waitFor(() => {
      expect(screen.getByText('abc123d')).toBeInTheDocument()
    })
    expect(screen.getByText('def456a')).toBeInTheDocument()
  })

  it('renders GitHub links for commits with githubUrl', async () => {
    mockInvokeFn.mockResolvedValue(mockCommits)

    render(<PulseView vaultPath="/test/vault" />)

    await waitFor(() => {
      const link = screen.getByText('abc123d')
      expect(link.tagName).toBe('A')
      expect(link).toHaveAttribute('href', 'https://github.com/owner/repo/commit/abc123def456')
    })

    // Non-GitHub commit should be a span
    const nonLink = screen.getByText('def456a')
    expect(nonLink.tagName).toBe('SPAN')
  })

  it('opens commit links through the shared external URL opener', async () => {
    mockInvokeFn.mockResolvedValue(mockCommits)

    render(<PulseView vaultPath="/test/vault" />)

    const link = await screen.findByText('abc123d')
    fireEvent.click(link)

    expect(openExternalUrl).toHaveBeenCalledWith('https://github.com/owner/repo/commit/abc123def456')
  })

  it('renders file list with correct titles when expanded', async () => {
    mockInvokeFn.mockResolvedValue(mockCommits)

    render(<PulseView vaultPath="/test/vault" />)

    // Files are collapsed by default — expand all commit cards first
    await waitFor(() => {
      expect(screen.getAllByLabelText('Expand files').length).toBeGreaterThan(0)
    })
    screen.getAllByLabelText('Expand files').forEach((btn) => {
      fireEvent.click(btn)
    })

    await waitFor(() => {
      expect(screen.getByText('my project')).toBeInTheDocument()
    })
    expect(screen.getByText('new note')).toBeInTheDocument()
    expect(screen.getByText('old')).toBeInTheDocument()
  })

  it.each([
    {
      label: 'a non-deleted file',
      rowLabels: ['Update project notes'],
      fileText: 'my project',
      expected: ['project/my-project.md', 'abc123def456'],
    },
    {
      label: 'a deleted file to open commit diff',
      rowLabels: ['Update project notes', 'Remove old notes'],
      fileText: 'old',
      expected: ['note/old.md', 'def456abc789'],
    },
  ])('calls onOpenNote when clicking $label', async ({ rowLabels, fileText, expected }) => {
    mockInvokeFn.mockResolvedValue(mockCommits)
    const onOpenNote = vi.fn()

    render(<PulseView vaultPath="/test/vault" onOpenNote={onOpenNote} />)

    await waitFor(() => {
      expect(screen.getAllByLabelText('Expand files').length).toBeGreaterThan(0)
    })
    rowLabels.forEach((rowLabel) => {
      fireEvent.click(screen.getByText(rowLabel))
    })

    await waitFor(() => {
      expect(screen.getByText(fileText)).toBeInTheDocument()
    })

    fireEvent.click(screen.getByText(fileText))
    expect(onOpenNote).toHaveBeenCalledWith(...expected)
  })

  it('shows empty state when no commits', async () => {
    mockInvokeFn.mockResolvedValue([])

    render(<PulseView vaultPath="/test/vault" />)

    await waitFor(() => {
      expect(screen.getByText('No activity yet')).toBeInTheDocument()
    })
  })

  it('shows error state and retry button', async () => {
    mockInvokeFn.mockRejectedValue('Not a git repository')

    render(<PulseView vaultPath="/test/vault" />)

    await waitFor(() => {
      expect(screen.getByText('Not a git repository')).toBeInTheDocument()
    })
    expect(screen.getByText('Retry')).toBeInTheDocument()
  })

  it('calls get_vault_pulse with skip=0 on initial load and passes correct page size', async () => {
    mockInvokeFn.mockResolvedValue([])

    render(<PulseView vaultPath="/test/vault" />)

    await waitFor(() => {
      expect(mockInvokeFn).toHaveBeenCalledWith('get_vault_pulse', { vaultPath: '/test/vault', limit: 20, skip: 0 })
    })
  })

  it('calls get_vault_pulse with correct arguments', async () => {
    mockInvokeFn.mockResolvedValue([])

    render(<PulseView vaultPath="/my/vault" />)

    await waitFor(() => {
      expect(mockInvokeFn).toHaveBeenCalledWith('get_vault_pulse', { vaultPath: '/my/vault', limit: 20, skip: 0 })
    })
  })

  it('toggles file list visibility when clicking anywhere on the commit row', async () => {
    mockInvokeFn.mockResolvedValue(mockCommits)

    render(<PulseView vaultPath="/test/vault" />)

    // Files are collapsed by default
    await waitFor(() => {
      expect(screen.getAllByLabelText('Expand files').length).toBeGreaterThan(0)
    })
    expect(screen.queryByText('my project')).not.toBeInTheDocument()

    fireEvent.click(screen.getByText('Update project notes'))

    await waitFor(() => {
      expect(screen.getByText('my project')).toBeInTheDocument()
    })

    fireEvent.click(screen.getByText('Update project notes'))

    expect(screen.queryByText('my project')).not.toBeInTheDocument()
  })

  it('uses a full-width surface for hover, focus, and expanded commit rows', async () => {
    mockInvokeFn.mockResolvedValue(mockCommits)

    render(<PulseView vaultPath="/test/vault" />)

    const commitRow = await screen.findByRole('button', { name: /Update project notes/i })
    const hasExpandedBackground = (className: string) => /(^|\s)bg-accent\/40(\s|$)/.test(className)

    expect(commitRow.className).toContain('-mx-4')
    expect(commitRow.className).toContain('px-4')
    expect(commitRow.className).toContain('rounded-none')
    expect(commitRow.className).toContain('hover:bg-accent/40')
    expect(commitRow.className).toContain('focus-visible:bg-accent/40')
    expect(hasExpandedBackground(commitRow.className)).toBe(false)

    fireEvent.click(commitRow)

    expect(hasExpandedBackground(commitRow.className)).toBe(true)
  })

  it('supports keyboard activation for commit rows and file rows', async () => {
    mockInvokeFn.mockResolvedValue(mockCommits)
    const onOpenNote = vi.fn()

    render(<PulseView vaultPath="/test/vault" onOpenNote={onOpenNote} />)

    const commitRow = await screen.findByRole('button', { name: /Update project notes/i })
    commitRow.focus()
    fireEvent.keyDown(commitRow, { key: 'Enter' })

    const fileButton = await screen.findByRole('button', { name: 'my project' })
    fileButton.focus()
    fireEvent.keyDown(fileButton, { key: 'Enter' })

    expect(onOpenNote).toHaveBeenCalledWith('project/my-project.md', 'abc123def456')
  })

  it('renders History header', async () => {
    mockInvokeFn.mockResolvedValue([])

    render(<PulseView vaultPath="/test/vault" />)

    await waitFor(() => {
      expect(screen.getByText('History')).toBeInTheDocument()
    })
  })

  it('shows a repository selector in the History header when multiple repositories are available', async () => {
    mockInvokeFn.mockResolvedValue([])

    render(
      <PulseView
        vaultPath="/work"
        repositories={[
          { path: '/default', label: 'Default', defaultForNewNotes: true },
          { path: '/work', label: 'Work', defaultForNewNotes: false },
        ]}
        selectedRepositoryPath="/work"
        onRepositoryChange={vi.fn()}
      />,
    )

    expect(await screen.findByTestId('pulse-repository-select')).toBeInTheDocument()
    expect(screen.getByText('Work')).toBeInTheDocument()
  })

  it('wires the Pulse header into the shared drag-region handler', async () => {
    mockInvokeFn.mockResolvedValue([])

    render(<PulseView vaultPath="/test/vault" />)

    const header = await screen.findByTestId('pulse-header')
    fireEvent.mouseDown(header)

    expect(dragRegionMouseDown).toHaveBeenCalledTimes(1)
  })

  it('keeps the expand-sidebar button clickable when the header is draggable', async () => {
    mockInvokeFn.mockResolvedValue([])
    const onExpandSidebar = vi.fn()

    render(
      <PulseView
        vaultPath="/test/vault"
        sidebarCollapsed
        onExpandSidebar={onExpandSidebar}
      />,
    )

    const button = await screen.findByRole('button', { name: 'Expand sidebar' })
    fireEvent.click(button)

    expect(onExpandSidebar).toHaveBeenCalledTimes(1)
  })

  it('offsets the collapsed-sidebar expand button past macOS traffic lights', async () => {
    mockInvokeFn.mockResolvedValue([])

    await withUserAgent(MAC_USER_AGENT, async () => {
      render(
        <PulseView
          vaultPath="/test/vault"
          sidebarCollapsed
          onExpandSidebar={vi.fn()}
        />,
      )

      expect(await screen.findByTestId('pulse-header')).toHaveStyle({ paddingLeft: '90px' })
    })
  })

  it('keeps the default collapsed-sidebar header padding off macOS', async () => {
    mockInvokeFn.mockResolvedValue([])

    await withUserAgent(WINDOWS_USER_AGENT, async () => {
      render(
        <PulseView
          vaultPath="/test/vault"
          sidebarCollapsed
          onExpandSidebar={vi.fn()}
        />,
      )

      expect(await screen.findByTestId('pulse-header')).toHaveStyle({ paddingLeft: '16px' })
    })
  })
})
