import { createWafer } from '../core/wafer.js';
import { generateDies, isYieldEligibleDie, getDieKey, hasPosition } from '../core/dies.js';
import { applyOrientation, transformDies } from '../core/transforms.js';
import { affineRotation, affineMirror, affineCompose, affinePoint } from '../core/transforms.js';
import { inferWaferFromXY } from '../core/inference/wafer.js';
import { resolveGridPitch } from '../core/inference/pitch.js';
import { assignGridIndices } from '../core/inference/grid.js';
import { generateReticleGrid } from '../core/reticle.js';
import { buildView } from './buildView.js';
import { modeOf } from '../core/utils.js';
import { aggregateValues, aggregateBinCounts } from '../core/aggregates.js';
/**
 * True when `def` describes a parametric (continuous-value) test — i.e. its
 * values are valid input for parametric statistics (mean, quartiles, Cpk,
 * correlation). Functional tests (`testType: 'F'`) record pass/fail outcomes,
 * not measurements. An undefined def or undefined `testType` defaults to
 * parametric, so untyped callers keep today's behaviour.
 */
export function isParametricTest(def) {
    return def?.testType !== 'F';
}
function normalizeInput(input) {
    if (!Array.isArray(input) && 'results' in input && input.results !== undefined && 'lotStack' in input && input.lotStack !== undefined) {
        throw new Error('buildWaferMap: pass either `results` or `lotStack`, not both.');
    }
    if (Array.isArray(input)) {
        return {
            results: input,
            waferOpts: undefined,
            dieOpts: undefined,
            explicitDies: undefined,
            reticleOpts: undefined,
            lotStackOpts: undefined,
            passBins: [1],
            testDefs: undefined,
            hbinDefs: undefined,
            sbinDefs: undefined,
            metadataFields: undefined,
            retestPolicy: 'last',
            edgeDieYieldMode: 'exclude',
        };
    }
    return {
        results: input.results ?? [],
        waferOpts: input.waferConfig,
        dieOpts: input.dieConfig,
        explicitDies: input.dies,
        reticleOpts: input.reticleConfig,
        lotStackOpts: input.lotStack,
        passBins: input.passBins ?? [1],
        testDefs: input.testDefs,
        hbinDefs: input.hbinDefs,
        sbinDefs: input.sbinDefs,
        metadataFields: input.metadataFields,
        retestPolicy: input.retestPolicy ?? 'last',
        edgeDieYieldMode: input.edgeDieYieldMode ?? 'exclude',
    };
}
// ── Grid origin & axis helpers ────────────────────────────────────────────────
function detectOrigin(_results, dieOpts) {
    if (dieOpts?.coordinateOrigin)
        return dieOpts.coordinateOrigin;
    return { type: 'center' };
}
function resolveGridOriginOffset(gridPoints, origin, ga) {
    if (origin.type === 'custom' && origin.offset) {
        return { offsetX: origin.offset.x, offsetY: origin.offset.y };
    }
    if (origin.type !== 'center') {
        let xMin = Infinity, xMax = -Infinity, yMin = Infinity, yMax = -Infinity;
        for (const p of gridPoints) {
            if (p.x < xMin)
                xMin = p.x;
            if (p.x > xMax)
                xMax = p.x;
            if (p.y < yMin)
                yMin = p.y;
            if (p.y > yMax)
                yMax = p.y;
        }
        return {
            offsetX: Math.round((xMax + xMin) / 2),
            offsetY: Math.round((yMax + yMin) / 2),
        };
    }
    return { offsetX: ga.offsetX, offsetY: ga.offsetY };
}
/**
 * Determine the physical offset (`colMidX`, `colMidY`) that places the wafer
 * centre at physical (0,0).  Die physical positions are computed as
 * `physX = col * pitchX - colMidX`, where `col = round(proberX) - offsetX`.
 *
 * - When `waferCenter` (a prober coordinate) is supplied, that die is anchored
 *   to physical (0,0): `colMidX = (round(center.x) - offsetX) * pitchX`.
 *   `anchored = true`.
 * - Otherwise the offset is the midpoint of the observed column/row extent, so
 *   the data is centred on the canvas.  This is correct only for full, roughly
 *   symmetric coverage; for partial data the data midpoint is not the wafer
 *   centre.  `anchored = false`.
 *
 * Extents are measured in the die-col frame (using `offsetX`/`offsetY`), so the
 * result is consistent with how die positions are computed downstream.
 */
