// Zusammenfassung/Summary row FieldCandidate producer (KAR-960/P3, Hebel B —
// capability-matrix.md "2. Master-Sätze + Summen (B+E) im
// „Zusammenfassung"/„Summary"-Blatt — ~346 Dateien").
//
// Two, deliberately SEPARATE, sources feed this module's FieldCandidate[]
// output:
//
//   1. `summaryMasterRateFieldCandidates` — a NEW positional label→value row
//      scan for the 4 master rates the capability-matrix names (Lohnsatz,
//      SG&A %, Gewinn %, Scrap %) that no existing Kadi-v2 parser extracts
//      (field-registry.ts cap_sum_master_* doc comments — no
//      Leitfaden-documented cell/row exists for any of the 4, confirmed by
//      grep across canonical-fields.ts before this PR). CONSERVATIVE by
//      construction (task instruction: "nur eindeutige Label-Treffer,
//      Mehrdeutigkeit → FieldCandidate mit INCONSISTENT statt Raten"): every
//      label hit across the WHOLE grid becomes its own FieldCandidate — one
//      hit resolves to FieldConflictStatus.SINGLE downstream
//      (field-candidates.ts resolveFieldCandidates), two-or-more hits
//      resolve to AGREEMENT (same value) or INCONSISTENT (different values)
//      instead of this module silently guessing which one is "the" real
//      label. Real-corpus hit rate is UNKNOWN/likely low (no ground truth —
//      see field-registry.ts doc comments, capability-matrix.md's own B-group
//      finding of 14-22% "VORHANDEN") — the p3-validation.md real-file run
//      (KAR-960 task instruction point 4) reports the actual number, this is
//      not assumed to be high.
//
//   2. `summaryTotalsFieldCandidates` — wraps the ALREADY-parsed,
//      V11-verified summary-metrics.ts SummaryMetricsParse (the 19 canonical
//      money metrics — materialCosts/manufacturingCosts/totalCosts/
//      quotationPrice/scrapMaterial/scrapManufacturing among them, the
//      capability-matrix's "E — Summen" group) into FieldCandidate[]. This is
//      the "hier die P2-FieldCandidate-Pipeline PRODUKTIV verdrahten" half of
//      the task instruction for the E group specifically: no new extraction
//      logic (summary-metrics.ts's label-anchored ±3-row-window scan already
//      does this reliably, see that module's own header), just a thin
//      structural adapter so the SAME resolveAllFieldCandidates pipeline
//      (KAR-959/P2, previously zero production producers) now also carries
//      the Summen alongside the new master rates.
//
// Both scopes are `global` (Master-Prompt §21: workbook-wide, once-per-file
// values — never `process:<step>`, which manufacturing-field-candidates.ts
// (Hebel A) owns).
//
// tdd-guard:skip — covered by __tests__/summary-field-candidates.test.ts.

import { cellToString, a1 } from '../summary-parser'
import { parseLocaleNumber } from '../normalizer'
import { SUMMARY_METRIC_KEY_TO_CANONICAL } from '../canonical-fields'
import type { SummaryMetricsParse, SummaryMetricKey } from '../summary-metrics'
import type { FieldCandidate } from './types'

// ── Master-rate label scan (new — no existing parser covers this) ─────────

type MasterRateKey = 'laborRate' | 'sgaRate' | 'profitRate' | 'scrapRate'

interface MasterRateLabelSet {
  key: MasterRateKey
  fieldId: string
  /** Lowercased, whitespace-collapsed substrings — a cell matches if its
   * normalized text CONTAINS one of these (same substring-containment
   * discipline as summary-parser.ts's labelScan, so real-file label variants
   * with trailing units/colons still match). */
  labels: string[]
  /** Cell values >1 are divided by 100 before being reported (ASSUMPTION,
   * documented in the module header: a plain, non-%-formatted cell holding
   * "19" for 19% cannot be told apart from a genuine value >1 any other way
   * without re-reading the cell's number format, which this pure-grid
   * function does not have access to). Never applied to laborRate (a
   * currency-per-hour value, where >1 is the normal/expected range). */
  isPercent: boolean
}

