// Gemeinsame Maschinerie der real-file-Validierung des Capability-Detectors.
//
// Warum ein eigenes Modul: die Prüfung zerfällt in zwei Geltungsbereiche, die
// nicht dasselbe Gewicht haben (siehe die beiden Testdateien daneben) —
// Kernabnahme und `deferred_by_product_owner`. Beide brauchen dieselbe
// Klassifikation, dieselbe Matrix-Berechnung und dieselben Struktur-Invarianten.
// Die zu duplizieren hiesse, dass eine Korrektur an einer Invariante die andere
// Seite still ungeprueft laesst.
//
// Kein `.test.ts` im Namen: vitest sammelt nach `**/__tests__/**/*.test.ts`
// (vitest.config.ts), diese Datei enthaelt keine Tests. Gleiche Konvention wie
// `golden-fixtures.ts` daneben.
//
// VERTRAULICHKEIT (KAR-926 F2, unveraendert aus der Vorgaengerdatei
// uebernommen): hier steht NIEMALS ein realer Dateiname als Quelltext-Literal.
// Jeder Repraesentant wird zur LAUFZEIT ueber seine strukturelle bzw.
// Detektor-Klassifikation gefunden, nicht ueber einen festen Namen und nicht
// ueber ein Manifest. `console.log` in den Testdateien gibt Dateinamen als
// Laufzeitdaten aus — das ist die Unterscheidung, die der KAR-926-Praezedenzfall
// selbst zieht ("those must never appear as literals in committed repo TEXT ...
// only as runtime DATA", siehe qaf-type-detector.real-files.test.ts).
//
// Warum KEIN Manifest, obwohl KAR-926 selbst eines benutzt: dort war es der
// Behelf fuer eine von Hand gepflegte Multi-QAF-Liste. Hier benennt die Aufgabe
// nur den ABSOLUTEN PFAD des Korpus, keine einzelnen Dateien — ein Manifest
// waere reines Veraltungsrisiko, die Klassifikation zur Laufzeit ist strikt
// robuster. Jede Zusicherung unten haengt deshalb an dem, was die gefundene
// Datei tatsaechlich enthaelt, nie an einer festen Erwartung an eine bestimmte
// Datei — die Suite bleibt gueltig, wenn sich der Korpusinhalt aendert.

import { expect } from 'vitest'
import { existsSync, readdirSync, readFileSync, statSync } from 'node:fs'
import path from 'node:path'
import { parseQAFTemplate } from '@/lib/qaf-parser'
import { loadExcelWorkbook, parseSummarySheetFromWorkbook, summarizeWorkbookSheets } from '../../workbook-adapter'
import { matchesModuleSheetName } from '../../module-sheet-names'
import { detectG60 } from '../../g60/parser'
import { g60WorkbookFromExcelJs } from '../../g60/bridge'
import { detectMultiQaf, multiQafDetectionInputFromExcelJs, type MultiQafClassification } from '../../qaf-type-detector'
import { parseMaterialSheet } from '../../material-parser'
import { parseSbmSheet } from '../../sbm-parser'
import { parseRmrSheet } from '../../rmr-parser'
import { parseLogisticsSheet } from '../../logistics-parser'
import { parseLccnSheet } from '../../lccn-parser'
import { parseCo2eSheet } from '../../co2e-parser'
import { computeWorkbookCapabilityMatrix, type WorkbookCapabilityInput } from '../capability-detector'
import { CAPABILITY_MODULES, type WorkbookCapabilityMatrix } from '../types'

/**
 * Der volle, vertrauliche Korpus. Existiert nur auf dem Entwicklungsrechner —
 * genau deshalb lief diese Suite in CI nie.
 */
export const KORPUS_DIR = '/home/aria/work/qaf-corpus/incoming'

/**
 * Die vier committeten, anonymisierten Fixtures. Sie liegen im Repository und
 * stehen damit auch in CI zur Verfügung.
 *
 * Nachgemessen an den Blattnamen decken sie drei der vier Struktur-Familien ab,
 * die diese Suite als Repräsentanten sucht:
 *
 *   - `LEGACY_DE_SUMMARY` + Fertigungskosten → `qaf-8.8-hice-vergabe`
 *     (Blätter „Zusammenfassung" und „Fertigungskosten")
 *   - `V9_SUMMARY` → beide `qaf-9.1-standard-*`
 *     (Blätter „SUMMARY" und „MANUFACTURING COSTS"; auch die als „DE" benannte
 *     Fixture trägt englische Blattnamen — die Familie hängt an der Struktur,
 *     nicht am Dateinamen)
 *   - ohne-INPUT → alle vier (keine trägt ein Blatt „INPUT")
 *
 * **Nicht** abgedeckt: die BT-Familie. Ihr Erkennungsmerkmal ist der
 * Dateinamensfilter `/_BT_/i`, und keine Fixture trägt ihn. Das ist eine
 * benannte Lücke, keine stille — `btFamilieImBestand()` macht sie prüfbar.
 */
