// replaceComparisonFile (KAR-845 Teil 2): der COMPENSATE-Pfad — die in
// program-status benannte Testlücke („replaceComparisonFile hat repo-weit
// keinen eigenen Test; compensate-Pfad ungetestet"). Der Reparse-Wrapper-Test
// (reparse-comparison-file.test.ts) deckt bewusst nur die Wrapper-Logik; hier
// wird ingestQafUpload gemockt, damit jeder Guard-Abbruch NACH dem Insert
// erreichbar ist. Wichtigste Aussagen: (1) compensate entfernt exakt die
// eingefügte qaf_file-Zeile plus das Upload-Objekt — nie mehr; (2) ein
// recompare-Fehler rollt die Zeiger auf den FRISCH gelesenen Stand zurück,
// BEVOR compensate läuft; (3) der Happy-Path berührt compensate nicht
// (Gegenprobe). Mock-Strategie wie reparse-comparison-file.test.ts.

import { describe, it, expect, vi, beforeEach } from 'vitest'

const COMPARISON_ID = '22222222-2222-4222-8222-222222222222'
const PROJECT_ID = '33333333-3333-4333-8333-333333333333'
const UPLOAD_PATH = `${PROJECT_ID}/55555555-5555-4555-8555-555555555555-neu.xlsx`
const OLD_ALT = 'file-alt-old'
const OLD_NEU = 'file-neu-old'
const NEW_FILE_ID = 'new-file-1'

interface MockConfig {
  /** Zeiger der ZWEITEN qaf_comparison-Lesung (re-read vor dem Patch) —
   * bewusst abweichend setzbar, um zu beweisen, dass der Rollback die
   * frischen Werte nimmt (concurrent swap), nicht die der ersten Lesung. */
  freshAlt?: string | null
  freshNeu?: string | null
  fileDeleteError?: string
  /** Operation-Lock: upsert meldet Konflikt (Lock vergeben). */
  lockConflict?: boolean
  /** locked_at des bereits gehaltenen Locks (ISO). Der Takeover-Mock wertet
   * den CAS REAL aus (lockHeldSince < übergebener cutoff) — ein Vorzeichen-/
   * Operatorfehler in der Cutoff-Berechnung fällt damit im Test um
   * (Review-Finding zu PR #500: Flag-gesteuerte Mocks testen keine Zeitlogik). */
  lockHeldSince?: string
  /** Tabelle fehlt (Migration nicht applied): upsert errort mit 42P01. */
  lockTableMissing?: boolean
  lockReleaseError?: string
}

