// Integration tests for `KnowledgeMigrator`'s legacy-file copy step.
//
// Runs KnowledgeMigrator against a real SQLite DB and a real temp filesystem so
// the copy from `<filesDataDir>/<storageName>` into
// `<knowledgeBaseDir>/<baseId>/<relativePath>` is exercised end to end:
//   - the upload is copied and the row's relativePath matches the file on disk,
//   - same-name uploads in one base get deduped relativePaths (no collision),
//   - a missing source degrades to a warning while keeping the item.
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
import { mkdirSync } from 'node:fs'
import { existsSync } from 'node:fs'
import { tmpdir } from 'node:os'
import path from 'node:path'

import { knowledgeItemTable } from '@data/db/schemas/knowledge'
import type { FileMetadata } from '@shared/data/types/file/legacyFileMetadata'
import { FileItemDataSchema } from '@shared/data/types/knowledge'
import { setupTestDatabase } from '@test-helpers/db'
import { afterEach, describe, expect, it, vi } from 'vitest'

import { KnowledgeMigrator } from '../KnowledgeMigrator'

vi.mock('@logger', () => ({
  loggerService: {
    withContext: vi.fn(() => ({
      info: vi.fn(),
      warn: vi.fn(),
      error: vi.fn(),
      debug: vi.fn()
    }))
  }
}))

function dexieFileRow(
  overrides: Partial<FileMetadata> & Pick<FileMetadata, 'id' | 'name' | 'origin_name'>
): FileMetadata {
  return {
    id: overrides.id,
    name: overrides.name,
    origin_name: overrides.origin_name,
    path: overrides.path ?? `/legacy/${overrides.origin_name}`,
    size: overrides.size ?? 16,
    ext: overrides.ext ?? '.pdf',
    type: overrides.type ?? 'document',
    created_at: overrides.created_at ?? '2025-01-01T00:00:00.000Z',
    count: overrides.count ?? 1
  }
}

function makeCtx(
  dbh: ReturnType<typeof setupTestDatabase>,
  dexieFiles: FileMetadata[],
  reduxKnowledge: unknown,
  paths: { knowledgeBaseDir: string; filesDataDir: string }
) {
  return {
    sources: {
      dexieExport: {
        tableExists: vi.fn(async (name: string) => name === 'files' && dexieFiles.length > 0),
        createStreamReader: vi.fn((name: string) => ({
          readInBatches: vi.fn(async (_size: number, cb: (rows: FileMetadata[]) => Promise<void>) => {
            if (name === 'files') await cb(dexieFiles)
          })
        }))
      },
      reduxState: {
        getCategory: vi.fn(() => reduxKnowledge)
      }
    },
    db: dbh.db,
    sharedData: new Map<string, unknown>(),
    logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() },
    paths: {
      userData: path.dirname(paths.filesDataDir),
      knowledgeBaseDir: paths.knowledgeBaseDir,
      filesDataDir: paths.filesDataDir
    }
  } as never
}

