// WorkbookCapabilityDetector (KAR-959/P2, Master-Prompt §11).
//
// Turns "parsed workbook facets (post-KAR-958/P1, including
// FacetDegradation) + sheet roles (sheet-resolver.ts)" into a
// WorkbookCapabilityMatrix — one CapabilityStatus per module, with
// confidence, source sheet, and (when the module is not fully AVAILABLE) an
// EmptyFieldReason WHY, reusing ../types.ts's KAR-958/P1 taxonomy rather
// than growing a second one.
//
// Two layers:
//   1. `detectWorkbookCapabilities` — the PURE detection algorithm. Takes
//      already-resolved SheetRoleAssignment[] + a per-module FacetParseSignal
//      map (a small, parser-shape-independent normalization of "was this
//      module attempted, how many fields mapped, core fields found,
//      degradation?"). Fully synthetic-fixture-testable, no workbook/ExcelJS
//      dependency.
//   2. `computeWorkbookCapabilityMatrix` — the adapter for callers with REAL
//      parser outputs (MaterialParseResult, SbmParseResult, ..., QafSummary,
//      SummaryMetricsParse, the manufacturing QAFParseResult array). This is
//      the one function app/qaf-differences/actions.ts calls (KAR-959 task
//      instruction point 4, "EINE minimale Integration").
//
// tdd-guard:skip — covered by __tests__/capability-detector.test.ts
// (synthetic facet fixtures) + the env-gated real-file validation test.

import type { WorkbookSheetSummary } from '../workbook-adapter'
import type { QafSummary } from '../types'
import type { FacetDegradation, EmptyFieldReason } from '../types'
import type { SummaryMetricsParse } from '../summary-metrics'
import { resolveSheetRoles, sheetForRole, SHEET_RESOLVER_ASSUMPTIONS } from './sheet-resolver'
import { moduleSheetRole } from './field-registry'
import { CAPABILITY_MODEL_VERSION, CAPABILITY_MODULES } from './types'
import type { CapabilityStatus, ModuleCapability, SheetRoleAssignment, WorkbookCapabilityMatrix, QafModule, SheetRole } from './types'

// ── Facet parse signal — parser-shape-independent normalization ────────────

/** One module's normalized parse outcome, independent of which parser
 * produced it (structural typing — real parser results like
 * MaterialParseResult/SbmParseResult/... satisfy the *Facet interfaces below
 * without an explicit cast, see the `facetSignalFrom*` adapters). */
export interface FacetParseSignal {
  module: QafModule
  /** The sheet this facet was read from, when known (from sheet-resolver's
   * role assignment, or a parser-reported sheet name). */
  sheet: string | null
  /** Whether a candidate sheet was found AND a parse was attempted at all —
   * `false` means "this module's parser never even matched a sheet",
   * distinct from "matched a sheet but the header/labels were unusable"
   * (see MIN_SIGNAL floor docs on each *-parser.ts's ParseMeta). */
  present: boolean
  /** Number of data rows extracted — undefined for single-record facets
   * (SUMMARY/LC-CN/CO2e-summary), where "how many rows" is not meaningful. */
  rowCount?: number
  parseConfidence: number
  mappedFieldCount: number
  unmappedHeaders: string[]
  coreFieldsFound: boolean
  degradation?: FacetDegradation
}

/** Minimal structural shape every row-array facet parser result
 * (MaterialParseResult, SbmParseResult, RmrParseResult, LogisticsParseResult,
 * Co2eMaterialParseResult) already satisfies — `Array<Row> & ParseMeta`. */
interface RowArrayFacetResult {
  readonly length: number
  parseConfidence: number
  mappedFieldCount: number
  coreFieldsFound: boolean
  unmappedHeaders?: string[]
  degradation?: FacetDegradation
}