export const FIXTURE_DIR = path.resolve(__dirname, '../../../../../tests/fixtures/qaf-golden')

/**
 * Welche Quelle gilt? Der volle Korpus, wenn er da ist — sonst die Fixtures.
 *
 * Die Reihenfolge ist Absicht: wo der Korpus vorliegt, soll die Suite ihre
 * ursprüngliche Aussagekraft behalten (Repräsentanten aus 374 realen Dateien).
 * Die Fixtures sind der Rückfall, damit die Suite in CI überhaupt etwas prüft
 * statt zu schweigen.
 */
export const quellVerzeichnis = (): string => {
  // `QAF_FORCE_FIXTURES=1` erzwingt den Fixture-Zweig, auch wenn der Korpus
  // lokal liegt. Ohne diesen Schalter wäre der Zweig auf dem
  // Entwicklungsrechner **nicht prüfbar** — der Korpus verdeckt ihn, und ein
  // grüner Lauf sagte dann nichts über CI aus.
  if (process.env.QAF_FORCE_FIXTURES === '1') return FIXTURE_DIR
  return existsSync(KORPUS_DIR) ? KORPUS_DIR : FIXTURE_DIR
}

/** Rückwärtskompatibler Name; zeigt jetzt auf die tatsächlich benutzte Quelle. */
export const CORPUS_DIR = quellVerzeichnis()

/** Wie viele Dateien die aktuelle Quelle hergibt — für Berichtszeilen. */
export const quellUmfang = (): number => listCorpusFiles(quellVerzeichnis()).length

export const SUITE_TIMEOUT_MS = 240_000

/**
 * Läuft die Suite überhaupt? Jetzt auch dann, wenn nur die Fixtures da sind —
 * vorher entschied das allein die Existenz des lokalen Korpus.
 */
export const corpusVorhanden = (): boolean => existsSync(quellVerzeichnis())

/**
 * Läuft die Suite gegen den vollen Korpus oder gegen die vier Fixtures?
 *
 * Muss dieselbe Entscheidung treffen wie `quellVerzeichnis()`, sonst beschreibt
 * die Anzeige etwas anderes als das Gemessene. Eine erste Fassung prüfte nur
 * `!existsSync(KORPUS_DIR)` und meldete deshalb bei gesetztem
 * `QAF_FORCE_FIXTURES` „Quelle: KORPUS", während tatsächlich die Fixtures
 * gelesen wurden — die Funktion, die Ehrlichkeit herstellen soll, log selbst.
 */
export const laeuftAufFixtures = (): boolean => quellVerzeichnis() === FIXTURE_DIR

/**
 * Ist die BT-Familie in der aktuellen Quelle vertreten?
 *
 * Ohne diese Auskunft würde die Suite auf Fixtures eine Familie stillschweigend
 * überspringen und trotzdem grün melden. Die Tests fragen hier, statt es
 * anzunehmen.
 */
export const btFamilieImBestand = (): boolean => !laeuftAufFixtures()

/** Recursively list every .xlsx/.xlsm under CORPUS_DIR (top-level "QAFs/"
 * subfolder plus a couple of top-level files). .xls (old binary format) is
 * excluded — ExcelJS cannot load it, a documented pre-existing limitation
 * unrelated to this PR (gate-audit.md §3). */
export function listCorpusFiles(dir: string): string[] {
  if (!existsSync(dir)) return []
  const out: string[] = []
  for (const entry of readdirSync(dir)) {
    const full = path.join(dir, entry)
    const st = statSync(full)
    if (st.isDirectory()) out.push(...listCorpusFiles(full))
    else if (/\.(xlsx|xlsm)$/i.test(entry)) out.push(full)
  }
  return out.sort()
}

