import fs from 'fs'
import { test, expect, type Page } from '@playwright/test'
import { APP_COMMAND_IDS } from '../../src/hooks/appCommandCatalog'
import { createFixtureVaultCopy, openFixtureVaultTauri, removeFixtureVaultCopy } from '../helpers/fixtureVault'
import { triggerMenuCommand, triggerShortcutCommand } from './testBridge'

function markdownFiles(vaultPath: string): string[] {
  return fs.readdirSync(vaultPath).filter((name) => name.endsWith('.md')).sort()
}

function slugifyTitle(title: string): string {
  return title.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/(^-|-$)/g, '')
}

interface FileExpectation {
  vaultPath: string
  filename: string
}

interface FileContentExpectation extends FileExpectation {
  text: string
}

interface EmptyTitleHeadingState {
  contentType: string | null
  placeholder: string | null
}

const REACT_UPDATE_LOOP_FRAGMENTS = [
  'Maximum update depth',
  'React error #185',
  '#185',
]

function collectReactUpdateLoopErrors(page: Page): string[] {
  const errors: string[] = []
  const collect = (message: string) => {
    if (REACT_UPDATE_LOOP_FRAGMENTS.some((fragment) => message.includes(fragment))) {
      errors.push(message)
    }
  }

  page.on('pageerror', (error) => collect(error.message))
  page.on('console', (message) => {
    if (message.type() === 'error') collect(message.text())
  })

  return errors
}

async function createUntitledNote(page: Page): Promise<void> {
  await page.locator('body').click()
  await triggerMenuCommand(page, 'file-new-note')
  await expect(page.locator('.bn-editor')).toBeVisible({ timeout: 5_000 })
  await expect(page.getByTestId('breadcrumb-filename-trigger')).toContainText(/untitled-note-\d+(?:-\d+)?/i, {
    timeout: 5_000,
  })
  await expectStableEmptyTitleHeading(page)
}

async function writeNewHeading(page: Page, title: string): Promise<void> {
  await page.keyboard.type(title)
  await page.keyboard.press('Enter')
}

async function writeNewHeadingAndBody(page: Page, title: string, body: string): Promise<void> {
  await page.keyboard.type(title, { delay: 20 })
  await page.keyboard.press('Enter')
  await page.keyboard.type(body, { delay: 20 })
}

async function expectRenamedFile({ vaultPath, filename }: FileExpectation): Promise<void> {
  await expect(async () => {
    expect(markdownFiles(vaultPath)).toContain(filename)
  }).toPass({ timeout: 10_000 })
}

async function expectFileMissing({ vaultPath, filename }: FileExpectation): Promise<void> {
  await expect(async () => {
    expect(markdownFiles(vaultPath)).not.toContain(filename)
  }).toPass({ timeout: 10_000 })
}

async function expectFileContentContains({ vaultPath, filename, text }: FileContentExpectation): Promise<void> {
  await expect(async () => {
    const content = fs.readFileSync(`${vaultPath}/${filename}`, 'utf-8')
    expect(content).toContain(text)
  }).toPass({ timeout: 10_000 })
}

async function expectActiveFilename(page: Page, filenameStem: string): Promise<void> {
  await expect(page.getByTestId('breadcrumb-filename-trigger')).toContainText(filenameStem, { timeout: 10_000 })
}

async function expectEditorFocused(page: Page): Promise<void> {
  await expect.poll(async () => page.evaluate(() => {
    const active = document.activeElement as HTMLElement | null
    return Boolean(active?.isContentEditable || active?.closest('[contenteditable="true"]'))
  }), {
    timeout: 5_000,
  }).toBe(true)
}

async function readEmptyTitleHeadingState(page: Page): Promise<EmptyTitleHeadingState> {
  return page.evaluate(() => {
    const firstBlock = document.querySelector('.bn-block-content') as HTMLElement | null
    const inlineHeading = firstBlock?.querySelector('.bn-inline-content') as HTMLElement | null
    return {
      contentType: firstBlock?.getAttribute('data-content-type') ?? null,
      placeholder: inlineHeading ? getComputedStyle(inlineHeading, '::before').content : null,
    }
  })
}

async function selectionInsideEmptyTitleHeading(page: Page): Promise<boolean> {
  return page.evaluate(() => {
    const firstBlock = document.querySelector('.bn-block-content') as HTMLElement | null
    const selection = window.getSelection()
    const anchorNode = selection?.anchorNode ?? null
    const anchorElement = anchorNode instanceof Element ? anchorNode : anchorNode?.parentElement ?? null
    return Boolean(selection?.rangeCount && anchorElement && firstBlock?.contains(anchorElement))
  })
}

