/**
 * IPC handler registration tests for Phase 2 File channels.
 *
 * Verifies that `createInternalEntry`, `ensureExternalEntry`,
 * `getPhysicalPath`, and `permanentDelete` channels are registered on
 * `ipcMain.handle` and that each dispatches to the corresponding FileManager
 * method. `permanentDelete` covers both `FileEntryHandle` and `FilePathHandle`
 * branches via `dispatchHandle`.
 */

import { access, mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import path from 'node:path'

import { application } from '@application'
import { BaseService } from '@main/core/lifecycle'
import { IpcChannel } from '@shared/IpcChannel'
import { setupTestDatabase } from '@test-helpers/db'
import { MockMainDbServiceUtils } from '@test-mocks/main/DbService'
import { ipcMain } from 'electron'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'

vi.mock('@application', async () => {
  const { mockApplicationFactory } = await import('@test-mocks/main/application')
  return mockApplicationFactory()
})

const { FileManager } = await import('../FileManager')
const { danglingCache } = await import('../danglingCache')

describe('FileManager v2 IPC handler registration', () => {
  const dbh = setupTestDatabase()
  let tmp: string
  let internalRoot: string
  let fm: InstanceType<typeof FileManager>

  beforeEach(async () => {
    MockMainDbServiceUtils.setDb(dbh.db)
    tmp = await mkdtemp(path.join(tmpdir(), 'cherry-fm-ipc-v2-'))
    internalRoot = path.join(tmp, 'files-internal')
    await mkdir(internalRoot, { recursive: true })
    vi.mocked(application.getPath).mockImplementation((key: string, filename?: string) => {
      if (key === 'feature.files.data') {
        return filename ? path.join(internalRoot, filename) : internalRoot
      }
      return filename ? `/mock/${key}/${filename}` : `/mock/${key}`
    })
    BaseService.resetInstances()
    danglingCache.clear()
    vi.mocked(ipcMain.handle).mockClear()
    fm = new FileManager()
    // `onInit` is `protected` (lifecycle contract); bracket access is the
    // canonical test-only escape hatch to drive the init sequence without
    // requiring a public wrapper just for tests.
    await (fm as unknown as { onInit(): Promise<void> }).onInit()
  })

  afterEach(async () => {
    await rm(tmp, { recursive: true, force: true })
  })

  it('does not register files-page dangling handlers on legacy File_* channels', () => {
    const registeredChannels = vi.mocked(ipcMain.handle).mock.calls.map(([channel]) => channel)
    expect(registeredChannels).not.toContain('file:getDanglingState')
    expect(registeredChannels).not.toContain('file:batchGetDanglingStates')
  })

  it('registers File:createInternalEntry IPC channel', () => {
    const registeredChannels = vi.mocked(ipcMain.handle).mock.calls.map(([channel]) => channel)
    expect(registeredChannels).toContain(IpcChannel.File_CreateInternalEntry)
  })

  it('registers File:ensureExternalEntry IPC channel', () => {
    const registeredChannels = vi.mocked(ipcMain.handle).mock.calls.map(([channel]) => channel)
    expect(registeredChannels).toContain(IpcChannel.File_EnsureExternalEntry)
  })

  it('registers File:getPhysicalPath IPC channel', () => {
    const registeredChannels = vi.mocked(ipcMain.handle).mock.calls.map(([channel]) => channel)
    expect(registeredChannels).toContain(IpcChannel.File_GetPhysicalPath)
  })

  it('createInternalEntry handler creates a file from bytes and returns a FileEntry', async () => {
    const handler = vi.mocked(ipcMain.handle).mock.calls.find(([ch]) => ch === IpcChannel.File_CreateInternalEntry)?.[1]
    expect(handler).toBeDefined()

    const params = {
      source: 'bytes' as const,
      data: new Uint8Array([104, 101, 108, 108, 111]),
      name: 'hello',
      ext: 'txt'
    }
    const result = await handler!({} as never, params)

    expect(result.origin).toBe('internal')
    expect(result.name).toBe('hello')
    expect(result.ext).toBe('txt')
    expect(result.size).toBe(5)
  })

  it('ensureExternalEntry handler upserts an external entry', async () => {
    const extFile = path.join(tmp, 'external.pdf')
    await writeFile(extFile, '%PDF-1.4')

    const handler = vi.mocked(ipcMain.handle).mock.calls.find(([ch]) => ch === IpcChannel.File_EnsureExternalEntry)?.[1]
    expect(handler).toBeDefined()

    const result = await handler!({} as never, { externalPath: extFile })
    expect(result.origin).toBe('external')
    expect(result.externalPath).toBe(extFile)
    expect(result.name).toBe('external')
    expect(result.ext).toBe('pdf')

    // Idempotent — second call returns the same entry
    const result2 = await handler!({} as never, { externalPath: extFile })
    expect(result2.id).toBe(result.id)
  })

  it('createInternalEntry rejects unsafe bytes.name at the schema boundary', async () => {
    const handler = vi.mocked(ipcMain.handle).mock.calls.find(([ch]) => ch === IpcChannel.File_CreateInternalEntry)?.[1]
    // path separator
    await expect(
      handler!({} as never, {
        source: 'bytes' as const,
        data: new Uint8Array([1]),
        name: '../etc/passwd',
        ext: 'txt'
      })
    ).rejects.toThrow()
    // null byte
    await expect(
      handler!({} as never, {
        source: 'bytes' as const,
        data: new Uint8Array([1]),
        name: 'a\0b',
        ext: 'txt'
      })
    ).rejects.toThrow()
    // whitespace-only
    await expect(
      handler!({} as never, {
        source: 'bytes' as const,
        data: new Uint8Array([1]),
        name: '   ',
        ext: 'txt'
      })
    ).rejects.toThrow()
  })

  it('createInternalEntry rejects malformed url at the schema boundary', async () => {
    const handler = vi.mocked(ipcMain.handle).mock.calls.find(([ch]) => ch === IpcChannel.File_CreateInternalEntry)?.[1]
    await expect(handler!({} as never, { source: 'url' as const, url: 'not-a-url' })).rejects.toThrow()
  })

  it('createInternalEntry rejects relative path source at the schema boundary', async () => {
    const handler = vi.mocked(ipcMain.handle).mock.calls.find(([ch]) => ch === IpcChannel.File_CreateInternalEntry)?.[1]
    await expect(handler!({} as never, { source: 'path' as const, path: 'relative/file.txt' })).rejects.toThrow()
  })

  it('ensureExternalEntry rejects relative externalPath at the schema boundary', async () => {
    const handler = vi.mocked(ipcMain.handle).mock.calls.find(([ch]) => ch === IpcChannel.File_EnsureExternalEntry)?.[1]
    await expect(handler!({} as never, { externalPath: 'relative.pdf' })).rejects.toThrow()
  })

  it('getPhysicalPath handler returns the filesystem path for an internal entry', async () => {
    // First create an entry so we have a valid id
    const createHandler = vi
      .mocked(ipcMain.handle)
      .mock.calls.find(([ch]) => ch === IpcChannel.File_CreateInternalEntry)?.[1]
    const entry = await createHandler!({} as never, {
      source: 'bytes' as const,
      data: new Uint8Array([1, 2, 3]),
      name: 'data',
      ext: 'bin'
    })

    const getPathHandler = vi
      .mocked(ipcMain.handle)
      .mock.calls.find(([ch]) => ch === IpcChannel.File_GetPhysicalPath)?.[1]
    expect(getPathHandler).toBeDefined()

    const physicalPath = await getPathHandler!({} as never, { id: entry.id })
    expect(physicalPath).toContain(entry.id)
    expect(physicalPath).toContain('bin')
  })

  it('registers File:permanentDelete IPC channel', () => {
    const registeredChannels = vi.mocked(ipcMain.handle).mock.calls.map(([channel]) => channel)
    expect(registeredChannels).toContain(IpcChannel.File_PermanentDelete)
  })

  it('permanentDelete entry-handle removes an internal entry row and physical file', async () => {
    // Create an internal entry so we have a known id + physical path
    const createHandler = vi
      .mocked(ipcMain.handle)
      .mock.calls.find(([ch]) => ch === IpcChannel.File_CreateInternalEntry)?.[1]
    const entry = await createHandler!({} as never, {
      source: 'bytes' as const,
      data: new Uint8Array([72, 101, 108, 108, 111]),
      name: 'todelete',
      ext: 'txt'
    })

    // Resolve the physical path before deletion so we can check it afterward
    const getPathHandler = vi
      .mocked(ipcMain.handle)
      .mock.calls.find(([ch]) => ch === IpcChannel.File_GetPhysicalPath)?.[1]
    const physicalPath = await getPathHandler!({} as never, { id: entry.id })

    const deleteHandler = vi
      .mocked(ipcMain.handle)
      .mock.calls.find(([ch]) => ch === IpcChannel.File_PermanentDelete)?.[1]
    expect(deleteHandler).toBeDefined()

    // Entry-handle branch
    await expect(deleteHandler!({} as never, { kind: 'entry', entryId: entry.id })).resolves.toBeUndefined()

    // Physical file must be gone
    await expect(access(physicalPath)).rejects.toThrow()
  })

  it('permanentDelete entry-handle throws when the id does not exist', async () => {
    const deleteHandler = vi
      .mocked(ipcMain.handle)
      .mock.calls.find(([ch]) => ch === IpcChannel.File_PermanentDelete)?.[1]
    expect(deleteHandler).toBeDefined()

    // Use a valid UUID that was never inserted — DB lookup will reject it
    const nonExistentId = '123e4567-e89b-4d3c-a456-426614174000'
    await expect(deleteHandler!({} as never, { kind: 'entry', entryId: nonExistentId })).rejects.toThrow()
  })

  it('permanentDelete path-handle removes the file at the given path', async () => {
    // Stage a real file outside the entry system
    const orphan = path.join(tmp, 'orphan.txt')
    await writeFile(orphan, 'bye')

    const deleteHandler = vi
      .mocked(ipcMain.handle)
      .mock.calls.find(([ch]) => ch === IpcChannel.File_PermanentDelete)?.[1]
    await expect(deleteHandler!({} as never, { kind: 'path', path: orphan })).resolves.toBeUndefined()
    await expect(access(orphan)).rejects.toThrow()
  })

  it('permanentDelete rejects malformed handles at the schema boundary', async () => {
    const deleteHandler = vi
      .mocked(ipcMain.handle)
      .mock.calls.find(([ch]) => ch === IpcChannel.File_PermanentDelete)?.[1]
    // Missing discriminant
    await expect(deleteHandler!({} as never, { entryId: 'x' })).rejects.toThrow()
    // Unknown kind
    await expect(deleteHandler!({} as never, { kind: 'virtual', path: '/tmp/x' })).rejects.toThrow()
    // path-handle with a relative path
    await expect(deleteHandler!({} as never, { kind: 'path', path: 'relative.txt' })).rejects.toThrow()
  })

  it('registers File:getMetadata IPC channel', () => {
    const registeredChannels = vi.mocked(ipcMain.handle).mock.calls.map(([channel]) => channel)
    expect(registeredChannels).toContain(IpcChannel.File_GetMetadata)
  })

  it('getMetadata path-handle returns file metadata for a regular file', async () => {
    const file = path.join(tmp, 'meta.txt')
    await writeFile(file, 'hello')

    const handler = vi.mocked(ipcMain.handle).mock.calls.find(([ch]) => ch === IpcChannel.File_GetMetadata)?.[1]
    expect(handler).toBeDefined()

    const meta = await handler!({} as never, { kind: 'path', path: file })
    expect(meta.kind).toBe('file')
    expect(typeof meta.size).toBe('number')
    expect(meta.size).toBe(5)
  })

  it('getMetadata path-handle returns directory metadata for a directory', async () => {
    const dir = path.join(tmp, 'meta-dir')
    await mkdir(dir, { recursive: true })

    const handler = vi.mocked(ipcMain.handle).mock.calls.find(([ch]) => ch === IpcChannel.File_GetMetadata)?.[1]
    const meta = await handler!({} as never, { kind: 'path', path: dir })
    expect(meta.kind).toBe('directory')
  })

  it('getMetadata entry-handle is not yet wired (@phase 2)', async () => {
    const handler = vi.mocked(ipcMain.handle).mock.calls.find(([ch]) => ch === IpcChannel.File_GetMetadata)?.[1]
    const validEntryId = '123e4567-e89b-4d3c-a456-426614174000'
    await expect(handler!({} as never, { kind: 'entry', entryId: validEntryId })).rejects.toThrow()
  })
})