function makeMocks(cfg: MockConfig = {}) {
  const comparisonRow = {
    id: COMPARISON_ID,
    project_id: PROJECT_ID,
    part_number: 'P-1',
    baseline_file_id: OLD_ALT,
    comparison_file_id: OLD_NEU,
    comparison_mode: 'summary',
    engine_version: '1.5.0',
  }
  const freshRow = {
    baseline_file_id: cfg.freshAlt === undefined ? OLD_ALT : cfg.freshAlt,
    comparison_file_id: cfg.freshNeu === undefined ? OLD_NEU : cfg.freshNeu,
    part_number: 'P-1',
  }
  let comparisonSelects = 0

  // Generic statt benanntem (ungenutztem) Parameter: der Patch-Typ macht
  // update.mock.calls[i][0] typsicher, ohne no-unused-vars anzuschlagen.
  const update = vi.fn<(patch: Record<string, unknown>) => { eq: ReturnType<typeof vi.fn> }>(() => ({
    eq: vi.fn(() => Promise.resolve({ error: null })),
  }))
  const fileDelete = vi.fn(() => ({
    eq: vi.fn(() => Promise.resolve({ error: cfg.fileDeleteError ? { message: cfg.fileDeleteError } : null })),
  }))
  const partUpsert = vi.fn(() => Promise.resolve({ error: null }))
  const auditInsert = vi.fn(() => Promise.resolve({ error: null }))

  // Operation-Lock-Ketten. Erwerb: upsert(row, opts).select() — Zeile zurück
  // = erworben, [] = vergeben, 42P01 = Tabelle fehlt (Degrade-Pfad).
  // Generics statt benannter (ungenutzter) Parameter — wie beim update-Mock.
  const lockUpsert = vi.fn<(row: Record<string, unknown>, opts: Record<string, unknown>) => { select: ReturnType<typeof vi.fn> }>((row) => ({
    select: vi.fn(() =>
      Promise.resolve(
        cfg.lockTableMissing
          ? { data: null, error: { code: '42P01', message: 'relation "public.qaf_operation_lock" does not exist' } }
          : cfg.lockConflict
            ? { data: [], error: null }
            : { data: [{ locked_by: row.locked_by }], error: null },
      ),
    ),
  }))
  // Takeover-CAS: update(patch).eq().eq().lt(locked_at, cutoff).select().
  // Der lt-Vergleich läuft ECHT gegen cfg.lockHeldSince (ISO-Strings desselben
  // Formats vergleichen lexikographisch = chronologisch) — wie in Postgres.
  const lockTakeover = vi.fn<(patch: Record<string, unknown>) => unknown>(() => {
    const chain = {
      eq: vi.fn(() => chain),
      lt: vi.fn((_col: string, cutoff: string) => ({
        select: vi.fn(() =>
          Promise.resolve(
            cfg.lockHeldSince !== undefined && cfg.lockHeldSince < cutoff
              ? { data: [{ locked_by: 'taken' }], error: null }
              : { data: [], error: null },
          ),
        ),
      })),
    }
    return chain
  })
  // Release: delete().eq().eq().eq(locked_by, opId) — awaited am dritten eq.
  // Die eq-Argumente werden mitgeschrieben, damit Tests beweisen können,
  // dass NUR das eigene Lock (locked_by = opId des Erwerbs) gelöst wird.
  const lockReleaseEqCalls: Array<[string, unknown]> = []
  const lockDelete = vi.fn(() => {
    const eq3 = vi.fn((col: string, val: unknown) => {
      lockReleaseEqCalls.push([col, val])
      return Promise.resolve({ error: cfg.lockReleaseError ? { message: cfg.lockReleaseError } : null })
    })
    const eq2 = vi.fn((col: string, val: unknown) => {
      lockReleaseEqCalls.push([col, val])
      return { eq: eq3 }
    })
    const eq1 = vi.fn((col: string, val: unknown) => {
      lockReleaseEqCalls.push([col, val])
      return { eq: eq2 }
    })
    return { eq: eq1 }
  })

  const supabase = {
    auth: { getClaims: vi.fn(() => Promise.resolve({ data: { claims: { sub: 'user-1' } }, error: null })) },
    from: vi.fn((table: string) => {
      if (table === 'qaf_comparison') {
        return {
          select: vi.fn(() => {
            comparisonSelects += 1
            const row = comparisonSelects === 1 ? comparisonRow : freshRow
            return { eq: vi.fn(() => ({ maybeSingle: vi.fn(() => Promise.resolve({ data: row, error: null })) })) }
          }),
          update,
        }
      }
      if (table === 'qaf_file') {
        return {
          select: vi.fn(() => ({
            eq: vi.fn(() => ({ maybeSingle: vi.fn(() => Promise.resolve({ data: { g60_meta: null }, error: null })) })),
          })),
          delete: fileDelete,
        }
      }
      if (table === 'qaf_manufacturing_step') {
        return {
          select: vi.fn(() => ({
            eq: vi.fn(() => Promise.resolve({ data: [{ position_number: '10', process_name: 'Fräsen' }], error: null })),
          })),
        }
      }
      if (table === 'qaf_part') return { upsert: partUpsert }
      if (table === 'qaf_audit_log') return { insert: auditInsert }
      if (table === 'qaf_operation_lock') return { upsert: lockUpsert, update: lockTakeover, delete: lockDelete }
      throw new Error(`unexpected table queried: ${table}`)
    }),
  }

  const remove = vi.fn(() => Promise.resolve({ error: null }))
  const admin = { storage: { from: vi.fn(() => ({ remove })) } }

  return { supabase, admin, remove, update, fileDelete, partUpsert, auditInsert, lockUpsert, lockDelete, lockReleaseEqCalls }
}

let current = makeMocks()