function resolveCenterAnchor(gridPoints, offsetX, offsetY, pitchX, pitchY, waferCenter) {
    if (waferCenter) {
        return {
            colMidX: (Math.round(waferCenter.x) - offsetX) * pitchX,
            colMidY: (Math.round(waferCenter.y) - offsetY) * pitchY,
            anchored: true,
        };
    }
    if (gridPoints.length === 0) {
        return { colMidX: 0, colMidY: 0, anchored: false };
    }
    let cMin = Infinity, cMax = -Infinity, rMin = Infinity, rMax = -Infinity;
    for (const p of gridPoints) {
        const col = Math.round(p.x) - offsetX;
        const row = Math.round(p.y) - offsetY;
        if (col < cMin)
            cMin = col;
        if (col > cMax)
            cMax = col;
        if (row < rMin)
            rMin = row;
        if (row > rMax)
            rMax = row;
    }
    return {
        colMidX: ((cMin + cMax) / 2) * pitchX,
        colMidY: ((rMin + rMax) / 2) * pitchY,
        anchored: false,
    };
}
/**
 * Heuristic: does the data look like one-sided (partial) coverage rather than a
 * full wafer?
 *
 * For a full wafer — or a wafer sampled sparsely but across its whole face
 * (systematic skip-sampling or random sampling) — the data centroid coincides
 * with the centre of the bounding box: the mass is balanced. A contiguous
 * partial region (a half, a quadrant, a 60%-slice) pulls the centroid toward the
 * populated side, away from the bounding-box centre. We measure that pull on each
 * axis as |centroid − bbox-mid| / half-span and flag when it exceeds 0.11.
 *
 * Threshold rationale: contiguous half/quadrant/60% cases land at ≈0.12–0.14;
 * random sparse sampling stays ≲0.10 (centroid ≈ bbox centre by averaging). 0.11
 * separates them with a low (~2–3%) false-positive rate on random samples.
 *
 * Two cases this deliberately does NOT flag, because both still infer a correct
 * diameter/centre:
 *   - sparse-but-full-extent data (the point of the threshold), and
 *   - an edge ring / annulus, which is centroid-symmetric and reaches the true edge.
 * A small symmetric cluster far from the true centre is geometrically
 * indistinguishable from a tiny full wafer and is likewise not guessed at — the
 * documented remedy for all ambiguous cases is to supply waferConfig.center.
 */