export function facetSignalFromRowArray(module: QafModule, sheet: string | null, result: RowArrayFacetResult | null): FacetParseSignal {
  if (!result) {
    return { module, sheet, present: false, parseConfidence: 0, mappedFieldCount: 0, unmappedHeaders: [], coreFieldsFound: false }
  }
  return {
    module,
    sheet,
    present: true,
    rowCount: result.length,
    parseConfidence: result.parseConfidence,
    mappedFieldCount: result.mappedFieldCount,
    unmappedHeaders: result.unmappedHeaders ?? [],
    coreFieldsFound: result.coreFieldsFound,
    degradation: result.degradation,
  }
}

/** Minimal structural shape every single-record facet parser result
 * (LccnParseResult, Co2eSummaryParseResult) already satisfies — a nested
 * `meta` object rather than spread properties (these parsers have no "rows",
 * one record per file). */
interface SingleRecordFacetResult {
  meta: {
    parseConfidence: number
    mappedFieldCount: number
    coreFieldsFound: boolean
    degradation?: FacetDegradation
  }
}

export function facetSignalFromSingleRecord(module: QafModule, sheet: string | null, result: SingleRecordFacetResult | null): FacetParseSignal {
  if (!result) {
    return { module, sheet, present: false, parseConfidence: 0, mappedFieldCount: 0, unmappedHeaders: [], coreFieldsFound: false }
  }
  return {
    module,
    sheet,
    present: true,
    parseConfidence: result.meta.parseConfidence,
    mappedFieldCount: result.meta.mappedFieldCount,
    unmappedHeaders: [],
    coreFieldsFound: result.meta.coreFieldsFound,
    degradation: result.meta.degradation,
  }
}

/** MANUFACTURING's production-path shape (app/qaf-differences/actions.ts):
 * `QAFParseResult` on success, or a synthetic all-zero object when
 * parseQAFTemplate threw (KAR-958/P1 fix — see actions.ts's own comment on
 * that catch block). Neither carries `coreFieldsFound` directly (unlike the
 * 6 detail parsers) — it is DERIVED from `length > 0` here: parseQAFTemplate
 * only ever returns a non-empty array once it has already cleared its own
 * CORE_MANUFACTURING_FIELD_KEYS minimum (otherwise it throws, caught
 * upstream into the synthetic empty object) — so a non-empty result
 * structurally IMPLIES the core minimum was met, exactly the same guarantee
 * `coreFieldsFound: true` gives for the other 6 modules.
 *
 * `degradation` (KAR-959 review finding #1 fix, PR #326): unlike the 6
 * detail parsers, parseQAFTemplate has no soft "sheet not found, return
 * null" path — every failure is a THROW, caught in actions.ts's P1-catch
 * (see that catch block's own comment) into this same synthetic all-zero
 * object. Before this fix, that catch never attached a FacetDegradation, so
 * `present` stayed `false` (steps.length === 0) and deriveModuleCapability's
 * `!facet.present` branch reported DERIVABLE/NOT_YET_SUPPORTED ("parser does
 * not exist yet") for a module whose parser DOES exist and WAS invoked, it
 * just threw — the exact "PARSER_NOT_IMPLEMENTED and NOT_AVAILABLE must
 * never be represented as the same state" violation Master-Prompt §11 rules
 * out. `present` must therefore be `true` whenever a `degradation` finding
 * is attached (a parse was genuinely attempted, however it turned out) —
 * `!degradation` cases keep the pre-existing `length > 0` derivation. */
export function facetSignalFromManufacturingSteps(
  sheet: string | null,
  steps: { length: number; parseConfidence: number; mappedFieldCount: number; unmappedHeaders?: string[]; degradation?: FacetDegradation },
): FacetParseSignal {
  const coreFieldsFound = steps.length > 0
  const present = coreFieldsFound || steps.degradation !== undefined
  return {
    module: 'MANUFACTURING',
    sheet,
    present,
    rowCount: steps.length,
    parseConfidence: steps.parseConfidence,
    mappedFieldCount: steps.mappedFieldCount,
    unmappedHeaders: steps.unmappedHeaders ?? [],
    coreFieldsFound,
    degradation: steps.degradation,
  }
}