vi.mock('@/lib/supabase/server', () => ({
  createClient: vi.fn(() => Promise.resolve(current.supabase)),
}))
vi.mock('@/lib/supabase/admin', () => ({
  createAdminClient: vi.fn(() => current.admin),
}))
vi.mock('@/lib/logger', () => ({
  logger: { error: vi.fn(), warn: vi.fn(), info: vi.fn(), debug: vi.fn() },
}))
vi.mock('../ingest-core', () => ({ ingestQafUpload: vi.fn() }))
// Verhindert zugleich, dass die echte actions.ts (RPC-Familie) im Test lädt.
vi.mock('@/app/qaf-differences/actions', () => ({ recompareComparison: vi.fn() }))

const { ingestQafUpload } = await import('../ingest-core')
const { recompareComparison } = await import('@/app/qaf-differences/actions')
const { logger } = await import('@/lib/logger')
// Einmal top-level laden — der Erst-Import (Transform des Modulgraphen)
// zählt sonst in die Laufzeit des ERSTEN Tests und reißt dessen Timeout;
// der abgebrochene Zombie-Lauf konsumiert dann die Mocks des Folgetests.
const { replaceComparisonFile } = await import('../file-replace-actions')
const ingestMock = vi.mocked(ingestQafUpload)
const recompareMock = vi.mocked(recompareComparison)

type IngestReturn = Awaited<ReturnType<typeof ingestQafUpload>>

function summaryIngest(partNumber: string): IngestReturn {
  return {
    kind: 'summary',
    fileId: NEW_FILE_ID,
    fileName: 'neu.xlsx',
    stepIds: [],
    summary: {
      partNumber: { value: partNumber },
      partName: { value: 'Halter' },
      variant: { value: null },
      supplier: { value: 'ACME' },
      quotationDate: { value: '2026-08-01T00:00:00.000Z' },
      requestVersion: { value: '2' },
    },
  } as unknown as IngestReturn
}

/** ingest legt die Zeile an (onFileInserted) und liefert `result`. */
function ingestSucceedsWith(result: IngestReturn) {
  ingestMock.mockImplementation(async (_sb, _admin, _ctx, _upload, onFileInserted) => {
    onFileInserted(NEW_FILE_ID)
    return result
  })
}

beforeEach(() => {
  vi.clearAllMocks()
  current = makeMocks()
  ingestSucceedsWith(summaryIngest('P-1'))
  recompareMock.mockResolvedValue({ ok: true, data: null })
})

async function replace(
  role: 'alt' | 'neu' = 'alt',
  options?: { auditAction?: 'replace_file' | 'reparse_file' },
  path: string = UPLOAD_PATH,
) {
  return replaceComparisonFile(COMPARISON_ID, role, { path, name: 'neu.xlsx' }, options)
}