function isLikelyPartialCoverage(gridPoints, offsetX, offsetY) {
    let cMin = Infinity, cMax = -Infinity, rMin = Infinity, rMax = -Infinity;
    let cSum = 0, rSum = 0;
    for (const p of gridPoints) {
        const col = Math.round(p.x) - offsetX;
        const row = Math.round(p.y) - offsetY;
        cSum += col;
        rSum += row;
        if (col < cMin)
            cMin = col;
        if (col > cMax)
            cMax = col;
        if (row < rMin)
            rMin = row;
        if (row > rMax)
            rMax = row;
    }
    const n = gridPoints.length;
    const centroidOffset = (sum, min, max) => {
        const half = (max - min) / 2;
        if (half <= 0)
            return 0; // single column/row — no axis information
        return Math.abs(sum / n - (min + max) / 2) / half;
    };
    return centroidOffset(cSum, cMin, cMax) > 0.11 || centroidOffset(rSum, rMin, rMax) > 0.11;
}
function resolveAxisFlips(dieOpts, origin) {
    let flipX = dieOpts?.xAxisDirection === 'left';
    let flipY = dieOpts?.yAxisDirection === 'down';
    if (origin.type === 'UL' || origin.type === 'UR')
        flipY = true;
    if (origin.type === 'LR' || origin.type === 'UR')
        flipX = true;
    return { flipX, flipY };
}
// ── Lot-stack aggregation ─────────────────────────────────────────────────────
function collapseLotStack(lotStack, testDefs) {
    const { method, targetBin } = lotStack;
    // Lot-stack aggregation combines multiple wafers' values at "the same
    // physical die" — a concept that only makes sense by position. An
    // unpositioned die has no cross-wafer position identity to aggregate by,
    // so it's excluded from the stack entirely rather than aggregated
    // meaninglessly.
    const waferResults = lotStack.results.map(wafer => wafer.filter(hasPosition));
    // 1. Scalar numeric aggregations: mean, median, stddev, min, max, count.
    // Collect all unique testValues keys across all wafers, then run aggregateValues
    // once per key and merge results back so multi-test data isn't silently dropped.
    // Functional tests (testType 'F') are skipped — a mean/median/σ of a pass/fail
    // outcome is meaningless; this also keeps legacy 0/1-encoded functional values
    // out of value stacks. Keys with no matching def default to parametric.
    if (method === 'mean' || method === 'median' || method === 'stddev' || method === 'min' || method === 'max' || method === 'count') {
        const functionalKeys = new Set((testDefs ?? []).filter(td => !isParametricTest(td)).map(td => td.testNumber));
        const testKeys = new Set();
        for (const wafer of waferResults) {
            for (const die of wafer) {
                if (die.testValues) {
                    for (const k of Object.keys(die.testValues)) {
                        const key = Number(k);
                        if (!functionalKeys.has(key))
                            testKeys.add(key);
                    }
                }
            }
        }
        // No testValues keys at all — fall back to paramIndex 0 (legacy values[] path).
        if (testKeys.size === 0) {
            return aggregateValues(waferResults, method);
        }
        // Aggregate each test key independently and merge into a single testValues map per die.
        const keyList = [...testKeys];
        const perKey = keyList.map(k => aggregateValues(waferResults, method, k));
        // Build a position → merged testValues map from the per-key results.
        const mergedMap = new Map();
        for (let ki = 0; ki < keyList.length; ki++) {
            const testKey = keyList[ki];
            for (const die of perKey[ki]) {
                const pos = getDieKey(die);
                if (!mergedMap.has(pos))
                    mergedMap.set(pos, { template: die, testValues: {} });
                const entry = mergedMap.get(pos);
                const v = die.testValues?.[0]; // aggregateValues stores result at key 0
                if (v !== undefined)
                    entry.testValues[testKey] = v;
            }
        }
        return [...mergedMap.values()].map(({ template, testValues }) => ({
            ...template,
            testValues: Object.keys(testValues).length > 0 ? testValues : undefined,
        }));
    }
    // 2. Bin occurrence aggregations: countBin, percent.
    // Reuses template-based logic (assumes consistent wafer layout).
    if (method === 'countBin' || method === 'percent') {
        if (targetBin === undefined)
            return [];
        const aggregated = aggregateBinCounts(waferResults, targetBin, 'hard');
        if (method === 'countBin')
            return aggregated;
        const totalWafers = waferResults.length;
        return aggregated.map(dr => ({
            ...dr,
            testValues: { 0: totalWafers > 0 ? ((dr.testValues?.[0] ?? 0) / totalWafers) * 100 : 0 }
        }));
    }
    // 3. Mode aggregation: Categorical (most frequent hbin).
    // Remains local as core/aggregates focuses on testValue scalars.
    if (method === 'mode') {
        const grouped = new Map();
        for (const waferPoints of waferResults) {
            for (const pt of waferPoints) {
                const key = getDieKey(pt);
                if (!grouped.has(key))
                    grouped.set(key, []);
                grouped.get(key).push(pt);
            }
        }
        const result = [];
        for (const [key, points] of grouped) {
            const parts = key.split(',');
            const x = Number(parts[0]);
            const y = Number(parts[1]);
            const bins = points.map(p => p.hbin).filter((b) => b !== undefined);
            const m = modeOf(bins);
            if (m !== null)
                result.push({ x, y, hbin: m });
        }
        return result;
    }
    return [];
}
// ── Coverage & yield ──────────────────────────────────────────────────────────
function computeCoverage(dies) {
    const positioned = dies.filter(hasPosition);
    const totalDies = positioned.length;
    const edgeExcludedDies = positioned.filter(d => d.edgeExcluded).length;
    const filledDies = positioned.filter(d => dieHasTestData(d) || d.hbin !== undefined || d.sbin !== undefined).length;
    return {
        filledDies,
        totalDies,
        edgeExcludedDies,
        unpositionedDies: dies.length - positioned.length,
        ratio: totalDies > 0 ? filledDies / totalDies : 0,
    };
}
function computeYield(dies, passBins, edgeDieYieldMode = 'exclude') {
    const passBinSet = new Set(passBins);
    const fullDies = dies.filter(d => !d.partial);
    const edgeCount = fullDies.filter(d => d.edgeExcluded).length;
    const partialDies = dies.filter(d => d.partial).length;
    let passDies = 0;
    let failDies = 0;
    let hasBinData = false;
    for (const die of dies) {
        if (!isYieldEligibleDie(die))
            continue;
        const bin = die.hbin ?? die.sbin;
        if (bin !== undefined) {
            hasBinData = true;
            if (passBinSet.has(bin))
                passDies++;
            else
                failDies++;
        }
    }
    const totalDies = passDies + failDies;
    const yieldPercent = hasBinData && totalDies > 0 ? (passDies / totalDies) * 100 : null;
    let yieldPercentGross = null;
    if (edgeDieYieldMode === 'denominator-only') {
        const grossDenom = totalDies + edgeCount;
        yieldPercentGross = hasBinData && grossDenom > 0 ? (passDies / grossDenom) * 100 : null;
    }
    return {
        passDies,
        failDies,
        edgeExcludedDies: edgeCount,
        partialDies,
        totalDies,
        yieldPercent,
        yieldPercentGross,
    };
}
// ── Reticle builder ───────────────────────────────────────────────────────────
function buildReticles(reticleOpts, wafer, dies, diePitchX, diePitchY, 
// Grid-centering shift applied when building `dies` (see resolveGridOriginOffset):
// die.x/die.y = col/row + offsetX/offsetY. anchorDie is specified in public
// die.x/die.y coordinates, but generateReticleGrid places fields in the
// internal col/row (physX/physY) space, so it must be converted here.
offsetX = 0, offsetY = 0, 
// Physical placement of col/row (0,0) — i.e. physX/physY = col*pitchX - colMidX
// (see resolveCenterAnchor). The die grid is not necessarily centred on the
// wafer by a whole die pitch (partial/off-centre data), so this fractional
// remainder must be passed through or field boundaries drift off die edges.
colMidX = 0, colMidY = 0, 
// wafer.orientation (notch rotation) and the resolved data-pipeline axis flip
// (xAxisDirection/yAxisDirection/coordinateOrigin) — both already baked into
// `dies[].physX/physY` by applyOrientation/transformDies before this is called.
// generateReticleGrid returns rectangles in the PRE-bake physical frame, so the
// same bake must be replayed on each candidate here or the "does this field
// contain a die" test below compares mismatched frames and silently drops or
// keeps the wrong fields (see rotateAndFlip's doc comment).
orientation = 0, flipX = false, flipY = false) {
    if (!reticleOpts)
        return [];
    const anchorDie = reticleOpts.anchorDie ?? { x: 0, y: 0 };
    const all = generateReticleGrid(wafer, {
        width: reticleOpts.width,
        height: reticleOpts.height,
        diePitchX,
        diePitchY,
        anchorDie: { x: anchorDie.x - offsetX, y: anchorDie.y - offsetY },
        gridOrigin: { x: -colMidX, y: -colMidY },
    });
    // Only keep reticles that contain at least one die — fields that merely
    // overlap the wafer circle boundary but hold no dies should not be drawn.
    // `dies` here are already baked (applyOrientation → transformDies), while
    // generateReticleGrid emits pre-bake geometry, so replay the identical bake on
    // each candidate before testing containment — same composition, same order.
    const gridToBaked = affineCompose(affineMirror(flipX, flipY, wafer.center.x, wafer.center.y), affineRotation(orientation, wafer.center.x, wafer.center.y));
    return all.filter(r => {
        const hw = r.width / 2, hh = r.height / 2;
        const corners = [
            [r.x - hw, r.y - hh], [r.x + hw, r.y - hh],
            [r.x + hw, r.y + hh], [r.x - hw, r.y + hh],
        ].map(([x, y]) => affinePoint(gridToBaked, x, y));
        const x0 = Math.min(...corners.map(c => c.x));
        const x1 = Math.max(...corners.map(c => c.x));
        const y0 = Math.min(...corners.map(c => c.y));
        const y1 = Math.max(...corners.map(c => c.y));
        return dies.some(d => d.physX >= x0 && d.physX < x1 && d.physY >= y0 && d.physY < y1);
    });
}
// ── Edge exclusion ────────────────────────────────────────────────────────────
function applyEdgeExclusion(dies, wafer, exclusionMm) {
    const innerRadiusSq = (wafer.radius - exclusionMm) ** 2;
    return dies.map(die => {
        const dx = die.physX - wafer.center.x;
        const dy = die.physY - wafer.center.y;
        return dx * dx + dy * dy > innerRadiusSq ? { ...die, edgeExcluded: true } : die;
    });
}
// ── Retest deduplication ──────────────────────────────────────────────────────
function applyRetestPolicy(allResults, policy, passBins) {
    // Retesting is inherently a "same x/y tested more than once" concept — an
    // unpositioned die has no coordinate identity to dedupe by at this stage
    // (DieResult doesn't carry an id the way a built Die does), so it passes
    // through untouched, one result in, one result out, never merged with
    // another unpositioned entry.
    const results = allResults.filter(hasPosition);
    const unpositioned = allResults.filter(r => !hasPosition(r));
    const counts = new Map();
    for (const d of results) {
        let yCounts = counts.get(d.x);
        if (!yCounts) {
            yCounts = new Map();
            counts.set(d.x, yCounts);
        }
        yCounts.set(d.y, (yCounts.get(d.y) ?? 0) + 1);
    }
    const passBinSet = new Set(passBins);
    // Returns true when the candidate should replace the existing winner.
    // 'best': pass beats fail; within same category, lower hbin wins.
    // 'worst': fail beats pass; within same category, higher hbin wins.
    function shouldReplace(existing, candidate) {
        const eHbin = existing.hbin;
        const cHbin = candidate.hbin;
        if (eHbin === undefined || cHbin === undefined)
            return false;
        const ePass = passBinSet.has(eHbin);
        const cPass = passBinSet.has(cHbin);
        if (policy === 'best') {
            if (cPass !== ePass)
                return cPass; // pass beats fail
            return cHbin < eHbin; // tiebreak: lower bin number
        }
        else {
            if (cPass !== ePass)
                return ePass; // fail beats pass (i.e. replace when existing is pass)
            return cHbin > eHbin; // tiebreak: higher bin number
        }
    }
    const winners = new Map();
    for (const d of results) {
        const key = getDieKey(d);
        const existing = winners.get(key);
        if (policy === 'first' && existing)
            continue;
        if ((policy === 'best' || policy === 'worst') && existing) {
            if (!shouldReplace(existing, d))
                continue;
        }
        winners.set(key, d);
    }
    const deduped = Array.from(winners.values()).map(d => {
        const xMap = counts.get(d.x);
        const count = xMap?.get(d.y) ?? 1;
        return count > 1 ? { ...d, retestCount: count } : d;
    });
    return [...deduped, ...unpositioned];
}
// ── Test value helpers ────────────────────────────────────────────────────────
/** Read a test value from a die by test number. */
export function getDieTestValue(die, testNumber) {
    return die.testValues?.[testNumber];
}
/**
 * Single read-path for "did this die pass test `testNumber`".
 *
 * Primary source: `die.testPass[testNumber]` (the tester's recorded verdict).
 * Migration fallback — the ONLY place this rule exists: a functional test
 * (`testType: 'F'`) with no `testPass` entry but a `testValues` entry of
 * exactly 0 or 1 is legacy encoding (1 = pass, 0 = fail) from callers that
 * predate `testPass`. The fallback is never applied to parametric tests,
 * whose values are measurements.
 *
 * Returns `undefined` when no verdict is recorded — callers must treat that
 * as no-data, never as a fail.
 */