export interface ClassifiedFile {
  fullPath: string
  isG60: boolean
  summaryTemplate: 'QAF_LEGACY_DE_SUMMARY' | 'QAF_V9_SUMMARY' | null
  hasManufacturingSheet: boolean
  isEnManufacturingLabel: boolean
  hasInputSheet: boolean
  multiQafClassification: MultiQafClassification
  isBtFile: boolean
}

export async function classifyFile(fullPath: string): Promise<ClassifiedFile | null> {
  let wb: Awaited<ReturnType<typeof loadExcelWorkbook>>
  try {
    wb = await loadExcelWorkbook(readFileSync(fullPath))
  } catch {
    return null // unreadable (corrupt/legacy-binary/unsupported) — excluded.
  }
  const { summaryMetrics } = parseSummarySheetFromWorkbook(wb)
  const sheets = summarizeWorkbookSheets(wb)
  const manufacturingSheet = sheets.find((s) => matchesModuleSheetName(s.name, 'MANUFACTURING'))
  return {
    fullPath,
    isG60: detectG60(g60WorkbookFromExcelJs(wb)),
    summaryTemplate: summaryMetrics?.template ?? null,
    hasManufacturingSheet: manufacturingSheet !== undefined,
    // "EN label" signal without relying on module-sheet-names.ts's
    // language-tagging (which only fires for the DE "Fertigungskosten"
    // alias, see that module's own header) — a manufacturing-role sheet
    // whose name is NOT the DE "fertigungskosten" spelling is the EN/typo
    // variant by elimination (module-sheet-names.ts MANUFACTURING has
    // exactly 3 aliases: 1 DE, 2 EN/typo).
    isEnManufacturingLabel: manufacturingSheet !== undefined && !manufacturingSheet.name.toLowerCase().includes('fertigungskosten'),
    hasInputSheet: sheets.some((s) => s.name.trim().toLowerCase() === 'input'),
    multiQafClassification: detectMultiQaf(multiQafDetectionInputFromExcelJs(wb)).classification,
    isBtFile: /_BT_/i.test(path.basename(fullPath)),
  }
}

// Die Klassifikation liest JEDE Mappe des Korpus einmal vollstaendig ein. In der
// Vorgaengerfassung lag alles in einem einzigen `it`, also fiel sie genau einmal
// an; mit einem Test je Kategorie waere sie sonst sechsmal gelaufen. Der Cache
// gilt pro Modulinstanz — vitest isoliert Testdateien, die beiden Suiten
// klassifizieren also je einmal.
let klassifikationsCache: Promise<ClassifiedFile[]> | null = null

export function classifyCorpusOnce(): Promise<ClassifiedFile[]> {
  klassifikationsCache ??= (async () => {
    const files = listCorpusFiles(CORPUS_DIR)
    const classified: ClassifiedFile[] = []
    for (const f of files) {
      const c = await classifyFile(f)
      if (c) classified.push(c)
    }
    return classified
  })().catch((fehler: unknown) => {
    // Ein abgelehntes Promise im Cache bliebe dort liegen: jeder weitere Test
    // bekäme denselben Fehler, ohne dass je wieder klassifiziert wird, und die
    // Dateien hinter der Fehlerstelle blieben ungeprüft. `classifyFile` fängt
    // nur Ladefehler ab — eine ladbare, aber strukturell kaputte Mappe kann in
    // `detectG60`/`detectMultiQaf` weiterhin werfen.
    klassifikationsCache = null
    throw fehler
  })
  return klassifikationsCache
}

/** Per-module "was the underlying facet parse actually good" signal, read
 * directly off the raw parser outputs BEFORE they go through
 * computeWorkbookCapabilityMatrix — used by assertStructurallyPlausible
 * (KAR-959 review finding #4) to cross-check the matrix's derived status
 * against what was actually parsed, independent of the detector code under
 * test. Deliberately covers only the modules whose "was this good" signal is
 * a plain, unambiguous boolean off one parser result (MANUFACTURING/MATERIAL/
 * SBM/RMR/LOGISTICS/LC_CN) — SUMMARY (multi-slot) and CO2E (two merged
 * sub-parses) have more complex "found-ness" semantics already covered by
 * their own dedicated unit tests in capability-detector.test.ts, not
 * duplicated here. `rowCount` is only meaningful for the row-array facets
 * (MANUFACTURING/MATERIAL/SBM/RMR/LOGISTICS) — deriveModuleCapability reports
 * MISSING for a `rowCount === 0` facet even when `coreFieldsFound` is true
 * (header structurally readable, but the sheet has zero data rows — a real,
 * legitimate corpus case, not a bug), so the AVAILABLE/PARTIAL invariant
 * below only applies when rows were actually extracted; LC_CN (single-record,
 * no "rows") leaves `rowCount` undefined and is checked on `coreFieldsFound`
 * alone. */