describe('replaceComparisonFile — compensate path', () => {
  it('refuses an upload path outside the project shape before ingest or storage are touched', async () => {
    const res = await replace('alt', undefined, 'fremdes-projekt/55555555-5555-4555-8555-555555555555-neu.xlsx')
    expect(res.ok).toBe(false)
    if (!res.ok) expect(res.error).toBe('invalid storage path')
    expect(ingestMock).not.toHaveBeenCalled()
    expect(current.remove).not.toHaveBeenCalled()
    expect(current.fileDelete).not.toHaveBeenCalled()
  })

  it('wrong-kind guard compensates: the inserted qaf_file row is deleted and exactly the upload object removed', async () => {
    ingestSucceedsWith({ ...summaryIngest('P-1'), kind: 'multi_qaf', summary: undefined } as unknown as IngestReturn)
    const res = await replace('alt')
    expect(res.ok).toBe(false)
    if (!res.ok) expect(res.error).toContain('Summary-QAF')
    expect(current.fileDelete).toHaveBeenCalledTimes(1)
    const deleteEq = current.fileDelete.mock.results[0].value as { eq: ReturnType<typeof vi.fn> }
    expect(deleteEq.eq).toHaveBeenCalledWith('id', NEW_FILE_ID)
    expect(current.remove).toHaveBeenCalledTimes(1)
    expect(current.remove).toHaveBeenCalledWith([UPLOAD_PATH])
    // Zeiger und Teil-Identität bleiben unangetastet.
    expect(current.update).not.toHaveBeenCalled()
    expect(current.partUpsert).not.toHaveBeenCalled()
  })

  it('a Sachnummer mismatch compensates and names both numbers without touching qaf_part', async () => {
    ingestSucceedsWith(summaryIngest('X-9'))
    const res = await replace('alt')
    expect(res.ok).toBe(false)
    if (!res.ok) {
      expect(res.error).toContain('Sachnummer weicht ab')
      expect(res.error).toContain('X-9')
      expect(res.error).toContain('P-1')
    }
    expect(current.fileDelete).toHaveBeenCalledTimes(1)
    expect(current.remove).toHaveBeenCalledWith([UPLOAD_PATH])
    expect(current.partUpsert).not.toHaveBeenCalled()
    expect(current.update).not.toHaveBeenCalled()
  })

  it('a failed recompare rolls the pointers back to the FRESH pre-patch values, then compensates', async () => {
    // Die zweite Lesung liefert andere Zeiger als die erste (concurrent
    // swap während des langsamen Ingest) — der Rollback MUSS diese frischen
    // Werte wiederherstellen, nicht die der ersten Lesung.
    current = makeMocks({ freshAlt: 'file-alt-fresh', freshNeu: 'file-neu-fresh' })
    recompareMock.mockResolvedValue({ ok: false, error: 'induced recompare failure' })
    const res = await replace('alt')
    expect(res.ok).toBe(false)
    if (!res.ok) expect(res.error).toContain('Neu-Berechnung fehlgeschlagen')

    expect(current.update).toHaveBeenCalledTimes(2)
    expect(current.update.mock.calls[0][0]).toEqual({ baseline_file_id: NEW_FILE_ID, status: 'draft' })
    expect(current.update.mock.calls[1][0]).toEqual({
      baseline_file_id: 'file-alt-fresh',
      comparison_file_id: 'file-neu-fresh',
    })
    // Rollback VOR compensate — sonst zeigte der Vergleich kurzzeitig auf
    // eine bereits gelöschte Datei.
    const rollbackOrder = current.update.mock.invocationCallOrder[1]
    expect(rollbackOrder).toBeLessThan(current.fileDelete.mock.invocationCallOrder[0])
    expect(rollbackOrder).toBeLessThan(current.remove.mock.invocationCallOrder[0])
    expect(current.remove).toHaveBeenCalledWith([UPLOAD_PATH])
    expect(current.auditInsert).not.toHaveBeenCalled()
  })

  it('compensate is resilient: a failing row delete is logged and the storage cleanup still runs', async () => {
    current = makeMocks({ fileDeleteError: 'row locked' })
    ingestSucceedsWith({ ...summaryIngest('P-1'), kind: 'multi_qaf', summary: undefined } as unknown as IngestReturn)
    const res = await replace('alt')
    expect(res.ok).toBe(false)
    // Der Nutzer sieht den Guard-Fehler, nicht den Aufräum-Fehler.
    if (!res.ok) expect(res.error).toContain('Summary-QAF')
    expect(logger.error).toHaveBeenCalledWith(
      'qaf.replace compensate delete failed',
      expect.objectContaining({ insertedFileId: NEW_FILE_ID }),
    )
    expect(current.remove).toHaveBeenCalledWith([UPLOAD_PATH])
  })

  it('an ingest throw before any insert compensates storage only — no qaf_file delete', async () => {
    ingestMock.mockImplementation(async () => {
      throw new Error('unsupported file type (.xlsx/.xlsm/.xls only)')
    })
    const res = await replace('alt')
    expect(res.ok).toBe(false)
    if (!res.ok) expect(res.error).toContain('unsupported file type')
    expect(current.fileDelete).not.toHaveBeenCalled()
    expect(current.remove).toHaveBeenCalledTimes(1)
    expect(current.remove).toHaveBeenCalledWith([UPLOAD_PATH])
  })

  it('counter-probe (happy path): pointer patch + draft status + audit default, compensate untouched', async () => {
    const res = await replace('neu')
    expect(res.ok).toBe(true)
    expect(current.update).toHaveBeenCalledTimes(1)
    expect(current.update.mock.calls[0][0]).toEqual({ comparison_file_id: NEW_FILE_ID, status: 'draft' })
    expect(recompareMock).toHaveBeenCalledWith(COMPARISON_ID, { pins: [], refreshPlausibility: true })
    expect(current.auditInsert).toHaveBeenCalledWith(
      expect.objectContaining({
        action: 'replace_file',
        entity_id: COMPARISON_ID,
        detail: expect.objectContaining({ role: 'neu', newFileId: NEW_FILE_ID, by: 'user-1' }),
      }),
    )
    expect(current.fileDelete).not.toHaveBeenCalled()
    expect(current.remove).not.toHaveBeenCalled()
    // KAR-912-Carry-Forward: die Identitäten der ALTEN Datei erreichen den
    // Ingest als Guard-Input, upsertPart bleibt beim Replace aus.
    const ctx = ingestMock.mock.calls[0][2]
    expect(ctx.upsertPart).toBe(false)
    expect(ctx.carryForwardOldRowIdentities).toEqual([{ positionsnummer: '10', prozessbezeichnung: 'Fräsen' }])
  })

  it("stamps auditAction 'reparse_file' when the caller names the intent", async () => {
    const res = await replace('alt', { auditAction: 'reparse_file' })
    expect(res.ok).toBe(true)
    expect(current.auditInsert).toHaveBeenCalledWith(expect.objectContaining({ action: 'reparse_file' }))
  })
})