/** SUMMARY facet — no row array, no single `meta` block either: identity
 * fields (QafSummary, always present but per-field nullable) plus the
 * optional money-metrics block (SummaryMetricsParse, null only when no
 * SUMMARY-like sheet was found at all). "Core fields found"/"mapped field
 * count" are derived from how many of the identity + money-metric SLOTS were
 * actually located (SummaryMetricValue.confidence > 0 / SummaryField.value
 * !== null) — SUMMARY has no dedicated CORE_*_FIELD_KEYS minimum-signal gate
 * like the 6 detail parsers (summary-parser.ts/summary-metrics.ts never
 * throw/degrade that way), so this adapter treats "at least one slot
 * located" as the coreFieldsFound-equivalent signal. */
export function facetSignalFromSummary(sheet: string | null, summary: QafSummary, summaryMetrics: SummaryMetricsParse | null): FacetParseSignal {
  const identityValues = Object.values(summary) as Array<{ value: unknown }>
  const identityFound = identityValues.filter((f) => f.value !== null && f.value !== '').length
  const identityTotal = identityValues.length

  if (!summaryMetrics) {
    return {
      module: 'SUMMARY',
      sheet,
      present: identityFound > 0,
      mappedFieldCount: identityFound,
      unmappedHeaders: [],
      coreFieldsFound: identityFound > 0,
      parseConfidence: identityTotal > 0 ? identityFound / identityTotal : 0,
    }
  }

  const metricValues = Object.values(summaryMetrics.metrics) as Array<{ confidence: number }>
  const metricsFound = metricValues.filter((m) => m.confidence > 0).length
  const metricsTotal = metricValues.length
  const mappedFieldCount = identityFound + metricsFound
  const total = identityTotal + metricsTotal

  return {
    module: 'SUMMARY',
    sheet,
    present: true,
    mappedFieldCount,
    unmappedHeaders: [],
    coreFieldsFound: mappedFieldCount > 0,
    parseConfidence: total > 0 ? mappedFieldCount / total : 0,
  }
}

// ── Detection algorithm (pure) ──────────────────────────────────────────────

/** Below this confidence threshold (or with any unmapped header present), a
 * module whose core fields WERE found is still PARTIAL, not AVAILABLE —
 * Master-Prompt §11's AVAILABLE/PARTIALLY_AVAILABLE distinction.
 * ASSUMPTION (§15 discipline): 0.95 is a starting calibration, not corpus-
 * validated in this PR — Layer 2 corpus hardening (Master-Prompt §33.2) is
 * the explicit place to re-tune it against real files. */
export const AVAILABLE_CONFIDENCE_THRESHOLD = 0.95

/** A candidate sheet identified by the resolver but not extracted by any
 * parser is reported at a fraction of the resolver's own role-assignment
 * confidence — DERIVABLE is a weaker claim than "this sheet role
 * classification is correct AND the data on it is usable". */
const DERIVABLE_CONFIDENCE_FACTOR = 0.5