export function getTestPassStatus(die, testNumber, testDef) {
    const recorded = die.testPass?.[testNumber];
    if (recorded !== undefined)
        return recorded;
    if (testDef !== undefined && !isParametricTest(testDef)) {
        const v = die.testValues?.[testNumber];
        if (v === 0 || v === 1)
            return v === 1;
    }
    return undefined;
}
/**
 * True when the die carries any per-test data — a measured test value or a
 * recorded pass/fail verdict. The single source for "does this die have test
 * data", used by coverage, plot-mode inference, and the toolbar's value-mode
 * availability checks.
 */
export function dieHasTestData(die) {
    return (die.testValues !== undefined && Object.keys(die.testValues).length > 0) ||
        (die.testPass !== undefined && Object.keys(die.testPass).length > 0);
}
// ── Data attachment ───────────────────────────────────────────────────────────
function attachData(die, pt) {
    const base = {};
    if (pt.hbin !== undefined)
        base.hbin = pt.hbin;
    if (pt.sbin !== undefined)
        base.sbin = pt.sbin;
    if (pt.retestCount !== undefined)
        base.retestCount = pt.retestCount;
    if (pt.siteNum !== undefined)
        base.siteNum = pt.siteNum;
    if (pt.partId !== undefined)
        base.partId = pt.partId;
    if (pt.metadata !== undefined)
        base.metadata = pt.metadata;
    if (pt.testPass !== undefined)
        base.testPass = pt.testPass;
    if (pt.testValues !== undefined)
        base.testValues = pt.testValues;
    return { ...die, ...base };
}
function autoPlotMode(results, opts) {
    if (opts.plotMode)
        return opts.plotMode;
    return results.some(dieHasTestData) ? 'value' : 'hardBin';
}
// ── Main entry point ──────────────────────────────────────────────────────────
/**
 * Build a complete wafer map from any level of input.
 *
 * `x` and `y` in each result are **die grid positions** (prober step
 * coordinates — integers like −7, 0, 5), not millimetre values.  Supply
 * whatever geometry you have; the library infers the rest.
 *
 * @example Minimal — grid positions + values only (all geometry inferred):
 * ```ts
 * const result = buildWaferMap([
 *   { x:  0, y:  0, values: [0.95] },
 *   { x:  1, y:  0, values: [0.87] },
 *   { x:  0, y: -1, values: [0.91] },
 * ]);
 * renderWaferMap(document.getElementById('map'), result);
 * ```
 *
 * @example Multiple tests and bins supplied directly:
 * ```ts
 * const result = buildWaferMap({
 *   results: rows.map(r => ({
 *     x: +r.x, y: +r.y,
 *     values: [+r.testA, +r.testB, +r.testC],
 *     hbin: +r.hbin,
 *     sbin: +r.sbin,
 *   })),
 *   dieConfig: { width: 10, height: 10 },
 * });
 * ```
 *
 * @example Reticle overlay phased to die (2,1):
 * ```ts
 * const result = buildWaferMap({
 *   results,
 *   dieConfig: { width: 10, height: 10 },
 *   reticleConfig: { width: 4, height: 2, anchorDie: { x: 2, y: 1 } },
 * });
 * ```
 *
 * @example Aggregate bin failures across six wafers:
 * ```ts
 * const result = buildWaferMap({
 *   waferConfig: { diameter: 300 },
 *   dieConfig:   { width: 10, height: 10 },
 *   lotStack:    { results: [wafer1, wafer2, wafer3, wafer4, wafer5, wafer6],
 *               method: 'countBin', targetBin: 2 },
 * });
 * ```
 */