async function expectReadyEmptyTitleHeading(page: Page): Promise<void> {
  await expectEditorFocused(page)
  await expect.poll(() => readEmptyTitleHeadingState(page), {
    timeout: 5_000,
  }).toEqual({
    contentType: 'heading',
    placeholder: '"Title"',
  })
  await expect.poll(() => selectionInsideEmptyTitleHeading(page), { timeout: 5_000 }).toBe(true)
}

async function expectStableEmptyTitleHeading(page: Page): Promise<void> {
  await expectReadyEmptyTitleHeading(page)
  await page.waitForTimeout(300)
  await expectReadyEmptyTitleHeading(page)
}

async function activeSelectionBlockType(page: Page): Promise<string | null> {
  return page.evaluate(() => {
    const anchorNode = window.getSelection()?.anchorNode
    if (!anchorNode) return null

    const anchorElement = anchorNode instanceof Element ? anchorNode : anchorNode.parentElement
    if (!anchorElement) return null

    return anchorElement.closest('.bn-block-content')?.getAttribute('data-content-type') ?? null
  })
}

async function getRawEditorContent(page: Page): Promise<string> {
  return page.evaluate(() => {
    const host = document.querySelector('[data-testid="raw-editor-codemirror"]') as (Element & {
      __cmView?: { state: { doc: { toString: () => string } } }
    }) | null
    const view = host?.__cmView
    if (!view) throw new Error('CodeMirror view is missing')
    return view.state.doc.toString()
  })
}

async function setRawEditorContent(page: Page, content: string): Promise<void> {
  await page.evaluate((nextContent) => {
    const host = document.querySelector('[data-testid="raw-editor-codemirror"]') as (Element & {
      __cmView?: {
        state: { doc: { toString: () => string } }
        dispatch: (spec: { changes: { from: number; to: number; insert: string } }) => void
      }
    }) | null
    const view = host?.__cmView
    if (!view) throw new Error('CodeMirror view is missing')
    view.dispatch({
      changes: { from: 0, to: view.state.doc.toString().length, insert: nextContent },
    })
  }, content)
}

async function openRawEditor(page: Page): Promise<void> {
  await triggerShortcutCommand(page, APP_COMMAND_IDS.editToggleRawEditor)
  await expect(page.getByTestId('raw-editor-codemirror')).toBeVisible({ timeout: 5_000 })
}

async function openRichEditor(page: Page): Promise<void> {
  await triggerShortcutCommand(page, APP_COMMAND_IDS.editToggleRawEditor)
  await expect(page.locator('.bn-editor')).toBeVisible({ timeout: 5_000 })
}

async function expectTitleHeadingText(page: Page, title: string): Promise<void> {
  await expect(page.locator('.bn-editor [data-content-type="heading"]').first()).toContainText(title, {
    timeout: 5_000,
  })
}

async function dispatchRichHeadingPaste(page: Page, text: string): Promise<void> {
  await page.evaluate((textFragment) => {
    const target = document.querySelector<HTMLElement>(
      [
        '.bn-editor [data-content-type="heading"][data-level="1"] .bn-inline-content',
        '.bn-editor [data-content-type="heading"]:not([data-level]) .bn-inline-content',
        '.bn-editor h1',
      ].join(', '),
    )
    if (!target) throw new Error('Title heading paste target was not found')

    const clipboardData = new DataTransfer()
    clipboardData.setData('text/html', '<h1>Rich <em>Paste</em> Payload</h1><table><tr><td>structured</td></tr></table>')
    clipboardData.setData('text/plain', textFragment)

    const event = new Event('paste', { bubbles: true, cancelable: true })
    Object.defineProperty(event, 'clipboardData', { value: clipboardData })

    if (target.dispatchEvent(event)) {
      throw new Error('Rich title-heading paste was not intercepted')
    }
  }, text)
}

async function clickEditorChromeBelowLastBlock(page: Page): Promise<void> {
  const point = await page.evaluate(() => {
    const container = document.querySelector('.editor__blocknote-container')?.getBoundingClientRect()
    const blocks = Array.from(document.querySelectorAll('.bn-block-outer'))
    const lastBlock = blocks.at(-1)?.getBoundingClientRect()
    if (!container || !lastBlock) return null

    return {
      x: Math.min(container.right - 24, Math.max(container.left + 24, lastBlock.left + 40)),
      y: Math.min(container.bottom - 24, lastBlock.bottom + 18),
    }
  })

  expect(point).not.toBeNull()
  await page.mouse.click(point!.x, point!.y)
}

let tempVaultDir: string

test.beforeEach(async ({ page }, testInfo) => {
  testInfo.setTimeout(90_000)
  tempVaultDir = createFixtureVaultCopy()
  await openFixtureVaultTauri(page, tempVaultDir)
})

test.afterEach(async () => {
  removeFixtureVaultCopy(tempVaultDir)
})

