import { range, toSortedArray } from "../../utils/utils.js";
import { colCache } from "./utils/col-cache.js";
/**
 * Wrapper for OOXML error values in sharedItems (e.g. `<e v="REF!"/>`).
 * Distinguishes error strings from regular strings so they roundtrip as `<e>` not `<s>`.
 */
export class PivotErrorValue {
    constructor(code) {
        this.code = code;
    }
    /** Returns the display form with '#' prefix, e.g. "#REF!" */
    toString() {
        return `#${this.code}`;
    }
}
/** Map from PivotTableSubtotal to its Excel display name prefix */
export const METRIC_DISPLAY_NAMES = {
    sum: "Sum",
    count: "Count",
    average: "Average",
    max: "Max",
    min: "Min",
    product: "Product",
    countNums: "Count Numbers",
    stdDev: "StdDev",
    stdDevP: "StdDevP",
    var: "Var",
    varP: "VarP"
};
/** Set of all valid PivotTableSubtotal values (for runtime validation) */
export const VALID_SUBTOTALS = new Set(Object.keys(METRIC_DISPLAY_NAMES));
/**
 * Data rows start at index 2 in ExcelJS sparse arrays:
 * index 0 = empty (ExcelJS convention), index 1 = header row.
 */
const DATA_START_INDEX = 2;
/**
 * Creates a PivotTableSource adapter from a Table object.
 * This allows Tables to be used as pivot table data sources with the same interface as Worksheets.
 */
function createTableSourceAdapter(table) {
    const tableModel = table.model;
    // Validate that table has headerRow enabled (required for pivot table column names)
    if (tableModel.headerRow === false) {
        throw new Error("Cannot create pivot table from a table without headers. Set headerRow: true on the table.");
    }
    // Validate table has data rows
    if (!tableModel.rows || tableModel.rows.length === 0) {
        throw new Error("Cannot create pivot table from an empty table. Add data rows to the table.");
    }
    const columnNames = tableModel.columns.map(col => col.name);
    // Check for duplicate column names
    const nameSet = new Set();
    for (const name of columnNames) {
        if (nameSet.has(name)) {
            throw new Error(`Duplicate column name "${name}" found in table. Pivot tables require unique column names.`);
        }
        nameSet.add(name);
    }
    // Build the full data array: headers + rows
    const headerRow = [undefined, ...columnNames]; // sparse array starting at index 1
    const dataRows = tableModel.rows.map(row => [undefined, ...row]); // sparse array starting at index 1
    // Calculate the range reference for the table
    const tl = tableModel.tl;
    if (!tl) {
        throw new Error(`Table "${tableModel.name}" is missing top-left cell address (tl)`);
    }
    const startRow = tl.row;
    const startCol = tl.col;
    const endRow = startRow + tableModel.rows.length; // header row + data rows
    const endCol = startCol + columnNames.length - 1;
    const shortRange = colCache.encode(startRow, startCol, endRow, endCol);
    // Use the worksheet name (not table name) for pivotCacheDefinition's worksheetSource
    // The sheet attribute in worksheetSource must reference the actual worksheet name
    const worksheetName = table.worksheet.name;
    const tableName = tableModel.name;
    return {
        name: worksheetName,
        tableName,
        getRow(rowNumber) {
            if (rowNumber === 1) {
                return { values: headerRow };
            }
            const dataIndex = rowNumber - DATA_START_INDEX; // rowNumber 2 maps to index 0
            if (dataIndex >= 0 && dataIndex < dataRows.length) {
                return { values: dataRows[dataIndex] };
            }
            return { values: [] };
        },
        getColumn(columnNumber) {
            // Validate column number is within bounds
            if (columnNumber < 1 || columnNumber > columnNames.length) {
                return { values: [] };
            }
            // Values should be sparse array with header at index 1, data starting at index 2
            const values = [];
            values[1] = columnNames[columnNumber - 1];
            for (let i = 0; i < tableModel.rows.length; i++) {
                values[i + 2] = tableModel.rows[i][columnNumber - 1];
            }
            return { values };
        },
        getSheetValues() {
            // Return sparse array where index 1 is header row, and subsequent indices are data rows
            const result = [];
            result[1] = headerRow;
            for (let i = 0; i < dataRows.length; i++) {
                result[i + 2] = dataRows[i];
            }
            return result;
        },
        dimensions: { shortRange }
    };
}
/** Base cache ID starts at 10 (Excel convention), each subsequent table increments */
const BASE_CACHE_ID = 10;
/**
 * Resolves the data source from the model, supporting both sourceSheet and sourceTable.
 * Validates that exactly one source is provided.
 */