function deriveModuleCapability(module: QafModule, roleSheet: SheetRoleAssignment | undefined, facet: FacetParseSignal | undefined): ModuleCapability {
  if (!facet || !facet.present) {
    if (roleSheet) {
      return {
        module,
        status: 'DERIVABLE',
        confidence: roleSheet.confidence * DERIVABLE_CONFIDENCE_FACTOR,
        source: { sheet: roleSheet.sheetName },
        reason: 'NOT_YET_SUPPORTED',
        message: `Sheet "${roleSheet.sheetName}" was recognized with a ${module} role signal (${roleSheet.signal}, confidence ${roleSheet.confidence}) but no ${module} data was extracted from it.`,
      }
    }
    return {
      module,
      status: 'MISSING',
      confidence: 1,
      source: null,
      reason: 'MISSING_IN_WORKBOOK',
      message: `No sheet with a ${module} role signal was found in this workbook.`,
    }
  }

  const sheet = facet.sheet ?? roleSheet?.sheetName ?? null

  if (facet.degradation) {
    const status = degradationReasonToStatus(facet.degradation.reason)
    return {
      module,
      status,
      confidence: facet.parseConfidence,
      source: { sheet: facet.degradation.sheet ?? sheet },
      reason: facet.degradation.reason,
      message: facet.degradation.message,
    }
  }

  if (!facet.coreFieldsFound) {
    return {
      module,
      status: 'MISSING',
      confidence: facet.parseConfidence,
      source: { sheet },
      reason: 'MISSING_IN_WORKBOOK',
      message: `${module} core fields were not found (below the parser's minimum-signal floor).`,
    }
  }

  if (facet.rowCount === 0) {
    return {
      module,
      status: 'MISSING',
      confidence: facet.parseConfidence,
      source: { sheet },
      reason: 'MISSING_IN_WORKBOOK',
      message: `${module} sheet was located but contains zero data rows.`,
    }
  }

  const isFullyMapped = facet.parseConfidence >= AVAILABLE_CONFIDENCE_THRESHOLD && facet.unmappedHeaders.length === 0
  if (isFullyMapped) {
    return {
      module,
      status: 'AVAILABLE',
      confidence: facet.parseConfidence,
      source: { sheet },
      message: `${module} data extracted (${facet.mappedFieldCount} fields mapped, confidence ${facet.parseConfidence}).`,
    }
  }
  return {
    module,
    status: 'PARTIAL',
    confidence: facet.parseConfidence,
    source: { sheet },
    message: `${module} data extracted but incomplete (${facet.mappedFieldCount} fields mapped, confidence ${facet.parseConfidence}, ${facet.unmappedHeaders.length} unmapped header(s)).`,
  }
}

/** Master-Prompt §11: "PARSER_NOT_IMPLEMENTED and NOT_AVAILABLE must never be
 * represented as the same state" — this mapping keeps that distinction
 * (NOT_YET_SUPPORTED -> DERIVABLE, MISSING_IN_WORKBOOK -> MISSING are
 * different EmptyFieldReason -> CapabilityStatus pairs, never collapsed). */
function degradationReasonToStatus(reason: EmptyFieldReason): CapabilityStatus {
  switch (reason) {
    case 'PARSE_FAILED':
      return 'PARSE_FAILED'
    case 'MISSING_IN_WORKBOOK':
      return 'MISSING'
    case 'NOT_YET_SUPPORTED':
      return 'DERIVABLE'
    case 'INCONSISTENT':
      return 'PARTIAL'
    case 'LEGACY_FORMAT_UNSUPPORTED':
      // PARSE_FAILED, nicht MISSING: letzteres hieße "diese Mappe hat diese
      // Daten wirklich nicht", während der wahre Zustand "wir konnten die Mappe
      // nicht lesen" wäre.
      //
      // Historischer Kontext (KAR-961/P4): der Grund entstand, als das
      // Upload-Gate `.xls` komplett abwies. Seit 27.07.2026 liest der Ingest das
      // Altformat über legacy-workbook-shim.ts — die frühere Aussage "das Gate
      // weist die Datei ab, bevor computeWorkbookCapabilityMatrix je läuft"
      // stimmt nicht mehr. Belegt hat den Grund ohnehin nie jemand (kein
      // Vorkommen im Repo); der Zweig bleibt explizit, falls ein Aufrufer ihn
      // künftig je Facet setzt.
      return 'PARSE_FAILED'
    case 'GATED_BY_MODE':
      // No producer sets this today (see ../types.ts EmptyFieldReason doc —
      // "reserved for a future producer"); PARTIAL is the conservative
      // choice (data may exist but is suppressed by mode/classification, not
      // genuinely absent) — never MISSING, which would misrepresent gated
      // data as not extractable at all.
      return 'PARTIAL'
    default:
      return 'PARTIAL'
  }
}