/**
 * Derive the structured, public `WaferMapResult.warnings` array from the
 * internal `inference` record. Single source of truth for both build paths.
 *
 * Only genuinely questionable inference is flagged. Normalized-unit geometry
 * (raw prober steps with no physical dimensions) is the library's primary
 * supported input and is inferred confidently — it is NOT a warning. The one
 * advisory today is `'partial-coverage'`: data that does not span a full
 * symmetric wafer, where the inferred diameter/centre may be wrong. That
 * detection lives at the inference site; here we just promote its message(s)
 * into the structured shape.
 */
function buildWarnings(inference) {
    return (inference.warnings ?? []).map(message => ({
        code: codeForAdvisory(message),
        message,
        // Every geometry advisory means die positions may be wrong — a wrong-looking
        // map, not a missing feature — so all three are errors rather than notices.
        severity: 'error',
        confidence: inference.wafer.confidence,
    }));
}
/**
 * Map an advisory message to its stable machine-readable code.
 *
 * `inference.warnings` is the (deprecated) string channel and is the one place
 * both advisories are recorded, so the code is recovered here rather than being
 * assumed. Hosts branch on `code`, so stamping every message `'partial-coverage'`
 * — as this did before `'geometry-conflict'` existed — silently misclassifies it.
 * Keep this in step with the push sites in `buildWaferMap`.
 */