function resolveSource(model) {
    if (model.sourceSheet && model.sourceTable) {
        throw new Error("Cannot specify both sourceSheet and sourceTable. Choose one.");
    }
    if (model.sourceTable) {
        return createTableSourceAdapter(model.sourceTable);
    }
    if (!model.sourceSheet) {
        throw new Error("Either sourceSheet or sourceTable must be provided.");
    }
    return model.sourceSheet;
}
/** Resolve a value entry to its column name string */
function resolveValueName(v) {
    return typeof v === "string" ? v : v.name;
}
/** Resolve a value entry's metric (or undefined if inheriting table-wide default) */
function resolveValueMetric(v) {
    return typeof v === "string" ? undefined : v.metric;
}
function makePivotTable(worksheet, model) {
    // Resolve source (validates exactly one source is provided)
    const source = resolveSource(model);
    validate(model, source);
    const { rows } = model;
    const columns = model.columns ?? [];
    const pages = model.pages ?? [];
    const valueNames = model.values.map(resolveValueName);
    const defaultMetric = model.metric ?? "sum";
    const valueMetrics = model.values.map(v => resolveValueMetric(v) ?? defaultMetric);
    const cacheFields = makeCacheFields(source, [...rows, ...columns, ...pages], valueNames);
    const nameToIndex = {};
    for (let i = 0; i < cacheFields.length; i++) {
        nameToIndex[cacheFields[i].name] = i;
    }
    const resolveIndex = (fieldName, role) => {
        const idx = nameToIndex[fieldName];
        if (idx === undefined) {
            throw new Error(`${role} field "${fieldName}" not found in cache fields`);
        }
        return idx;
    };
    const rowIndices = rows.map(row => resolveIndex(row, "Row"));
    const columnIndices = columns.map(column => resolveIndex(column, "Column"));
    const valueIndices = valueNames.map(value => resolveIndex(value, "Value"));
    const pageIndices = pages.map(page => resolveIndex(page, "Page"));
    // R9-B1: Calculate tableNumber as max(existing)+1 to avoid collision with loaded
    // pivot tables that may have non-contiguous numbering (e.g. [1, 2, 5]).
    // Using length+1 would collide when adding multiple new tables.
    const existingTableNumbers = worksheet.workbook.pivotTables
        .map(pt => pt.tableNumber)
        .filter(n => Number.isFinite(n));
    const tableNumber = existingTableNumbers.length > 0
        ? existingTableNumbers.reduce((a, b) => (a > b ? a : b), -Infinity) + 1
        : 1;
    // Dynamic cacheId: avoid collision with existing (loaded) pivot tables.
    // Find the max cacheId already in use and start from max+1.
    const existingCacheIds = worksheet.workbook.pivotTables
        .map(pt => parseInt(pt.cacheId, 10))
        .filter(id => Number.isFinite(id));
    // R8-O3: Use reduce instead of Math.max(...spread) to avoid stack overflow with many pivot tables
    const nextCacheId = existingCacheIds.length > 0
        ? existingCacheIds.reduce((a, b) => (a > b ? a : b), -Infinity) + 1
        : BASE_CACHE_ID + tableNumber - 1;
    // form pivot table object
    return {
        source,
        rows: rowIndices,
        columns: columnIndices,
        values: valueIndices,
        pages: pageIndices,
        metric: defaultMetric,
        valueMetrics,
        cacheFields,
        cacheId: String(nextCacheId),
        // Control whether pivot table style overrides worksheet column widths
        // '0' = preserve worksheet column widths (useful for custom sizing)
        // '1' = apply pivot table style width/height (default Excel behavior)
        applyWidthHeightFormats: model.applyWidthHeightFormats ?? "1",
        // Table number for file naming (pivotTable1.xml, pivotTable2.xml, etc.)
        tableNumber
    };
}
function validate(model, source) {
    const columns = model.columns ?? [];
    const pages = model.pages ?? [];
    const valueNames = model.values.map(resolveValueName);
    // Get header names from source (already resolved)
    const headerNames = source.getRow(1).values.slice(1);
    // Validate no empty header names (null, undefined, empty-string, or whitespace-only).
    // Note: numeric 0 and boolean false are valid headers (they coerce to "0"/"false").
    for (let i = 0; i < headerNames.length; i++) {
        const h = headerNames[i];
        if (h === null || h === undefined || h === "" || (typeof h === "string" && h.trim() === "")) {
            throw new Error(`Empty or missing header name at column ${i + 1} in ${source.name}. Pivot tables require all columns to have non-empty headers.`);
        }
    }
    // Validate no duplicate header names
    const headerDupCheck = new Set();
    for (const h of headerNames) {
        const name = String(h);
        if (headerDupCheck.has(name)) {
            throw new Error(`Duplicate header name "${name}" found in ${source.name}. Pivot tables require unique column names.`);
        }
        headerDupCheck.add(name);
    }
    // Use Set for O(1) lookup — coerce to String for consistent comparison with user-supplied names
    const headerNameSet = new Set(headerNames.map(String));
    const validateFieldExists = (name) => {
        if (!headerNameSet.has(name)) {
            throw new Error(`The header name "${name}" was not found in ${source.name}.`);
        }
    };
    for (const name of model.rows) {
        validateFieldExists(name);
    }
    for (const name of columns) {
        validateFieldExists(name);
    }
    for (const name of valueNames) {
        validateFieldExists(name);
    }
    for (const name of pages) {
        validateFieldExists(name);
    }
    // Validate no duplicate field names across axis areas (rows, columns, pages).
    // A field can only belong to one axis area. Values can overlap with axis areas (dataField="1").
    const fieldToAxis = new Map();
    const axisAreas = [
        { name: "rows", fields: model.rows },
        { name: "columns", fields: columns },
        { name: "pages", fields: pages }
    ];
    for (const area of axisAreas) {
        for (const field of area.fields) {
            const existing = fieldToAxis.get(field);
            if (existing === area.name) {
                throw new Error(`Duplicate field "${field}" in ${area.name}. Each field can only appear once per axis area.`);
            }
            else if (existing) {
                throw new Error(`Field "${field}" cannot appear in both ${existing} and ${area.name}. Each field can only be assigned to one axis area.`);
            }
            fieldToAxis.set(field, area.name);
        }
    }
    if (!model.rows.length) {
        throw new Error("No pivot table rows specified.");
    }
    // Allow empty columns - Excel will use "Values" as column field
    if (model.values.length < 1) {
        throw new Error("Must have at least one value.");
    }
    // Validate metric values at runtime (guards against `as any` bypasses)
    if (model.metric !== undefined && !VALID_SUBTOTALS.has(model.metric)) {
        throw new Error(`Invalid metric "${model.metric}". Must be one of: ${[...VALID_SUBTOTALS].join(", ")}.`);
    }
    for (const v of model.values) {
        const perMetric = resolveValueMetric(v);
        if (perMetric !== undefined && !VALID_SUBTOTALS.has(perMetric)) {
            throw new Error(`Invalid metric "${perMetric}" on value field "${resolveValueName(v)}". Must be one of: ${[...VALID_SUBTOTALS].join(", ")}.`);
        }
    }
    // Validate no duplicate value field names
    const valueDupCheck = new Set();
    for (const name of valueNames) {
        if (valueDupCheck.has(name)) {
            throw new Error(`Duplicate value field "${name}". Each value field name must be unique.`);
        }
        valueDupCheck.add(name);
    }
    // Multiple values with columns is supported — the synthetic "Values" pseudo-field
    // (field x="-2") is appended to colFields so Excel positions the data field labels
    // correctly on the column axis.
}
/**
 * R8-B2: Unwrap complex cell value types to a primitive suitable for pivot table shared items.
 * - CellErrorValue ({error:"#REF!"}) → PivotErrorValue
 * - CellRichTextValue ({richText:[...]}) → concatenated plain text
 * - CellFormulaValue / CellArrayFormulaValue / CellSharedFormulaValue → result value (recursive)
 * - CellHyperlinkValue ({text,hyperlink}) → text string
 * - CellCheckboxValue ({checkbox:bool}) → boolean
 * - Other objects → String(v)
 */