/**
 * The pure detection algorithm (Master-Prompt §11 WorkbookCapabilityDetector).
 * `facets` need not have an entry for every CAPABILITY_MODULES member — a
 * missing key is treated identically to an explicit `undefined` (module
 * never attempted).
 */
export function detectWorkbookCapabilities(sheetRoles: ReadonlyArray<SheetRoleAssignment>, facets: Partial<Record<QafModule, FacetParseSignal>>): WorkbookCapabilityMatrix {
  const modules = CAPABILITY_MODULES.map((module) => {
    const role = moduleSheetRole(module)
    const roleSheet = role ? sheetForRole(sheetRoles, role) : undefined
    return deriveModuleCapability(module, roleSheet, facets[module])
  })

  return {
    sheetRoles: [...sheetRoles],
    modules,
    assumptions: [
      ...SHEET_RESOLVER_ASSUMPTIONS,
      `A module whose core fields were found is AVAILABLE only at parseConfidence >= ${AVAILABLE_CONFIDENCE_THRESHOLD} with zero unmapped headers; otherwise PARTIAL. Not corpus-calibrated in this PR (Master-Prompt §33.2 Layer-2 territory).`,
      'A sheet role recognized by sheet-resolver.ts but with no corresponding extracted data is reported DERIVABLE/NOT_YET_SUPPORTED, not MISSING — this generalizes gate-audit.md\'s B14 finding (the corpus\'s "0 Fertigungskosten rows" files) into an honest, visible capability finding instead of collapsing "genuinely absent" and "recognized but unparsed" into the same state (Master-Prompt §11 explicit requirement).',
      'G60 (internal/g60/) and the foreign-form rejections WAF/LAF/LEK are outside this capability matrix\'s 8-module scope (see types.ts CAPABILITY_MODULES doc) — a G60-shaped workbook still gets sheet-role classification but no per-module G60 capability finding in this PR.',
    ],
    modelVersion: CAPABILITY_MODEL_VERSION,
  }
}

// ── High-level orchestrator (real parser shapes) ────────────────────────────

/** Input shape for computeWorkbookCapabilityMatrix — deliberately typed
 * against the STRUCTURAL shapes above (RowArrayFacetResult/
 * SingleRecordFacetResult), not the concrete parser result type names, so
 * this module does not need to import every one of material-parser.ts's/
 * sbm-parser.ts's/.../co2e-parser.ts's exported result types — the real
 * MaterialParseResult/SbmParseResult/RmrParseResult/LogisticsParseResult/
 * Co2eMaterialParseResult values app/qaf-differences/actions.ts already has
 * in scope after its existing parse calls satisfy RowArrayFacetResult
 * structurally, and LccnParseResult/Co2eSummaryParseResult satisfy
 * SingleRecordFacetResult, with no cast required at the call site. */
export interface WorkbookCapabilityInput {
  sheets: ReadonlyArray<WorkbookSheetSummary>
  summary: QafSummary
  summaryMetrics: SummaryMetricsParse | null
  manufacturingSteps: { length: number; parseConfidence: number; mappedFieldCount: number; unmappedHeaders?: string[]; degradation?: FacetDegradation }
  material: RowArrayFacetResult | null
  sbm: RowArrayFacetResult | null
  rmr: RowArrayFacetResult | null
  logistics: RowArrayFacetResult | null
  lccn: SingleRecordFacetResult | null
  co2eMaterial: RowArrayFacetResult | null
  co2eSummary: SingleRecordFacetResult | null
}

/**
 * The ingest-time entrypoint (KAR-959 task instruction point 4): resolves
 * sheet roles, adapts every already-parsed facet into a FacetParseSignal,
 * and runs the pure detector. No workbook/ExcelJS access of its own — every
 * facet is passed in already parsed (task instruction: "NACH dem
 * bestehenden Parsen"), so this function performs no additional I/O and no
 * second pass over the workbook.
 */