const MASTER_RATE_LABEL_SETS: readonly MasterRateLabelSet[] = [
  {
    key: 'laborRate',
    fieldId: 'cap_sum_master_labor_rate',
    labels: ['lohnsatz', 'kalkulationslohnsatz', 'stundenlohnsatz', 'labor rate', 'labour rate', 'hourly labor rate', 'hourly labour rate'],
    isPercent: false,
  },
  {
    key: 'sgaRate',
    fieldId: 'cap_sum_master_sga_rate',
    labels: ['sgk-satz', 'sg&a-satz', 'sozialgemeinkostensatz', 'sga rate', 'sg&a rate'],
    isPercent: true,
  },
  {
    key: 'profitRate',
    fieldId: 'cap_sum_master_profit_rate',
    labels: ['gewinnsatz', 'gewinnaufschlag', 'margensatz', 'profit rate', 'profit margin rate', 'margin rate'],
    isPercent: true,
  },
  {
    key: 'scrapRate',
    fieldId: 'cap_sum_master_scrap_rate',
    labels: ['ausschusssatz', 'schrottsatz', 'ausschussquote', 'scrap rate', 'scrap quota'],
    isPercent: true,
  },
]

/** Same bounded-scan discipline as workbook-adapter.ts's semanticActiveRange
 * — ws.dimensions/rowCount lie routinely on the real corpus (that module's
 * own doc comment), so this scans a generous fixed window rather than
 * trusting a declared sheet size. Zusammenfassung/Summary sheets are short
 * (summary-metrics.ts's own TEMPLATE_CONFIG never references a row past the
 * low 30s) — 80 rows / 30 cols is ample headroom without risking a runaway
 * scan on a corrupted/oversized sheet. */
const MASTER_RATE_SCAN_MAX_ROWS = 80
const MASTER_RATE_SCAN_MAX_COLS = 30
/** KAR-960 review fix (PR #327 finding 1): deliberately SHORT compared to
 * summary-parser.ts's/co2e-parser.ts's/lccn-parser.ts's MAX_SCAN_RIGHT=6 —
 * those scans return text (or accept the FIRST cell unconditionally), this
 * one feeds a Preis-Kalkulator financial value straight through, so the blast
 * radius of a wrong stop decision is a silently wrong price. 3 columns is
 * enough for "label: | value" and "label: | unit | value" layouts, without
 * giving a misattributed value room to hide 4-6 columns away. */
const MASTER_RATE_MAX_SCAN_RIGHT = 3

/** Cell text that is PURELY a unit marker belonging to the value being
 * scanned for (field-registry.ts cap_sum_master_* units: 'BW/h' for
 * laborRate, '%' for the other three) — tolerated as a pass-through cell
 * without ending the scan. Anything else non-empty and non-numeric is
 * treated as a foreign label/value (see scanValueRightOf doc comment) and
 * stops the scan immediately. */
const UNIT_SUFFIX_RE = /^(%|€|eur|bw\/h|eur\/h|€\/h)$/i

function normalizeLabelCell(v: unknown): string {
  const s = cellToString(v)
  if (s === null) return ''
  return s
    .replace(/\*+/g, ' ')
    .replace(/\s+/g, ' ')
    .trim()
    .toLowerCase()
}

interface RawHit {
  row: number
  col: number
  value: number
}