function codeForAdvisory(message) {
    if (message.includes(GEOMETRY_CONFLICT_MARKER))
        return 'geometry-conflict';
    if (message.includes(INFERRED_PITCH_MARKER))
        return 'inferred-pitch';
    return 'partial-coverage';
}
/** Distinctive phrase identifying the geometry-conflict advisory in the string channel. */
const GEOMETRY_CONFLICT_MARKER = 'do not fit inside the supplied';
/** Distinctive phrase identifying the inferred-pitch advisory in the string channel. */
const INFERRED_PITCH_MARKER = 'The die pitch was inferred as';
export function buildWaferMap(input, options) {
    const norm = normalizeInput(input);
    const { debug: _debug, ...viewOpts } = options ?? {};
    const rawResults = norm.lotStackOpts ? collapseLotStack(norm.lotStackOpts, norm.testDefs) : norm.results;
    // Fail fast on string coordinates — common mistake when piping CSV without numeric casting
    for (let i = 0; i < Math.min(5, rawResults.length); i++) {
        const r = rawResults[i];
        if (typeof r.x === 'string' || typeof r.y === 'string') {
            throw new TypeError(`buildWaferMap: x and y must be numbers, received strings. ` +
                `Did you forget to cast CSV values? e.g. { x: +row.x, y: +row.y }`);
        }
    }
    // A die is either fully positioned (both x and y) or fully unpositioned
    // (neither) — see hasPosition(). A caller that supplies only one of the
    // two almost certainly has a bug (a null x with a real y, say), and
    // silently treating it as "unpositioned" would hide that; fail fast
    // instead. Checked over every result, not just a sample, since this is
    // cheap and a partial-half-state bug could easily hide past the first few
    // rows of a real file.
    for (const r of rawResults) {
        if ((r.x == null) !== (r.y == null)) {
            throw new TypeError(`buildWaferMap: die has only one of x/y set (x=${String(r.x)}, y=${String(r.y)}). ` +
                `A die must be either fully positioned (both x and y) or fully unpositioned ` +
                `(neither) — omit both to mark a die as having no reported position.`);
        }
    }
    const results = applyRetestPolicy(rawResults, norm.retestPolicy, norm.passBins);
    const inference = {
        wafer: { confidence: 1.0, method: 'provided' },
        diePitch: { confidence: 1.0, units: 'mm' },
        grid: { confidence: 1.0 },
    };
    // ── Explicit dies path ─────────────────────────────────────────────────────
    if (norm.explicitDies) {
        let dies = norm.explicitDies;
        // Pre-built dies always carry their own position (they're a caller-supplied
        // layout) — only `results` (the data to attach) can include unpositioned
        // entries. Those never match anything by getDieKey (an unpositioned die
        // keys by id, which no pre-built die shares), so they're carried through
        // separately below rather than silently dropped.
        const positionedResults = results.filter(hasPosition);
        const unpositionedResults = results.filter(r => !hasPosition(r));
        if (positionedResults.length > 0) {
            const lookup = new Map(positionedResults.map(d => [getDieKey(d), d]));
            dies = dies.map(die => {
                const pt = lookup.get(getDieKey(die));
                return pt ? attachData(die, pt) : die;
            });
        }
        const diameter = norm.waferOpts?.diameter ?? 300;
        const wafer = createWafer({
            diameter,
            notch: norm.waferOpts?.notch,
            orientation: norm.waferOpts?.orientation ?? 0,
            metadata: norm.waferOpts?.metadata,
        });
        // Pre-built dies carry the caller's own physX/physY, but waferConfig.orientation
        // is still honored — applyOrientation rotates them to match, the same as the
        // main (results-based) path. Without this, the wafer notch and any overlay that
        // rotates with wafer.orientation (reticle fields, quadrant boundaries) would
        // rotate while the dies themselves stayed put.
        // explicitDies is a caller-supplied pre-built layout, always positioned
        // by convention (not part of the coordinate-less data path).
        if (wafer.orientation !== 0)
            dies = applyOrientation(dies, wafer);
        const reticles = buildReticles(norm.reticleOpts, wafer, dies, 1, 1, 0, 0, 0, 0, wafer.orientation);
        const showReticle = viewOpts.showReticle ?? (norm.reticleOpts !== undefined);
        const view = buildView(wafer, dies, {
            ...viewOpts,
            reticles,
            showReticle,
            plotMode: autoPlotMode(results, viewOpts),
            testDefs: norm.testDefs,
            isLotStack: false,
        }, { hbinDefs: norm.hbinDefs, sbinDefs: norm.sbinDefs, metadataFields: norm.metadataFields });
        const unpositionedDies = unpositionedResults.map((pt, i) => attachData({ id: `unpositioned_${i}`, width: dies[0]?.width ?? 1, height: dies[0]?.height ?? 1 }, pt));
        const allDies = [...dies, ...unpositionedDies];
        return {
            wafer, dies: allDies, view, reticleConfig: norm.reticleOpts, units: 'mm', inference,
            warnings: buildWarnings(inference),
            plotMode: view.plotMode,
            metadata: view.metadata,
            isLotStack: false,
            dataCoverage: computeCoverage(allDies),
            yield: computeYield(allDies, norm.passBins, norm.edgeDieYieldMode),
            reticles,
            hbinDefs: norm.hbinDefs,
            sbinDefs: norm.sbinDefs,
            testDefs: norm.testDefs,
            metadataFields: norm.metadataFields,
        };
    }
    // ── Grid-position path ─────────────────────────────────────────────────────
    // Geometry inference (pitch, origin, grid, wafer diameter, orientation,
    // edge-exclusion, reticle assignment) runs only on positioned dies —
    // unpositioned ones have no coordinates to infer from and are folded back
    // in at the very end, after `dies`/`view`/`reticles` are all built from
    // the positioned subset only.
    const positionedResults = results.filter(hasPosition);
    const unpositionedResults = results.filter(r => !hasPosition(r));
    const gridPoints = positionedResults.map(d => ({ x: d.x, y: d.y }));
    const pitchResult = resolveGridPitch(gridPoints, norm.dieOpts, norm.waferOpts?.diameter);
    inference.diePitch = { confidence: pitchResult.confidence, units: pitchResult.units };
    const { pitchX, pitchY } = pitchResult;
    const units = pitchResult.units;
    const origin = detectOrigin(positionedResults, norm.dieOpts);
    const ga = assignGridIndices(gridPoints);
    inference.grid = { confidence: ga.confidence };
    const { offsetX, offsetY } = resolveGridOriginOffset(gridPoints, origin, ga);
    const { flipX, flipY } = resolveAxisFlips(norm.dieOpts, origin);
    // Physical offset placing the wafer centre at (0,0).  When waferConfig.center
    // is given that prober coordinate is anchored to (0,0); otherwise the observed
    // data extent is centred (correct only for full, symmetric coverage).
    const { colMidX, colMidY, anchored } = resolveCenterAnchor(gridPoints, offsetX, offsetY, pitchX, pitchY, norm.waferOpts?.center);
    let waferDiameter = norm.waferOpts?.diameter;
    // Physical positions in the same frame as die placement (relative to the
    // resolved wafer centre), so an anchored centre yields a circle that encloses
    // the data about the true centre — not the data midpoint.
    const physPoints = gridPoints.map(p => ({
        x: (Math.round(p.x) - offsetX) * pitchX - colMidX,
        y: (Math.round(p.y) - offsetY) * pitchY - colMidY,
    }));
    /**
     * Distance from the wafer centre to the furthest corner of the furthest die.
     *
     * Every die here came from `results`, i.e. a real tested prober position — and a
     * prober can only step to sites that sit entirely on the wafer, so a probed die
     * is never edge-straddling. The wafer must therefore be at least this big; the
     * die extent is ground truth and the inferred diameter is the guess.
     */
    const requiredRadius = physPoints.length > 0
        ? Math.max(...physPoints.map(({ x: px, y: py }) => Math.hypot(Math.abs(px) + pitchX / 2, Math.abs(py) + pitchY / 2)))
        : 0;
    if (waferDiameter === undefined) {
        if (gridPoints.length > 0) {
            if (units === 'mm') {
                // pitchX/pitchY are real mm — physPoints are true physical positions.
                const wi = inferWaferFromXY(physPoints, { minRadius: requiredRadius });
                waferDiameter = wi.diameter;
                inference.wafer = { confidence: wi.confidence, method: wi.method };
            }
            else {
                // pitchX/pitchY are normalized (no physical info). inferWaferFromXY would
                // receive coordinates in normalized units and produce a meaningless diameter.
                // Instead derive the diameter directly from the grid step extents: the
                // furthest corner distance for each data die is the radius the circle must
                // reach to fully enclose that die. Using corners (not centres) handles
                // asymmetric grids where one side extends further from (0,0) than the other.
                // Uses the true maximum, not a percentile: every die is a real probed site
                // and must fit inside the circle, so there are no outliers to trim here.
                waferDiameter = requiredRadius * 2;
                inference.wafer = { confidence: pitchResult.confidence * 0.8, method: 'extent' };
            }
        }
        else {
            waferDiameter = units === 'mm' ? 300 : 30;
            inference.wafer = { confidence: 0, method: 'default' };
        }
    }
    // Partial-data guard: when the wafer centre was not anchored (no
    // waferConfig.center) and the data does not look like full symmetric
    // coverage, the inferred centre/diameter may be wrong and dies may be
    // mis-positioned relative to the true boundary. Surface this so callers are
    // not silently misled (see WaferMapResult.inference.warnings).
    if (!anchored && gridPoints.length > 0 && isLikelyPartialCoverage(gridPoints, offsetX, offsetY)) {
        inference.wafer.method = 'inferred-partial';
        (inference.warnings ??= []).push('Wafer geometry was inferred from die positions alone. The data does not span a full symmetric wafer, so the inferred diameter and centre may be wrong and dies may be mis-positioned relative to the true wafer boundary. Supply waferConfig.diameter and waferConfig.center (the prober coordinate of the wafer centre) to position partial data correctly.');
    }
    // Geometry advisories for caller-supplied `waferConfig.diameter`.
    //
    // A "these dies don't fit" claim is only meaningful when the die pitch was ALSO
    // supplied. Pitch is a free scaling parameter: for any set of grid positions and
    // any diameter there is always a pitch small enough to fit them, so with an
    // inferred pitch "doesn't fit" says nothing about the data. `resolveGridPitch`
    // already clamps an inferred pitch so it's guaranteed to fit the given diameter
    // (see its containment clamp), so this check would never have anything to
    // report for that path anyway — it's restricted to the case below where the
    // caller supplied both diameter and pitch, which is the only case where "doesn't
    // fit" is a genuine, checkable contradiction rather than something the inference
    // itself would have prevented.
    if (norm.waferOpts?.diameter !== undefined) {
        const pitchWasSupplied = norm.dieOpts?.width !== undefined && norm.dieOpts?.height !== undefined;
        if (pitchWasSupplied && requiredRadius > waferDiameter / 2 + 1e-9) {
            // Both quantities were asserted by the caller, so this is a genuine
            // contradiction between two independent facts — and by the prober invariant
            // the die positions are the trustworthy one. We do NOT silently resize.
            const outside = physPoints.filter(({ x: px, y: py }) => Math.hypot(Math.abs(px) + pitchX / 2, Math.abs(py) + pitchY / 2) > waferDiameter / 2 + 1e-9).length;
            (inference.warnings ??= []).push(`${outside} of ${physPoints.length} probed die positions ${GEOMETRY_CONFLICT_MARKER} ${waferDiameter} mm wafer ` +
                `at the supplied die pitch of ${pitchX} × ${pitchY} mm. A die with test results is a real prober position and is ` +
                `always fully on the wafer, so one of the two supplied values must be wrong: containing these dies at this pitch ` +
                `would need a diameter of at least ${(requiredRadius * 2).toFixed(1)} mm. Check waferConfig.diameter and ` +
                `dieConfig.width/height against the real device.`);
        }
        else if (!pitchWasSupplied) {
            // Containment is guaranteed (resolveGridPitch clamps to fit the diameter),
            // but the assumed aspect ratio may still not match the true die shape when
            // edge dies are absent from the data (a reticle-complete map, or partial
            // dies filtered out upstream) — only worth flagging that as an
            // unverifiable assumption made on the caller's behalf.
            (inference.warnings ??= []).push(`${INFERRED_PITCH_MARKER} ${pitchX.toFixed(3)} × ${pitchY.toFixed(3)} mm by assuming the die grid fits within the ` +
                `${waferDiameter} mm wafer. The assumed aspect ratio may not match the true die shape whenever edge dies are ` +
                `absent from the data (a reticle-complete map, or partial dies filtered out upstream). Supply dieConfig.width and ` +
                `dieConfig.height — the die pitch, not the diameter, is what fixes placement.`);
        }
    }
    const wafer = createWafer({
        diameter: waferDiameter,
        notch: norm.waferOpts?.notch,
        orientation: norm.waferOpts?.orientation ?? 0,
        metadata: norm.waferOpts?.metadata,
    });
    const dieConfigGeom = { width: pitchX, height: pitchY };
    // Build dies directly from data positions — never generate positions without data.
    //
    // `partial` is always false here. Each of these dies came from `results`, i.e. a
    // real tested prober position, and a prober only steps to sites that lie entirely
    // on the wafer — a prober map never contains edge-straddling dies. Deriving the
    // flag by testing die corners against the wafer circle inverted that: it treated
    // the (often inferred) geometry as truth and the measured data as suspect, so an
    // undersized circle invented partial dies that cannot physically exist, greyed
    // them out, and silently dropped them from yield. When the geometry genuinely
    // cannot contain the data we now say so via inference.warnings above instead.
    //
    // `partial` remains meaningful for a synthesized die grid clipped to a wafer —
    // see `clipDiesToWafer`, which is where straddling dies legitimately arise.
    let dies = positionedResults.map(pt => {
        const col = Math.round(pt.x) - offsetX;
        const row = Math.round(pt.y) - offsetY;
        const base = {
            id: `${col}_${row}`,
            x: col, y: row,
            physX: col * pitchX - colMidX,
            physY: row * pitchY - colMidY,
            width: pitchX, height: pitchY,
            insideWafer: true,
            partial: false,
        };
        return attachData(base, pt);
    });
    // Shift x/y from centred grid indices to original input coordinates.
    // die.physX/physY remain unchanged — only the public identity fields move.
    if (offsetX !== 0 || offsetY !== 0) {
        dies = dies.map(die => ({
            ...die,
            x: die.x + offsetX,
            y: die.y + offsetY,
            id: `${die.x + offsetX}_${die.y + offsetY}`,
        }));
    }
    dies = applyOrientation(dies, wafer);
    if (flipX || flipY) {
        dies = transformDies(dies, { flipX, flipY }, wafer.center);
    }
    if (norm.waferOpts?.edgeExclusion && norm.waferOpts.edgeExclusion > 0) {
        dies = applyEdgeExclusion(dies, wafer, norm.waferOpts.edgeExclusion);
    }
    const reticles = buildReticles(norm.reticleOpts, wafer, dies, pitchX, pitchY, offsetX, offsetY, colMidX, colMidY, wafer.orientation, flipX, flipY);
    const showReticle = viewOpts.showReticle ?? (norm.reticleOpts !== undefined);
    const view = buildView(wafer, dies, {
        ...viewOpts,
        reticles,
        showReticle,
        plotMode: autoPlotMode(results, viewOpts),
        testDefs: norm.testDefs,
        dataAxisFlip: { x: flipX, y: flipY },
        isLotStack: norm.lotStackOpts !== undefined,
        aggregationMethod: norm.lotStackOpts?.method,
        lotSize: norm.lotStackOpts?.results.length,
    }, { hbinDefs: norm.hbinDefs, sbinDefs: norm.sbinDefs, metadataFields: norm.metadataFields });
    // Unpositioned dies never went through grid/geometry inference above (no
    // coordinates to infer from) — folded in only now, so `view`/`reticles`
    // (the render draw list) reflect positioned dies only, while the returned
    // `dies` and every stat computed below sees the full population.
    const unpositionedDies = unpositionedResults.map((pt, i) => attachData({ id: `unpositioned_${i}`, width: pitchX, height: pitchY }, pt));
    const allDies = [...dies, ...unpositionedDies];
    return {
        wafer, dies: allDies, view, reticleConfig: norm.reticleOpts, units, inference,
        warnings: buildWarnings(inference),
        plotMode: view.plotMode,
        metadata: view.metadata,
        isLotStack: norm.lotStackOpts !== undefined,
        dataCoverage: computeCoverage(allDies),
        yield: computeYield(allDies, norm.passBins, norm.edgeDieYieldMode),
        reticles,
        hbinDefs: norm.hbinDefs,
        sbinDefs: norm.sbinDefs,
        testDefs: norm.testDefs,
        metadataFields: norm.metadataFields,
        aggrMethod: view.aggrMethod,
        lotSize: view.lotSize,
    };
}
//# sourceMappingURL=buildWaferMap.js.map