describe('replaceComparisonFile — operation lock', () => {
  it('a FRESH held lock refuses the operation by name — no ingest, no cleanup, and the FOREIGN lock is never released', async () => {
    // 1 Minute alt: deutlich innerhalb des 15-min-TTL. Der Takeover-Mock
    // wertet den echten cutoff aus — stünde der cutoff fälschlich in der
    // Zukunft (Vorzeichenfehler), würde dieses frische Lock übernommen und
    // der Test fiele um.
    current = makeMocks({ lockConflict: true, lockHeldSince: new Date(Date.now() - 60_000).toISOString() })
    const res = await replace('alt')
    expect(res.ok).toBe(false)
    if (!res.ok) expect(res.error).toContain('läuft bereits')
    expect(ingestMock).not.toHaveBeenCalled()
    expect(current.remove).not.toHaveBeenCalled()
    expect(current.fileDelete).not.toHaveBeenCalled()
    // Das fremde Lock gehört dem laufenden Vorgang — kein delete.
    expect(current.lockDelete).not.toHaveBeenCalled()
  })

  it('a STALE lock (older than the TTL) is taken over via the CAS and the flow proceeds to success', async () => {
    // 20 Minuten alt: jenseits des 15-min-TTL → Übernahme muss gelingen.
    current = makeMocks({ lockConflict: true, lockHeldSince: new Date(Date.now() - 20 * 60_000).toISOString() })
    const res = await replace('alt')
    expect(res.ok).toBe(true)
    expect(current.lockDelete).toHaveBeenCalledTimes(1)
  })

  it('happy path releases exactly once, keyed to the OWN operation id from the acquire', async () => {
    const res = await replace('alt')
    expect(res.ok).toBe(true)
    expect(current.lockUpsert).toHaveBeenCalledTimes(1)
    const acquiredRow = current.lockUpsert.mock.calls[0][0]
    expect(current.lockDelete).toHaveBeenCalledTimes(1)
    expect(current.lockReleaseEqCalls).toContainEqual(['locked_by', acquiredRow.locked_by])
  })

  it('the lock is released even when a guard aborts and compensates (finally)', async () => {
    ingestSucceedsWith({ ...summaryIngest('P-1'), kind: 'multi_qaf', summary: undefined } as unknown as IngestReturn)
    const res = await replace('alt')
    expect(res.ok).toBe(false)
    expect(current.fileDelete).toHaveBeenCalledTimes(1)
    expect(current.lockDelete).toHaveBeenCalledTimes(1)
  })

  it('a missing lock table (migration not applied) degrades honestly: warn, unlocked flow, no release', async () => {
    current = makeMocks({ lockTableMissing: true })
    const res = await replace('alt')
    expect(res.ok).toBe(true)
    expect(logger.warn).toHaveBeenCalledWith(
      expect.stringContaining('operation lock unavailable'),
      expect.objectContaining({ comparisonId: COMPARISON_ID }),
    )
    expect(current.lockDelete).not.toHaveBeenCalled()
  })

  it('a failed lock release is logged and does not change the operation result', async () => {
    current = makeMocks({ lockReleaseError: 'network hiccup' })
    const res = await replace('alt')
    expect(res.ok).toBe(true)
    expect(logger.error).toHaveBeenCalledWith(
      'qaf.replace lock release failed',
      expect.objectContaining({ comparisonId: COMPARISON_ID, error: 'network hiccup' }),
    )
  })
})