test('new-note H1 auto-rename keeps the editor usable and leaves no untitled duplicates', async ({ page }) => {
  const errors: string[] = []
  page.on('pageerror', (err) => {
    errors.push(err.message)
  })

  const titles = [
    'Fresh Focus Title',
    'Rapid Rename 2',
    'Rapid Rename 3',
    'Rapid Rename 4',
    'Rapid Rename 5',
  ]

  for (const [index, title] of titles.entries()) {
    await createUntitledNote(page)
    if (index === 0) {
      await writeNewHeadingAndBody(page, title, 'Body continues while rename is pending.')
    } else {
      await writeNewHeading(page, title)
    }
    await expectActiveFilename(page, slugifyTitle(title))
    await expectRenamedFile({ vaultPath: tempVaultDir, filename: `${slugifyTitle(title)}.md` })
    await expectEditorFocused(page)
    await expectFileContentContains({
      vaultPath: tempVaultDir,
      filename: `${slugifyTitle(title)}.md`,
      text: `# ${title}`,
    })

    if (index === 0) {
      await expectFileContentContains({
        vaultPath: tempVaultDir,
        filename: 'fresh-focus-title.md',
        text: 'Body continues while rename is pending.',
      })
      await page.keyboard.type(' focus-probe')
      await expectFileContentContains({
        vaultPath: tempVaultDir,
        filename: 'fresh-focus-title.md',
        text: 'focus-probe',
      })
    }
  }

  const files = markdownFiles(tempVaultDir)
  expect(files).toContain('fresh-focus-title.md')
  expect(files.filter((name) => name.startsWith('untitled-note-'))).toEqual([])
  expect(files.filter((name) => /^rapid-rename-\d+\.md$/.test(name))).toHaveLength(4)
  expect(errors).toEqual([])
})

test('new-note short title typing and rich heading paste stay in the H1 until Enter', async ({ page }) => {
  const errors = collectReactUpdateLoopErrors(page)
  const titleStart = 'Obsi'
  const title = 'Obsidian'
  const richPasteSuffix = ' Rich Paste Payload'
  const pastedTitle = `${title}${richPasteSuffix}`
  const bodyText = 'Body starts only after intentional Enter.'

  await createUntitledNote(page)
  await page.keyboard.type(titleStart, { delay: 80 })
  await expectTitleHeadingText(page, titleStart)
  await expectEditorFocused(page)
  await expect.poll(() => activeSelectionBlockType(page), { timeout: 5_000 }).toBe('heading')

  await page.waitForTimeout(1_000)
  await expectTitleHeadingText(page, titleStart)
  await expect.poll(() => activeSelectionBlockType(page), { timeout: 5_000 }).toBe('heading')

  await page.keyboard.type('dian', { delay: 80 })
  await expectTitleHeadingText(page, title)
  await page.keyboard.press('End')
  await dispatchRichHeadingPaste(page, richPasteSuffix)
  await expectTitleHeadingText(page, pastedTitle)
  await expect.poll(() => activeSelectionBlockType(page), { timeout: 5_000 }).toBe('heading')
  await page.keyboard.press('Enter')
  await page.keyboard.type(bodyText, { delay: 35 })

  await expectActiveFilename(page, slugifyTitle(pastedTitle))
  await expectFileContentContains({
    vaultPath: tempVaultDir,
    filename: `${slugifyTitle(pastedTitle)}.md`,
    text: `# ${pastedTitle}`,
  })
  await expectFileContentContains({
    vaultPath: tempVaultDir,
    filename: `${slugifyTitle(pastedTitle)}.md`,
    text: bodyText,
  })
  expect(errors).toEqual([])
})

test('@smoke new-note typing stays focused through initial save settlement', async ({ page }) => {
  const title = 'Creation Focus Guard'
  const bodyText = 'Body keeps accepting text while creation writes and saves settle.'

  await createUntitledNote(page)
  await page.keyboard.type(title, { delay: 35 })
  await page.keyboard.press('Enter')
  await page.keyboard.type(bodyText, { delay: 35 })
  await page.waitForTimeout(1_000)

  await expectEditorFocused(page)
  await page.keyboard.type(' Still focused.')
  await expectActiveFilename(page, slugifyTitle(title))
  await expectFileContentContains({
    vaultPath: tempVaultDir,
    filename: `${slugifyTitle(title)}.md`,
    text: 'Still focused.',
  })
})