export type FacetBehaviorSignal = { coreFieldsFound: boolean; degradationReason?: string; rowCount?: number }

export interface MatrixErgebnis {
  matrix: WorkbookCapabilityMatrix
  sheetNames: string[]
  facetSignals: Partial<Record<string, FacetBehaviorSignal>>
}

/** Same facet-parse sequence app/qaf-differences/actions.ts's ingestQafUpload
 * runs (KAR-959 task instruction point 4's actual production call site) —
 * exercised here against a real file instead of a synthetic fixture. Mirrors
 * the KAR-959 review finding #1 fix: the manufacturing-facet catch attaches a
 * PARSE_FAILED FacetDegradation, same as actions.ts's own P1-catch. */
export async function computeMatrixFor(fullPath: string): Promise<MatrixErgebnis> {
  const buffer = readFileSync(fullPath)
  const wb = await loadExcelWorkbook(buffer)
  const { summary, summaryMetrics } = parseSummarySheetFromWorkbook(wb)

  let steps: {
    length: number
    parseConfidence: number
    mappedFieldCount: number
    unmappedHeaders: string[]
    degradation?: { facet: 'manufacturing'; reason: 'PARSE_FAILED'; sheet: string | null; message: string }
  }
  try {
    const file = new File([new Uint8Array(buffer)], path.basename(fullPath), {
      type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
    })
    const parsed = await parseQAFTemplate(file)
    steps = { length: parsed.length, parseConfidence: parsed.parseConfidence, mappedFieldCount: parsed.mappedFieldCount, unmappedHeaders: parsed.unmappedHeaders }
  } catch (err) {
    steps = {
      length: 0,
      parseConfidence: 0,
      mappedFieldCount: 0,
      unmappedHeaders: [],
      degradation: { facet: 'manufacturing', reason: 'PARSE_FAILED', sheet: null, message: err instanceof Error ? err.message : String(err) },
    }
  }

  const [material, sbm, rmr, logistics, lccn, co2e] = await Promise.all([
    parseMaterialSheet(wb),
    parseSbmSheet(wb),
    parseRmrSheet(wb),
    parseLogisticsSheet(wb),
    parseLccnSheet(wb),
    parseCo2eSheet(wb),
  ])

  const input: WorkbookCapabilityInput = {
    sheets: summarizeWorkbookSheets(wb),
    summary,
    summaryMetrics,
    manufacturingSteps: steps,
    material,
    sbm,
    rmr,
    logistics,
    lccn,
    co2eMaterial: co2e?.materialRows ?? null,
    co2eSummary: co2e?.summary ?? null,
  }

  const facetSignals: Partial<Record<string, FacetBehaviorSignal>> = {
    MANUFACTURING: { coreFieldsFound: steps.length > 0, degradationReason: steps.degradation?.reason, rowCount: steps.length },
    ...(material ? { MATERIAL: { coreFieldsFound: material.coreFieldsFound, degradationReason: material.degradation?.reason, rowCount: material.length } } : {}),
    ...(sbm ? { SBM: { coreFieldsFound: sbm.coreFieldsFound, degradationReason: sbm.degradation?.reason, rowCount: sbm.length } } : {}),
    ...(rmr ? { RMR: { coreFieldsFound: rmr.coreFieldsFound, degradationReason: rmr.degradation?.reason, rowCount: rmr.length } } : {}),
    ...(logistics
      ? { LOGISTICS: { coreFieldsFound: logistics.coreFieldsFound, degradationReason: logistics.degradation?.reason, rowCount: logistics.length } }
      : {}),
    ...(lccn ? { LC_CN: { coreFieldsFound: lccn.meta.coreFieldsFound, degradationReason: lccn.meta.degradation?.reason } } : {}),
  }

  return { matrix: computeWorkbookCapabilityMatrix(input), sheetNames: wb.worksheets.map((w) => w.name), facetSignals }
}