/** Find the first non-empty cell to the right of (row, labelCol) that parses
 * as a locale number, tolerating empty cells and pure unit-marker cells
 * (UNIT_SUFFIX_RE) in between — but STOPS at the first non-empty cell that is
 * neither a number nor a unit marker.
 *
 * KAR-960 review fix (PR #327 finding 1): the original version only stopped
 * on a cell ending in ':' — any intervening label/text cell WITHOUT a colon
 * (e.g. a neighboring field's label written as plain "SGK-Satz" instead of
 * "SGK-Satz:") was silently skipped, so a number belonging to a DIFFERENT,
 * further-right label could be mis-attributed to the label being scanned
 * (row ["Gewinnsatz", "SGK-Satz", "25%"]: scanning from "Gewinnsatz" used to
 * skip over "SGK-Satz" — no colon — and return 25% as Gewinnsatz's value,
 * when it is actually SGK-Satz's). Any non-empty, non-numeric, non-unit text
 * is now ALWAYS a stop signal, colon or not — it is either a genuinely
 * different field's label or some other unrelated content, and this scan has
 * no way to tell which, so it must not scan past it. */
function scanValueRightOf(grid: unknown[][], row: number, labelCol: number): { col: number; value: number } | null {
  const rowArr = grid[row] ?? []
  for (let c = labelCol + 1; c <= labelCol + MASTER_RATE_MAX_SCAN_RIGHT && c < rowArr.length; c++) {
    const cell = rowArr[c]
    const n = parseLocaleNumber(cell)
    if (n !== null) return { col: c, value: n }
    const text = cellToString(cell)
    if (text === null) continue // empty cell — keep scanning
    if (UNIT_SUFFIX_RE.test(text.trim())) continue // unit marker directly next to the value — keep scanning
    break // any other non-empty, non-numeric text — foreign label/value, stop
  }
  return null
}

function findRawHits(grid: unknown[][], labels: readonly string[]): RawHit[] {
  const hits: RawHit[] = []
  const rowLimit = Math.min(grid.length, MASTER_RATE_SCAN_MAX_ROWS)
  for (let r = 0; r < rowLimit; r++) {
    const rowArr = grid[r] ?? []
    const colLimit = Math.min(rowArr.length, MASTER_RATE_SCAN_MAX_COLS)
    for (let c = 0; c < colLimit; c++) {
      const norm = normalizeLabelCell(rowArr[c])
      if (norm === '' || !labels.some((l) => norm.includes(l))) continue
      const found = scanValueRightOf(grid, r, c)
      if (found) hits.push({ row: r, col: found.col, value: found.value })
    }
  }
  return hits
}

/** Label-scan confidence — deliberately below summary-metrics.ts's 1.0
 * label-anchored / 0.6 fixed-row tiers: there is no expected-row prior for
 * any of these 4 fields (no Leitfaden/real-file cell coordinate is
 * documented, see field-registry.ts cap_sum_master_* notes) — a whole-grid
 * substring match is the ONLY signal, so it is reported at a level that
 * keeps it below AVAILABLE_CONFIDENCE_THRESHOLD (capability-detector.ts,
 * 0.95) even for a single unambiguous hit. */
export const MASTER_RATE_LABEL_SCAN_CONFIDENCE = 0.7

/**
 * Scan the whole Zusammenfassung/Summary grid for the 4 master rates
 * (Lohnsatz/SG&A/Gewinn/Scrap). Emits ONE FieldCandidate per label hit — 0
 * hits emits nothing for that field (no evidence, no candidate, NOT a
 * fabricated null/zero candidate), 1 hit emits exactly 1 candidate
 * (resolves to SINGLE), 2+ hits emit one candidate PER hit (resolves to
 * AGREEMENT or INCONSISTENT downstream — see module header, Master-Prompt
 * §14 "never silently discard a conflicting candidate").
 */
export function summaryMasterRateFieldCandidates(grid: unknown[][], sheet: string | null): FieldCandidate<number>[] {
  return masterRateLabelScanCandidates(grid, sheet, MASTER_RATE_LABEL_SCAN_CONFIDENCE)
}