test('new-note editor mode roundtrip stays editable after auto-rename', async ({ page }) => {
  const errors: string[] = []
  page.on('pageerror', (err) => {
    errors.push(err.message)
  })
  const title = 'Mode Switch Crash Guard'
  const filename = `${slugifyTitle(title)}.md`
  const rawLine = 'Raw mode edit after auto-rename.'
  const richLine = 'Rich editor still accepts typing after the raw-mode return.'

  await createUntitledNote(page)
  await writeNewHeadingAndBody(page, title, 'Initial body before the raw-mode round trip.')
  await expectActiveFilename(page, slugifyTitle(title))
  await expectRenamedFile({ vaultPath: tempVaultDir, filename })

  await openRawEditor(page)
  const rawContent = await getRawEditorContent(page)
  await setRawEditorContent(page, `${rawContent}\n\n${rawLine}`)
  await page.waitForTimeout(650)

  await openRichEditor(page)
  await expect(page.locator('.bn-editor')).toContainText(rawLine, { timeout: 5_000 })
  await page.locator('.bn-editor').click()
  await page.keyboard.type(richLine)

  await expectFileContentContains({
    vaultPath: tempVaultDir,
    filename,
    text: rawLine,
  })
  await expectFileContentContains({
    vaultPath: tempVaultDir,
    filename,
    text: richLine,
  })
  expect(errors).toEqual([])
})

test('new-note H1 auto-rename preserves body typing and cursor while rename lands', async ({ page }) => {
  const errors: string[] = []
  page.on('pageerror', (err) => {
    errors.push(err.message)
  })

  await createUntitledNote(page)
  await page.keyboard.type('Cursor Stable Rename', { delay: 30 })
  await page.keyboard.press('Enter')

  // Let the initial untitled save settle so the rename timer can fire mid-body typing.
  await page.waitForTimeout(700)

  const bodyText = 'Body keeps flowing through the rename without losing the caret or freezing.'
  await page.keyboard.type(bodyText, { delay: 70 })

  await expectActiveFilename(page, 'cursor-stable-rename')
  await expectRenamedFile({ vaultPath: tempVaultDir, filename: 'cursor-stable-rename.md' })
  await expectFileContentContains({
    vaultPath: tempVaultDir,
    filename: 'cursor-stable-rename.md',
    text: bodyText,
  })
  await expectEditorFocused(page)
  await expect.poll(() => activeSelectionBlockType(page), { timeout: 5_000 }).toBe('paragraph')

  await page.keyboard.type(' Still typing after rename.')
  await expectFileContentContains({
    vaultPath: tempVaultDir,
    filename: 'cursor-stable-rename.md',
    text: 'Still typing after rename.',
  })
  await expectEditorFocused(page)
  await expect(errors).toEqual([])
})

test('fresh-note Enter stays stable after autosave and editor chrome clicks', async ({ page }) => {
  const errors: string[] = []
  page.on('pageerror', (err) => {
    errors.push(err.message)
  })
  const title = 'Enter Selection Guard'
  const filename = 'enter-selection-guard.md'
  const firstLine = 'First paragraph before chrome clicks.'
  const secondLine = 'Second paragraph after editor chrome click.'
  const thirdLine = 'Third paragraph after another chrome click.'

  await createUntitledNote(page)
  await page.keyboard.type(title, { delay: 35 })
  await page.keyboard.press('Enter')
  await page.keyboard.type(firstLine, { delay: 35 })

  await expectActiveFilename(page, 'enter-selection-guard')
  await expectRenamedFile({ vaultPath: tempVaultDir, filename })
  await page.waitForTimeout(900)

  await clickEditorChromeBelowLastBlock(page)
  await page.keyboard.press('Enter')
  await page.keyboard.type(secondLine, { delay: 35 })

  await clickEditorChromeBelowLastBlock(page)
  await page.keyboard.press('Enter')
  await page.keyboard.type(thirdLine, { delay: 35 })

  await expect(page.locator('.error-boundary')).toHaveCount(0)
  await expectEditorFocused(page)
  expect(errors).toEqual([])
  await expectFileContentContains({ vaultPath: tempVaultDir, filename, text: firstLine })
  await expectFileContentContains({ vaultPath: tempVaultDir, filename, text: secondLine })
  await expectFileContentContains({ vaultPath: tempVaultDir, filename, text: thirdLine })
})

test('@smoke new-note H1 auto-rename does not recreate the untitled file when a buffered save lands after rename', async ({ page }) => {
  const title = 'Late Save Guard'
  const lateBody = 'Body typed right before rename'

  await createUntitledNote(page)
  const untitledStem = (await page.getByTestId('breadcrumb-filename-trigger').textContent())?.trim()
  expect(untitledStem).toMatch(/^untitled-note-\d+(?:-\d+)?$/i)

  await writeNewHeading(page, title)
  await page.waitForTimeout(2_600)
  await page.keyboard.type(lateBody)

  await expectActiveFilename(page, 'late-save-guard')
  await expectRenamedFile({ vaultPath: tempVaultDir, filename: 'late-save-guard.md' })
  await expectFileContentContains({
    vaultPath: tempVaultDir,
    filename: 'late-save-guard.md',
    text: lateBody,
  })

  await page.waitForTimeout(800)
  await expectFileMissing({
    vaultPath: tempVaultDir,
    filename: `${untitledStem}.md`,
  })
})