function unwrapCellValue(v) {
    if (v === null || v === undefined) {
        return null;
    }
    if (typeof v !== "object") {
        // Already a primitive (string, number, boolean)
        return v;
    }
    if (v instanceof Date) {
        return v;
    }
    if (v instanceof PivotErrorValue) {
        return v;
    }
    const obj = v;
    // CellErrorValue: { error: "#REF!" } → PivotErrorValue with code "REF!" (strip leading #)
    if (typeof obj.error === "string") {
        const errorStr = obj.error;
        return new PivotErrorValue(errorStr.startsWith("#") ? errorStr.slice(1) : errorStr);
    }
    // CellFormulaValue / CellArrayFormulaValue / CellSharedFormulaValue: { formula/sharedFormula, result }
    if ("formula" in obj || "sharedFormula" in obj) {
        const result = obj.result;
        if (result === undefined || result === null) {
            return null;
        }
        // result can be number | string | boolean | Date | CellErrorValue — recurse
        return unwrapCellValue(result);
    }
    // CellRichTextValue: { richText: [{text:"..."}, ...] }
    if (Array.isArray(obj.richText)) {
        return obj.richText.map(rt => rt.text ?? "").join("");
    }
    // CellHyperlinkValue: { text, hyperlink }
    if (typeof obj.text === "string" && typeof obj.hyperlink === "string") {
        return obj.text;
    }
    // CellCheckboxValue: { checkbox: boolean }
    if (typeof obj.checkbox === "boolean") {
        return obj.checkbox;
    }
    // Unknown object — fallback to string
    return String(v);
}
function makeCacheFields(source, fieldNamesWithSharedItems, valueFieldNames) {
    // Cache fields are used in pivot tables to reference source data.
    // Fields in fieldNamesWithSharedItems get their unique values extracted as sharedItems.
    // Fields in valueFieldNames (but not in fieldNamesWithSharedItems) get min/max calculated.
    // Other fields are unused and get null sharedItems.
    const names = source.getRow(1).values;
    // Use Set for O(1) lookup instead of object
    const sharedItemsFields = new Set(fieldNamesWithSharedItems);
    const valueFields = new Set(valueFieldNames);
    const aggregate = (columnIndex) => {
        const columnValues = source.getColumn(columnIndex).values;
        // Build unique values set directly, skipping header (index 0,1).
        // null/undefined are tracked separately (collapsed to a single null sentinel)
        // because Set treats each null as the same key but undefined as a separate key,
        // and we need null in sharedItems so renderCellNew can find it via indexOf.
        const uniqueValues = new Set();
        // R8-B12: PivotErrorValue uses reference equality in Set, so track error strings
        // separately to avoid duplicates (e.g., two PivotErrorValue("#REF!") instances).
        const seenErrors = new Map();
        let hasNull = false;
        for (let i = DATA_START_INDEX; i < columnValues.length; i++) {
            // R8-B2: Unwrap complex cell values (formula results, rich text, errors, etc.)
            const v = unwrapCellValue(columnValues[i]);
            if (v === null || (typeof v === "number" && isNaN(v))) {
                hasNull = true;
            }
            else if (v instanceof PivotErrorValue) {
                // R8-B12: Deduplicate PivotErrorValue by error string
                if (!seenErrors.has(v.code)) {
                    seenErrors.set(v.code, v);
                    uniqueValues.add(v);
                }
            }
            else {
                uniqueValues.add(v);
            }
        }
        const sorted = toSortedArray(uniqueValues);
        // Append null at the end (OOXML convention: <m/> items go last in sharedItems)
        if (hasNull) {
            sorted.push(null);
        }
        return sorted;
    };
    // Calculate min/max and integer status for numeric fields
    const getMinMax = (columnIndex) => {
        const columnValues = source.getColumn(columnIndex).values;
        let min = Infinity;
        let max = -Infinity;
        let hasNumeric = false;
        let allInteger = true;
        for (let i = DATA_START_INDEX; i < columnValues.length; i++) {
            // R8-B10: Unwrap formula/complex cell values to extract numeric results
            const unwrapped = unwrapCellValue(columnValues[i]);
            if (typeof unwrapped === "number" && !isNaN(unwrapped)) {
                hasNumeric = true;
                if (unwrapped < min) {
                    min = unwrapped;
                }
                if (unwrapped > max) {
                    max = unwrapped;
                }
                if (!Number.isInteger(unwrapped)) {
                    allInteger = false;
                }
            }
        }
        return hasNumeric ? { minValue: min, maxValue: max, allInteger } : null;
    };
    // Build result array
    const result = [];
    for (const columnIndex of range(1, names.length)) {
        const rawName = names[columnIndex];
        const name = String(rawName);
        if (sharedItemsFields.has(name)) {
            // Field used for rows/columns - extract unique values as sharedItems
            result.push({ name, sharedItems: aggregate(columnIndex) });
        }
        else if (valueFields.has(name)) {
            // Field used only for values (aggregation) - calculate min/max
            const minMax = getMinMax(columnIndex);
            result.push({
                name,
                sharedItems: null,
                containsNumber: minMax ? "1" : undefined,
                minValue: minMax?.minValue,
                maxValue: minMax?.maxValue,
                containsInteger: minMax?.allInteger ? "1" : undefined
            });
        }
        else {
            // Unused field - just empty sharedItems (like Excel does)
            result.push({ name, sharedItems: null });
        }
    }
    return result;
}
export { makePivotTable };