describe('KnowledgeMigrator legacy file copy (integration)', () => {
  const dbh = setupTestDatabase()
  let tempRoot: string | undefined

  afterEach(() => {
    if (tempRoot) {
      rmSync(tempRoot, { recursive: true, force: true })
      tempRoot = undefined
    }
  })

  it('copies uploads into the KB dir, dedupes same-name files, and degrades when a source is missing', async () => {
    tempRoot = mkdtempSync(path.join(tmpdir(), 'knowledge-file-copy-'))
    const filesDataDir = path.join(tempRoot, 'Files')
    const knowledgeBaseDir = path.join(tempRoot, 'KnowledgeBase')
    mkdirSync(filesDataDir, { recursive: true })
    // v1 stores uploads at `{id}{ext}` and the migrator locates the source by id+ext, not `name`.
    writeFileSync(path.join(filesDataDir, 'fileA.pdf'), 'A')
    writeFileSync(path.join(filesDataDir, 'fileB.pdf'), 'B')
    // 'fileC.pdf' intentionally not written — its source is missing.

    const dexieFiles: FileMetadata[] = [
      dexieFileRow({ id: 'fileA', name: 'fileA.pdf', origin_name: 'report.pdf' }),
      dexieFileRow({ id: 'fileB', name: 'fileB.pdf', origin_name: 'report.pdf' }),
      dexieFileRow({ id: 'fileC', name: 'fileC.pdf', origin_name: 'missing.pdf' })
    ]
    const reduxKnowledge = {
      bases: [
        {
          id: 'kb-1',
          name: 'KB One',
          dimensions: 1024,
          model: { id: 'emb', name: 'emb', provider: 'openai' },
          items: [
            { id: 'item-a', type: 'file', content: 'fileA' },
            { id: 'item-b', type: 'file', content: 'fileB' },
            { id: 'item-c', type: 'file', content: 'fileC' }
          ]
        }
      ]
    }

    const ctx = makeCtx(dbh, dexieFiles, reduxKnowledge, { knowledgeBaseDir, filesDataDir })

    const migrator = new KnowledgeMigrator()
    const prepare = await migrator.prepare(ctx)
    expect(prepare.success).toBe(true)
    const baseId = (migrator as unknown as { preparedBases: { id: string }[] }).preparedBases[0].id

    const execute = await migrator.execute(ctx)
    expect(execute.success).toBe(true)
    // Execute-phase warnings are returned on the result (not just logged), so the engine
    // can surface "kept but not reindexable" diagnostics in the migration report.
    expect(execute.warnings?.some((w) => w.includes('source missing') && w.includes('fileC.pdf'))).toBe(true)

    const rows = await dbh.db.select({ data: knowledgeItemTable.data }).from(knowledgeItemTable)
    const relativePaths = rows.map((row) => (row.data as { relativePath: string }).relativePath).sort()
    // report.pdf appears twice in the same base → deduped; the missing one keeps its name.
    expect(relativePaths).toEqual(['missing.pdf', 'report.pdf', 'report_1.pdf'])

    // Present sources are copied into the KB dir's `raw/` material root with their finalized relativePath.
    const copiedNames = ['report.pdf', 'report_1.pdf']
    const copiedContents = copiedNames
      .map((name) => readFileSync(path.join(knowledgeBaseDir, baseId, 'raw', name), 'utf8'))
      .sort()
    expect(copiedContents).toEqual(['A', 'B'])

    // The missing source is not copied, but its item is kept.
    expect(existsSync(path.join(knowledgeBaseDir, baseId, 'raw', 'missing.pdf'))).toBe(false)
    const warnings = (migrator as unknown as { warnings: string[] }).warnings
    expect(warnings.some((w) => w.includes('source missing') && w.includes('fileC.pdf'))).toBe(true)
  })

  it('locates the source via id+ext even when a deduped upload has a malformed double-extension name', async () => {
    // v1 FileStorage.findDuplicateFile returns a malformed FileMetadata on a second upload of the
    // same bytes: `name` gains a double extension (a1b2.pdf.pdf) and `origin_name` is set to the
    // storage name. Trusting `name` would resolve to a path that does not exist, so the bytes would
    // never reach raw/ and the item would be permanently unreindexable. The migrator must rebuild
    // the storage name from `{id}{ext}` (the real on-disk name) and copy successfully.
    tempRoot = mkdtempSync(path.join(tmpdir(), 'knowledge-file-copy-dedup-'))
    const filesDataDir = path.join(tempRoot, 'Files')
    const knowledgeBaseDir = path.join(tempRoot, 'KnowledgeBase')
    mkdirSync(filesDataDir, { recursive: true })
    // Real on-disk file is `{id}{ext}`; the malformed `name` (a1b2.pdf.pdf) does NOT exist.
    writeFileSync(path.join(filesDataDir, 'a1b2.pdf'), 'DUP')

    const dexieFiles: FileMetadata[] = [
      dexieFileRow({ id: 'a1b2', name: 'a1b2.pdf.pdf', origin_name: 'a1b2.pdf', ext: '.pdf' })
    ]
    const reduxKnowledge = {
      bases: [
        {
          id: 'kb-dup',
          name: 'KB Dup',
          dimensions: 1024,
          model: { id: 'emb', name: 'emb', provider: 'openai' },
          items: [{ id: 'item-dup', type: 'file', content: 'a1b2' }]
        }
      ]
    }

    const ctx = makeCtx(dbh, dexieFiles, reduxKnowledge, { knowledgeBaseDir, filesDataDir })

    const migrator = new KnowledgeMigrator()
    expect((await migrator.prepare(ctx)).success).toBe(true)
    const baseId = (migrator as unknown as { preparedBases: { id: string }[] }).preparedBases[0].id
    const execute = await migrator.execute(ctx)
    expect(execute.success).toBe(true)

    const [row] = await dbh.db.select({ data: knowledgeItemTable.data }).from(knowledgeItemTable)
    const relativePath = (row.data as { relativePath: string }).relativePath
    // Bytes are copied (located via id+ext), so the item stays reindexable — no "source missing".
    expect(readFileSync(path.join(knowledgeBaseDir, baseId, 'raw', relativePath), 'utf8')).toBe('DUP')
    const warnings = (migrator as unknown as { warnings: string[] }).warnings
    expect(warnings.some((w) => w.includes('source missing'))).toBe(false)
  })

  it('falls back to the storage name when a legacy file has a blank origin_name', async () => {
    tempRoot = mkdtempSync(path.join(tmpdir(), 'knowledge-file-copy-blank-'))
    const filesDataDir = path.join(tempRoot, 'Files')
    const knowledgeBaseDir = path.join(tempRoot, 'KnowledgeBase')
    mkdirSync(filesDataDir, { recursive: true })
    writeFileSync(path.join(filesDataDir, 'fileZ.pdf'), 'Z')

    const dexieFiles: FileMetadata[] = [dexieFileRow({ id: 'fileZ', name: 'fileZ.pdf', origin_name: '' })]
    const reduxKnowledge = {
      bases: [
        {
          id: 'kb-blank',
          name: 'KB Blank',
          dimensions: 1024,
          model: { id: 'emb', name: 'emb', provider: 'openai' },
          items: [{ id: 'item-z', type: 'file', content: 'fileZ' }]
        }
      ]
    }

    const ctx = makeCtx(dbh, dexieFiles, reduxKnowledge, { knowledgeBaseDir, filesDataDir })

    const migrator = new KnowledgeMigrator()
    expect((await migrator.prepare(ctx)).success).toBe(true)
    const baseId = (migrator as unknown as { preparedBases: { id: string }[] }).preparedBases[0].id
    expect((await migrator.execute(ctx)).success).toBe(true)

    const [row] = await dbh.db.select({ data: knowledgeItemTable.data }).from(knowledgeItemTable)
    const relativePath = (row.data as { relativePath: string }).relativePath
    // Falls back to the sanitized storage name (never blank).
    expect(relativePath).toBe('fileZ.pdf')
    // The stored row survives the read path that lists items — a blank
    // relativePath would throw here and poison the whole base.
    expect(FileItemDataSchema.safeParse(row.data).success).toBe(true)
    // Copied to a real file under the base dir's `raw/` material root, not onto the base dir itself.
    expect(readFileSync(path.join(knowledgeBaseDir, baseId, 'raw', relativePath), 'utf8')).toBe('Z')
  })

  it("reserves the processed-markdown slot so a PDF and a real .md sibling don't collide", async () => {
    // A base with a file processor that holds both report.pdf and a real report.md: without
    // reserving the prospective .md artifact slot, a later reindex of report.pdf would try to
    // write report.md onto the existing sibling and hard-fail. The migrator must dedup so the
    // PDF's prospective artifact slot never equals the real .md sibling's path.
    tempRoot = mkdtempSync(path.join(tmpdir(), 'knowledge-file-copy-md-'))
    const filesDataDir = path.join(tempRoot, 'Files')
    const knowledgeBaseDir = path.join(tempRoot, 'KnowledgeBase')
    mkdirSync(filesDataDir, { recursive: true })
    writeFileSync(path.join(filesDataDir, 'fpdf.pdf'), 'PDF')
    writeFileSync(path.join(filesDataDir, 'fmd.md'), 'MD')

    const dexieFiles: FileMetadata[] = [
      dexieFileRow({ id: 'fpdf', name: 'fpdf.pdf', origin_name: 'report.pdf', ext: '.pdf' }),
      dexieFileRow({ id: 'fmd', name: 'fmd.md', origin_name: 'report.md', ext: '.md' })
    ]
    const reduxKnowledge = {
      bases: [
        {
          id: 'kb-md',
          name: 'KB Md',
          dimensions: 1024,
          model: { id: 'emb', name: 'emb', provider: 'openai' },
          // A configured preprocessor makes report.pdf a processable source that will emit report.md.
          preprocessProvider: { type: 'preprocess', provider: { id: 'mineru' } },
          items: [
            { id: 'item-pdf', type: 'file', content: 'fpdf' },
            { id: 'item-md', type: 'file', content: 'fmd' }
          ]
        }
      ]
    }

    const ctx = makeCtx(dbh, dexieFiles, reduxKnowledge, { knowledgeBaseDir, filesDataDir })

    const migrator = new KnowledgeMigrator()
    expect((await migrator.prepare(ctx)).success).toBe(true)
    const baseId = (migrator as unknown as { preparedBases: { id: string }[] }).preparedBases[0].id
    expect((await migrator.execute(ctx)).success).toBe(true)

    const rows = await dbh.db.select({ data: knowledgeItemTable.data }).from(knowledgeItemTable)
    const relativePaths = rows.map((row) => (row.data as { relativePath: string }).relativePath)
    // PDF keeps its name; the real markdown sibling is bumped so the PDF's prospective
    // processed-artifact slot (report.md) stays free for the processor.
    expect(relativePaths.slice().sort()).toEqual(['report.pdf', 'report_1.md'])
    // The invariant: the PDF's prospective .md artifact must not equal a real sibling's path.
    expect(relativePaths).not.toContain('report.md')
    // Both sources copied into raw/.
    expect(readFileSync(path.join(knowledgeBaseDir, baseId, 'raw', 'report.pdf'), 'utf8')).toBe('PDF')
    expect(readFileSync(path.join(knowledgeBaseDir, baseId, 'raw', 'report_1.md'), 'utf8')).toBe('MD')
  })
})