/**
 * Structural + BEHAVIORAL invariants every matrix must satisfy regardless of
 * which real file produced it.
 *
 * KAR-959 review finding #4 fix (PR #326): the original version of this
 * function only checked that `m.status` is a member of the 5-value
 * CapabilityStatus union — trivially true for any well-typed
 * WorkbookCapabilityMatrix and therefore a tautology (it can never fail
 * regardless of what the detector actually computes). The checks added below
 * cross-reference the matrix's DERIVED status/source against independently-
 * read RAW parser signals (facetSignals — read directly off
 * material/sbm/rmr/logistics/lccn/steps in computeMatrixFor, before they
 * reach computeWorkbookCapabilityMatrix) and the workbook's own real sheet
 * names, so a detector regression that e.g. reports MISSING for a module that
 * actually parsed cleanly, or invents a source sheet that doesn't exist, now
 * fails this test against real corpus files.
 */
export function assertStructurallyPlausible(
  matrix: WorkbookCapabilityMatrix,
  sheetCount: number,
  sheetNames: string[],
  facetSignals: Partial<Record<string, FacetBehaviorSignal>>,
) {
  expect(matrix.modules).toHaveLength(CAPABILITY_MODULES.length)
  expect(matrix.modules.map((m) => m.module).sort()).toEqual([...CAPABILITY_MODULES].sort())
  expect(matrix.sheetRoles).toHaveLength(sheetCount)
  expect(matrix.assumptions.length).toBeGreaterThan(0)
  expect(matrix.modelVersion.length).toBeGreaterThan(0)
  for (const m of matrix.modules) {
    expect(['AVAILABLE', 'PARTIAL', 'DERIVABLE', 'MISSING', 'PARSE_FAILED']).toContain(m.status)
    expect(m.confidence).toBeGreaterThanOrEqual(0)
    expect(m.confidence).toBeLessThanOrEqual(1)
    expect(m.message.length).toBeGreaterThan(0)
    if (m.status === 'AVAILABLE') expect(m.reason).toBeUndefined()

    // A reported source sheet must be one of the workbook's REAL sheet names
    // — the detector must never invent/mis-derive a sheet name that isn't
    // actually in the file.
    if (m.source?.sheet) {
      expect(sheetNames, `${m.module} reports source sheet "${m.source.sheet}" which is not one of this workbook's real sheets`).toContain(m.source.sheet)
    }

    // PARSE_FAILED must always carry its degradation reason through to the
    // module finding (KAR-959 review finding #1's own guarantee) — a
    // PARSE_FAILED status with no reason would be indistinguishable from any
    // other status at the UI layer.
    if (m.status === 'PARSE_FAILED') {
      expect(m.reason).toBe('PARSE_FAILED')
    }

    // A facet whose raw parse found its core fields AND actually extracted
    // rows (row-array facets — `rowCount === 0` legitimately yields MISSING,
    // see the FacetBehaviorSignal doc above), with no PARSE_FAILED
    // degradation, must be reported as usable (AVAILABLE/PARTIAL) — never
    // MISSING/DERIVABLE, which would silently discard real, successfully
    // extracted data (the exact class of bug KAR-959 review finding #1 fixed
    // for MANUFACTURING specifically; this generalizes the invariant to every
    // module this test can independently verify).
    const signal = facetSignals[m.module]
    if (signal?.coreFieldsFound && signal.degradationReason !== 'PARSE_FAILED' && (signal.rowCount === undefined || signal.rowCount > 0)) {
      expect(['AVAILABLE', 'PARTIAL'], `${m.module} had coreFieldsFound=true (rowCount=${signal.rowCount}, no PARSE_FAILED degradation) but the matrix reported ${m.status}`).toContain(m.status)
    }
  }
}

/** Diagnostic-only real-file classification summary (structural status counts
 * only, no cell values), same convention qaf-type-detector.real-files.test.ts's
 * own console.log uses (no-console is not enforced for test files in this
 * repo's eslint config). Der Dateiname ist Laufzeitdatum, kein Repo-Text. */
export function berichtszeile(label: string, fullPath: string, ergebnis: MatrixErgebnis, coverage: {
  available: number
  partial: number
  derivable: number
  missing: number
  parseFailed: number
}): string {
  return (
    `${label}: ${path.basename(fullPath)} -> ${ergebnis.matrix.modules.map((m) => `${m.module}=${m.status}`).join(', ')} ` +
    `(available=${coverage.available} partial=${coverage.partial} derivable=${coverage.derivable} missing=${coverage.missing} parseFailed=${coverage.parseFailed})`
  )
}