/** Shared core scan (KAR-962/P5 §21 refactor — see premise-field-candidates.ts
 * for the second caller): identical label-set/scan-window/percent-rescale
 * discipline for BOTH the Zusammenfassung/Summary sheet
 * (summaryMasterRateFieldCandidates) and the Prämissenblatt/Assumptions-sheet
 * (premiseMasterRateFieldCandidates) — the same 4 master rates can legitimately
 * be labeled on either sheet (capability-matrix.md: "teils
 * „Dokumentenlenkung"/„Lohnsatz"-Tab"), and a hit on EACH becomes its own
 * FieldCandidate (never merged here) so field-candidates.ts's resolver can
 * apply its normal SINGLE/AGREEMENT/INCONSISTENT logic across BOTH sources —
 * exactly the Master-Prompt §14 "never silently discard a conflicting
 * candidate" discipline, now spanning two sheets instead of one. `confidence`
 * is a parameter (not a shared constant) so a future caller with a different
 * evidence strength for its own sheet does not have to fork this function. */
export function masterRateLabelScanCandidates(grid: unknown[][], sheet: string | null, confidence: number): FieldCandidate<number>[] {
  const candidates: FieldCandidate<number>[] = []
  for (const set of MASTER_RATE_LABEL_SETS) {
    const hits = findRawHits(grid, set.labels)
    for (const hit of hits) {
      const value = set.isPercent && Math.abs(hit.value) > 1 ? hit.value / 100 : hit.value
      candidates.push({
        fieldId: set.fieldId,
        module: 'SUMMARY',
        value,
        scope: 'global',
        source: { sheet, cell: a1(hit.row, hit.col) },
        confidence,
      })
    }
  }
  return candidates
}

// ── Summen (E group) — wraps the already-parsed summary-metrics.ts output ──

/** The SUMMARY_METRIC_KEYS this producer wires into the FieldCandidate
 * pipeline — capability-matrix.md's "E — Summen/Kennzahlen" group's core
 * subset (Material gesamt, Fertigung gesamt, SG&A/Gesamtkosten, Gewinn/
 * Stückpreis, Scrap). Not the full 19-key SUMMARY_METRIC_KEYS list — the
 * remaining keys (packaging/customs/devices/one-time-payments/…) are already
 * fully served by qaf_summary_diff (compare.ts) for the existing A5
 * Kennzahlen-Vergleich section; this producer exists so the CAPABILITY-KERN
 * (module-level AVAILABLE/PARTIAL status, price-calculator chip gating) also
 * sees them, not to duplicate the diff table itself. */
export const SUMMARY_TOTALS_CANDIDATE_METRIC_KEYS: readonly SummaryMetricKey[] = [
  'materialCosts',
  'manufacturingCosts',
  'totalCosts',
  'quotationPrice',
  'scrapMaterial',
  'scrapManufacturing',
]

export function summaryTotalsFieldCandidates(
  summaryMetrics: SummaryMetricsParse | null,
  sheet: string | null,
): FieldCandidate<number>[] {
  if (!summaryMetrics) return []
  const candidates: FieldCandidate<number>[] = []
  for (const key of SUMMARY_TOTALS_CANDIDATE_METRIC_KEYS) {
    const metric = summaryMetrics.metrics[key]
    if (metric.value === null || metric.confidence <= 0) continue
    candidates.push({
      fieldId: SUMMARY_METRIC_KEY_TO_CANONICAL[key],
      module: 'SUMMARY',
      value: metric.value,
      scope: 'global',
      source: { sheet, cell: metric.cell },
      confidence: metric.confidence,
    })
  }
  return candidates
}

/** Convenience: both producers combined — the single call site
 * workbook-adapter.ts/actions.ts use so a future third Hebel-B producer only
 * needs to be added here once. */
export function summaryFieldCandidates(
  grid: unknown[][],
  summaryMetrics: SummaryMetricsParse | null,
  sheet: string | null,
): FieldCandidate<number>[] {
  return [...summaryMasterRateFieldCandidates(grid, sheet), ...summaryTotalsFieldCandidates(summaryMetrics, sheet)]
}