export function computeWorkbookCapabilityMatrix(input: WorkbookCapabilityInput): WorkbookCapabilityMatrix {
  const sheetRoles = resolveSheetRoles(input.sheets)
  const sheetFor = (role: Exclude<SheetRole, 'unknown'>): string | null => sheetForRole(sheetRoles, role)?.sheetName ?? null

  const facets: Partial<Record<QafModule, FacetParseSignal>> = {
    SUMMARY: facetSignalFromSummary(sheetFor('summary'), input.summary, input.summaryMetrics),
    MANUFACTURING: facetSignalFromManufacturingSteps(sheetFor('manufacturing'), input.manufacturingSteps),
    MATERIAL: facetSignalFromRowArray('MATERIAL', sheetFor('material'), input.material),
    SBM: facetSignalFromRowArray('SBM', sheetFor('sbm'), input.sbm),
    RMR: facetSignalFromRowArray('RMR', sheetFor('rmr'), input.rmr),
    LOGISTICS: facetSignalFromRowArray('LOGISTICS', sheetFor('logistics'), input.logistics),
    LC_CN: facetSignalFromSingleRecord('LC_CN', sheetFor('lccn'), input.lccn),
    // CO2E has TWO sub-parses (summary panel + material rows, co2e-parser.ts
    // "Struktur-Entscheidung") sharing one sheet — merged into a single
    // CO2E FacetParseSignal by taking whichever sub-part found more (the
    // more informative one), same "combine, do not silently pick one"
    // spirit as templateFingerprint's own co2e matchedFieldKeys union
    // (actions.ts) — a full merge (not just max-picking) is not attempted
    // here since the two sub-parts have structurally different meta shapes
    // (single-record vs. row-array); documented as an assumption below.
    CO2E: mergeCo2eFacetSignals(sheetFor('co2e'), input.co2eSummary, input.co2eMaterial),
  }

  const matrix = detectWorkbookCapabilities(sheetRoles, facets)
  return {
    ...matrix,
    assumptions: [
      ...matrix.assumptions,
      'CO2E merges two structurally distinct sub-parses (summary panel + material rows) into one module-level capability by keeping whichever sub-part reports the higher parseConfidence — not a field-level union. A future refinement could split CO2E into two capability entries instead.',
    ],
  }
}

/**
 * KAR-959 review finding #2 fix (PR #326): a `present` sub-signal must ALWAYS
 * win over a not-present one — `parseConfidence` is only a tie-break AMONG
 * present signals, never a way for an absent facet to outrank a present one.
 * The pre-fix version compared `parseConfidence` directly regardless of
 * `present`: `facetSignalFromSingleRecord`'s not-present branch returns
 * `parseConfidence: 0` (see that function's `!result` branch above), so a
 * present-but-low-quality materialSignal (parseConfidence 0, present true —
 * e.g. rows were extracted but coreFieldsFound is false) tied with an
 * ABSENT summarySignal (also parseConfidence 0, present false) and the `>=`
 * comparison picked summarySignal purely because it runs first — silently
 * discarding the fact that CO2e material data WAS actually found.
 */
function mergeCo2eFacetSignals(sheet: string | null, summaryResult: SingleRecordFacetResult | null, materialResult: RowArrayFacetResult | null): FacetParseSignal {
  const summarySignal = facetSignalFromSingleRecord('CO2E', sheet, summaryResult)
  const materialSignal = facetSignalFromRowArray('CO2E', sheet, materialResult)
  if (summarySignal.present !== materialSignal.present) {
    return summarySignal.present ? summarySignal : materialSignal
  }
  if (!summarySignal.present && !materialSignal.present) return summarySignal
  return summarySignal.parseConfidence >= materialSignal.parseConfidence ? summarySignal : materialSignal
}
