"use strict";
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
    if (k2 === undefined) k2 = k;
    var desc = Object.getOwnPropertyDescriptor(m, k);
    if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
      desc = { enumerable: true, get: function() { return m[k]; } };
    }
    Object.defineProperty(o, k2, desc);
}) : (function(o, m, k, k2) {
    if (k2 === undefined) k2 = k;
    o[k2] = m[k];
}));
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
    Object.defineProperty(o, "default", { enumerable: true, value: v });
}) : function(o, v) {
    o["default"] = v;
});
var __importStar = (this && this.__importStar) || (function () {
    var ownKeys = function(o) {
        ownKeys = Object.getOwnPropertyNames || function (o) {
            var ar = [];
            for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
            return ar;
        };
        return ownKeys(o);
    };
    return function (mod) {
        if (mod && mod.__esModule) return mod;
        var result = {};
        if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
        __setModuleDefault(result, mod);
        return result;
    };
})();
var __importDefault = (this && this.__importDefault) || function (mod) {
    return (mod && mod.__esModule) ? mod : { "default": mod };
};
Object.defineProperty(exports, "__esModule", { value: true });
exports.runCatalogImport = void 0;
const XLSX = __importStar(require("@e965/xlsx"));
const client_1 = require("@prisma/client");
const crypto_1 = __importDefault(require("crypto"));
const promises_1 = __importDefault(require("fs/promises"));
const path_1 = __importDefault(require("path"));
const sanitize_html_1 = __importDefault(require("sanitize-html"));
const database_1 = __importDefault(require("../config/database"));
const catalog_utils_1 = require("../utils/catalog.utils");
const EXPECTED_SHEETS = [
    'Products',
    'Inventory',
    'Categories',
    'Sub Categories',
    'Authors',
    'Publishers',
    'Product Text',
    'Custom Attributes',
    'Homepage Categories',
    'Header Categories',
    'Currencies',
    'Display Limits',
    'Catalogues',
    'Reconciliation',
];
const EXPECTED_COUNTS = {
    Products: 3083,
    Categories: 47,
    Authors: 1072,
    Publishers: 300,
    'Custom Attributes': 23166,
    Catalogues: 43,
};
const increment = (map, key) => {
    map.set(key, (map.get(key) ?? 0) + 1);
};
const duplicateGroupCount = (map) => [...map.values()].filter((count) => count > 1).length;
const rowNumber = (row) => Number(row.__rowNumber);
const rowValue = (row, column) => row[column] ?? '';
const countRecord = (record, key, amount = 1) => {
    record[key] = (record[key] ?? 0) + amount;
};
class ImportLogger {
    logPath;
    errorLogPath;
    summaryPath;
    writeQueue = Promise.resolve();
    constructor(logsDir, sourceFingerprint) {
        const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
        const prefix = `catalog-import-${timestamp}-${sourceFingerprint.slice(0, 10)}`;
        this.logPath = path_1.default.join(logsDir, `${prefix}.jsonl`);
        this.errorLogPath = path_1.default.join(logsDir, `${prefix}.errors.jsonl`);
        this.summaryPath = path_1.default.join(logsDir, `${prefix}.summary.json`);
    }
    async initialize() {
        await promises_1.default.mkdir(path_1.default.dirname(this.logPath), { recursive: true });
        // A successful run must still deliver an explicit (possibly empty) error log.
        await Promise.all([
            promises_1.default.writeFile(this.logPath, '', 'utf8'),
            promises_1.default.writeFile(this.errorLogPath, '', 'utf8'),
        ]);
    }
    enqueue(filePath, payload) {
        const line = `${JSON.stringify({ timestamp: new Date().toISOString(), ...payload })}\n`;
        this.writeQueue = this.writeQueue.then(() => promises_1.default.appendFile(filePath, line, 'utf8'));
        return this.writeQueue;
    }
    event(event, payload = {}) {
        return this.enqueue(this.logPath, { event, ...payload });
    }
    error(event, payload = {}) {
        void this.enqueue(this.logPath, { event, level: 'error', ...payload });
        return this.enqueue(this.errorLogPath, { event, level: 'error', ...payload });
    }
    async writeSummary(summary) {
        await this.writeQueue;
        await promises_1.default.writeFile(this.summaryPath, `${JSON.stringify(summary, null, 2)}\n`, 'utf8');
    }
    async flush() {
        await this.writeQueue;
    }
}
const workbookFingerprint = async (workbookPath) => {
    const data = await promises_1.default.readFile(workbookPath);
    return crypto_1.default.createHash('sha256').update(data).digest('hex');
};
const recordFromArray = (headers, values, sourceRow) => {
    const record = { __rowNumber: String(sourceRow) };
    headers.forEach((header, index) => {
        record[header] = (0, catalog_utils_1.asText)(values[index]);
    });
    return record;
};
const streamWorkbook = async (workbookPath, onRow, onSheet) => {
    // SheetJS is used for compatibility with the verified workbook's OPC entry
    // ordering. The file is only 4.8 MB; rows are released sheet-by-sheet and all
    // database writes are still processed in bounded chunks.
    const workbook = XLSX.readFile(workbookPath, {
        cellDates: false,
        cellFormula: true,
        cellText: true,
        dense: true,
    });
    for (const sheetName of workbook.SheetNames) {
        await onSheet?.(sheetName);
        const worksheet = workbook.Sheets[sheetName];
        const rows = XLSX.utils.sheet_to_json(worksheet, {
            header: 1,
            raw: false,
            defval: '',
            blankrows: true,
        });
        const headerRowIndex = rows.findIndex((row) => row.some((value) => (0, catalog_utils_1.cleanText)(value)));
        if (headerRowIndex < 0)
            continue;
        const headers = rows[headerRowIndex].map((value) => (0, catalog_utils_1.cleanText)(value));
        for (let rowIndex = headerRowIndex + 1; rowIndex < rows.length; rowIndex += 1) {
            const values = rows[rowIndex];
            if (!values.some((value) => (0, catalog_utils_1.cleanText)(value)))
                continue;
            await onRow(sheetName, recordFromArray(headers, values, rowIndex + 1));
        }
    }
};
const preflightWorkbook = async (workbookPath, logger) => {
    const data = {
        sheetRows: {},
        skuCounts: new Map(),
        isbnCounts: new Map(),
        baseSlugCounts: new Map(),
        productIds: new Set(),
        categoryTokens: new Set(),
        categoryNames: new Set(),
        authorValues: new Set(),
        productAuthorValues: new Set(),
        publisherValues: new Set(),
        productPublisherValues: new Set(),
        attributeProductIds: new Set(),
        productTextProductIds: new Set(),
        missingFields: {},
        invalidPriceRows: [],
        invalidStockRows: [],
        discountAbovePriceRows: [],
        imageUrlCount: 0,
        productsWithImages: 0,
        productTextRows: 0,
        cataloguePdfUrls: new Set(),
        nonContiguousAttributeGroups: 0,
        nonContiguousTextGroups: 0,
    };
    let lastAttributeProductId = '';
    let lastTextProductId = '';
    const completedAttributeProducts = new Set();
    const completedTextProducts = new Set();
    await streamWorkbook(workbookPath, (sheetName, row) => {
        data.sheetRows[sheetName] = (data.sheetRows[sheetName] ?? 0) + 1;
        if (sheetName === 'Products') {
            const productId = (0, catalog_utils_1.cleanText)(rowValue(row, 'Product ID'));
            const sku = (0, catalog_utils_1.asText)(rowValue(row, 'SKU / ISBN Code')).trim();
            const isbn = (0, catalog_utils_1.asText)(rowValue(row, 'ISBN')).trim();
            const sourceSlug = rowValue(row, 'SEO Slug');
            const title = rowValue(row, 'Book Name');
            const baseSlug = (0, catalog_utils_1.slugify)(sourceSlug) || (0, catalog_utils_1.slugify)(title) || 'book';
            const price = (0, catalog_utils_1.parseMoney)(rowValue(row, 'Price'));
            const stock = (0, catalog_utils_1.parseNonNegativeInteger)(rowValue(row, 'Stock Quantity'));
            const discount = (0, catalog_utils_1.parseMoney)(rowValue(row, 'Discount Price'));
            const imageUrls = (0, catalog_utils_1.imageUrlsFromCell)(rowValue(row, 'Product Image URLs'));
            data.productIds.add(productId);
            increment(data.skuCounts, sku);
            increment(data.isbnCounts, isbn);
            increment(data.baseSlugCounts, baseSlug);
            if (price.warning)
                data.invalidPriceRows.push(rowNumber(row));
            if (stock.warning)
                data.invalidStockRows.push(rowNumber(row));
            if (!discount.warning && !price.warning && discount.value > price.value) {
                data.discountAbovePriceRows.push(rowNumber(row));
            }
            data.imageUrlCount += imageUrls.length;
            if (imageUrls.length > 0)
                data.productsWithImages += 1;
            for (const column of [
                'Product ID',
                'Book Name',
                'ISBN',
                'SKU / ISBN Code',
                'Categories (Normalized)',
                'SEO Slug',
                'Price',
                'Discount Price',
                'Stock Quantity',
                'Status',
                'Product Image URLs',
                'Author',
                'Publisher',
                'Description (Plain Text Preview)',
            ]) {
                if (!(0, catalog_utils_1.cleanText)(rowValue(row, column)))
                    countRecord(data.missingFields, column);
            }
            for (const categoryName of rowValue(row, 'Categories (Normalized)').split('|')) {
                if ((0, catalog_utils_1.cleanText)(categoryName))
                    data.categoryTokens.add((0, catalog_utils_1.lookupKey)(categoryName));
            }
            if ((0, catalog_utils_1.asText)(rowValue(row, 'Author')))
                data.productAuthorValues.add((0, catalog_utils_1.asText)(rowValue(row, 'Author')));
            if ((0, catalog_utils_1.asText)(rowValue(row, 'Publisher'))) {
                data.productPublisherValues.add((0, catalog_utils_1.asText)(rowValue(row, 'Publisher')));
            }
        }
        else if (sheetName === 'Categories') {
            data.categoryNames.add((0, catalog_utils_1.lookupKey)(rowValue(row, 'Category Name')));
        }
        else if (sheetName === 'Authors') {
            data.authorValues.add((0, catalog_utils_1.asText)(rowValue(row, 'Author Value (as stored)')));
        }
        else if (sheetName === 'Publishers') {
            data.publisherValues.add((0, catalog_utils_1.asText)(rowValue(row, 'Publisher Value (as stored)')));
        }
        else if (sheetName === 'Product Text') {
            const productId = (0, catalog_utils_1.cleanText)(rowValue(row, 'Product ID'));
            data.productTextRows += 1;
            data.productTextProductIds.add(productId);
            if (lastTextProductId && lastTextProductId !== productId)
                completedTextProducts.add(lastTextProductId);
            if (completedTextProducts.has(productId))
                data.nonContiguousTextGroups += 1;
            lastTextProductId = productId;
        }
        else if (sheetName === 'Custom Attributes') {
            const productId = (0, catalog_utils_1.cleanText)(rowValue(row, 'Product ID'));
            data.attributeProductIds.add(productId);
            if (lastAttributeProductId && lastAttributeProductId !== productId) {
                completedAttributeProducts.add(lastAttributeProductId);
            }
            if (completedAttributeProducts.has(productId))
                data.nonContiguousAttributeGroups += 1;
            lastAttributeProductId = productId;
        }
        else if (sheetName === 'Catalogues') {
            const pdfUrl = (0, catalog_utils_1.cleanText)(rowValue(row, 'PDF URL'));
            if (pdfUrl)
                data.cataloguePdfUrls.add(pdfUrl);
        }
    }, (sheetName) => {
        data.sheetRows[sheetName] = data.sheetRows[sheetName] ?? 0;
    });
    const missingSheets = EXPECTED_SHEETS.filter((sheetName) => !(sheetName in data.sheetRows));
    if (missingSheets.length > 0) {
        throw new Error(`Workbook is missing required sheets: ${missingSheets.join(', ')}`);
    }
    for (const [sheetName, expected] of Object.entries(EXPECTED_COUNTS)) {
        const actual = data.sheetRows[sheetName] ?? 0;
        if (actual !== expected) {
            throw new Error(`Sheet ${sheetName} has ${actual} rows; expected ${expected}.`);
        }
    }
    const missingCategoryNames = [...data.categoryTokens].filter((name) => !data.categoryNames.has(name));
    const missingAuthorValues = [...data.productAuthorValues].filter((name) => !data.authorValues.has(name));
    const missingPublisherValues = [...data.productPublisherValues].filter((name) => !data.publisherValues.has(name));
    if (missingCategoryNames.length || missingAuthorValues.length || missingPublisherValues.length) {
        throw new Error(`Workbook relationship validation failed: ${missingCategoryNames.length} categories, ` +
            `${missingAuthorValues.length} authors, and ${missingPublisherValues.length} publishers are absent from their masters.`);
    }
    if (data.nonContiguousAttributeGroups || data.nonContiguousTextGroups) {
        throw new Error('Product Text or Custom Attributes rows are not grouped by Product ID; safe streaming import cannot continue.');
    }
    await logger.event('preflight.completed', {
        sheetRows: data.sheetRows,
        duplicateIsbnGroups: duplicateGroupCount(data.isbnCounts),
        duplicateSkuGroups: duplicateGroupCount(data.skuCounts),
        duplicateSlugGroups: duplicateGroupCount(data.baseSlugCounts),
        missingFields: data.missingFields,
        invalidPriceRows: data.invalidPriceRows,
        invalidStockRows: data.invalidStockRows,
        discountAbovePriceRows: data.discountAbovePriceRows,
        imageUrlCount: data.imageUrlCount,
        productsWithImages: data.productsWithImages,
    });
    return data;
};
const collectMasterRows = async (workbookPath) => {
    const masters = {
        categories: [],
        authors: [],
        publishers: [],
        homepageCategories: [],
        currencies: [],
        displayLimits: [],
        catalogues: [],
    };
    await streamWorkbook(workbookPath, (sheetName, row) => {
        if (sheetName === 'Categories')
            masters.categories.push(row);
        if (sheetName === 'Authors')
            masters.authors.push(row);
        if (sheetName === 'Publishers')
            masters.publishers.push(row);
        if (sheetName === 'Homepage Categories')
            masters.homepageCategories.push(row);
        if (sheetName === 'Currencies')
            masters.currencies.push(row);
        if (sheetName === 'Display Limits')
            masters.displayLimits.push(row);
        if (sheetName === 'Catalogues')
            masters.catalogues.push(row);
    });
    return masters;
};
const asyncPool = async (items, concurrency, worker) => {
    let cursor = 0;
    const workers = Array.from({ length: Math.max(1, concurrency) }, async () => {
        while (cursor < items.length) {
            const index = cursor;
            cursor += 1;
            await worker(items[index]);
        }
    });
    await Promise.all(workers);
};
const checkpointRows = async (runId, sheetName, rows, status, message) => {
    if (rows.length === 0)
        return;
    await database_1.default.catalogImportItem.createMany({
        data: rows.map((row) => ({
            runId,
            sheetName,
            sourceRow: rowNumber(row),
            externalKey: (0, catalog_utils_1.cleanText)(rowValue(row, 'Product ID')) ||
                (0, catalog_utils_1.cleanText)(rowValue(row, 'Category ID')) ||
                (0, catalog_utils_1.cleanText)(rowValue(row, 'Catalogue ID')) ||
                null,
            status,
            message,
        })),
        skipDuplicates: true,
    });
};
const importMasters = async (workbookPath, runId, summary, logger, concurrency) => {
    const masters = await collectMasterRows(workbookPath);
    await asyncPool(masters.categories, concurrency, async (row) => {
        const sourceCategoryId = (0, catalog_utils_1.cleanText)(rowValue(row, 'Category ID'));
        const name = (0, catalog_utils_1.asText)(rowValue(row, 'Category Name'));
        try {
            await database_1.default.category.upsert({
                where: { sourceCategoryId },
                create: {
                    sourceCategoryId,
                    name,
                    slug: (0, catalog_utils_1.slugify)(name) || `category-${sourceCategoryId}`,
                    isActive: (0, catalog_utils_1.parseEnabled)(rowValue(row, 'Status')),
                    displayOrder: rowNumber(row) - 1,
                },
                update: {
                    name,
                    slug: (0, catalog_utils_1.slugify)(name) || `category-${sourceCategoryId}`,
                    isActive: (0, catalog_utils_1.parseEnabled)(rowValue(row, 'Status')),
                    displayOrder: rowNumber(row) - 1,
                },
            });
            countRecord(summary.imported, 'Categories');
            await checkpointRows(runId, 'Categories', [row], client_1.ImportItemStatus.IMPORTED);
        }
        catch (error) {
            countRecord(summary.failed, 'Categories');
            await logger.error('category.failed', {
                sourceRow: rowNumber(row),
                sourceCategoryId,
                error: error instanceof Error ? error.message : String(error),
            });
            await checkpointRows(runId, 'Categories', [row], client_1.ImportItemStatus.FAILED, error instanceof Error ? error.message : String(error));
        }
    });
    const authorData = masters.authors.map((row) => {
        const name = (0, catalog_utils_1.asText)(rowValue(row, 'Author Value (as stored)'));
        const key = (0, catalog_utils_1.sourceKey)('author', name);
        return {
            name,
            sourceKey: key,
            slug: `${(0, catalog_utils_1.slugify)(name) || 'author'}-${key.slice(0, 10)}`,
        };
    });
    try {
        const result = await database_1.default.author.createMany({ data: authorData, skipDuplicates: true });
        countRecord(summary.imported, 'Authors', result.count);
        countRecord(summary.updated, 'Authors', masters.authors.length - result.count);
        await checkpointRows(runId, 'Authors', masters.authors, client_1.ImportItemStatus.IMPORTED);
    }
    catch (error) {
        await logger.error('authors.batch.failed', {
            error: error instanceof Error ? error.message : String(error),
        });
        await asyncPool(masters.authors, concurrency, async (row) => {
            const name = (0, catalog_utils_1.asText)(rowValue(row, 'Author Value (as stored)'));
            const key = (0, catalog_utils_1.sourceKey)('author', name);
            try {
                await database_1.default.author.upsert({
                    where: { sourceKey: key },
                    create: { name, sourceKey: key, slug: `${(0, catalog_utils_1.slugify)(name) || 'author'}-${key.slice(0, 10)}` },
                    update: { name },
                });
                countRecord(summary.imported, 'Authors');
                await checkpointRows(runId, 'Authors', [row], client_1.ImportItemStatus.IMPORTED);
            }
            catch (rowError) {
                countRecord(summary.failed, 'Authors');
                await logger.error('author.failed', {
                    sourceRow: rowNumber(row),
                    error: rowError instanceof Error ? rowError.message : String(rowError),
                });
            }
        });
    }
    const publisherData = masters.publishers.map((row) => {
        const name = (0, catalog_utils_1.asText)(rowValue(row, 'Publisher Value (as stored)'));
        const key = (0, catalog_utils_1.sourceKey)('publisher', name);
        return {
            name,
            sourceKey: key,
            slug: `${(0, catalog_utils_1.slugify)(name) || 'publisher'}-${key.slice(0, 10)}`,
        };
    });
    try {
        const result = await database_1.default.publisher.createMany({ data: publisherData, skipDuplicates: true });
        countRecord(summary.imported, 'Publishers', result.count);
        countRecord(summary.updated, 'Publishers', masters.publishers.length - result.count);
        await checkpointRows(runId, 'Publishers', masters.publishers, client_1.ImportItemStatus.IMPORTED);
    }
    catch (error) {
        await logger.error('publishers.batch.failed', {
            error: error instanceof Error ? error.message : String(error),
        });
        await asyncPool(masters.publishers, concurrency, async (row) => {
            const name = (0, catalog_utils_1.asText)(rowValue(row, 'Publisher Value (as stored)'));
            const key = (0, catalog_utils_1.sourceKey)('publisher', name);
            try {
                await database_1.default.publisher.upsert({
                    where: { sourceKey: key },
                    create: {
                        name,
                        sourceKey: key,
                        slug: `${(0, catalog_utils_1.slugify)(name) || 'publisher'}-${key.slice(0, 10)}`,
                    },
                    update: { name },
                });
                countRecord(summary.imported, 'Publishers');
                await checkpointRows(runId, 'Publishers', [row], client_1.ImportItemStatus.IMPORTED);
            }
            catch (rowError) {
                countRecord(summary.failed, 'Publishers');
                await logger.error('publisher.failed', {
                    sourceRow: rowNumber(row),
                    error: rowError instanceof Error ? rowError.message : String(rowError),
                });
            }
        });
    }
    for (const row of masters.homepageCategories) {
        const sourceHomepageCategoryId = (0, catalog_utils_1.cleanText)(rowValue(row, 'Category ID'));
        const name = (0, catalog_utils_1.asText)(rowValue(row, 'Category Name'));
        await database_1.default.homepageCategory.upsert({
            where: { sourceHomepageCategoryId },
            create: {
                sourceHomepageCategoryId,
                name,
                slug: `${(0, catalog_utils_1.slugify)(name) || 'homepage'}-${sourceHomepageCategoryId}`,
                isActive: (0, catalog_utils_1.parseEnabled)(rowValue(row, 'Status')),
                adminSourceUrl: (0, catalog_utils_1.cleanText)(rowValue(row, 'Admin Source URL')) || null,
            },
            update: {
                name,
                isActive: (0, catalog_utils_1.parseEnabled)(rowValue(row, 'Status')),
                adminSourceUrl: (0, catalog_utils_1.cleanText)(rowValue(row, 'Admin Source URL')) || null,
            },
        });
    }
    await checkpointRows(runId, 'Homepage Categories', masters.homepageCategories, client_1.ImportItemStatus.IMPORTED);
    countRecord(summary.imported, 'Homepage Categories', masters.homepageCategories.length);
    for (const row of masters.currencies) {
        const sourceCurrencyId = (0, catalog_utils_1.cleanText)(rowValue(row, 'Currency ID'));
        const exchangeRate = (0, catalog_utils_1.parseMoney)(rowValue(row, 'Currency Value'), 1);
        await database_1.default.storeCurrency.upsert({
            where: { sourceCurrencyId },
            create: {
                sourceCurrencyId,
                name: (0, catalog_utils_1.asText)(rowValue(row, 'Name')),
                currency: (0, catalog_utils_1.asText)(rowValue(row, 'Currency')),
                exchangeRate: exchangeRate.value,
                isActive: (0, catalog_utils_1.parseEnabled)(rowValue(row, 'Status')),
                adminSourceUrl: (0, catalog_utils_1.cleanText)(rowValue(row, 'Admin Source URL')) || null,
            },
            update: {
                name: (0, catalog_utils_1.asText)(rowValue(row, 'Name')),
                currency: (0, catalog_utils_1.asText)(rowValue(row, 'Currency')),
                exchangeRate: exchangeRate.value,
                isActive: (0, catalog_utils_1.parseEnabled)(rowValue(row, 'Status')),
                adminSourceUrl: (0, catalog_utils_1.cleanText)(rowValue(row, 'Admin Source URL')) || null,
            },
        });
    }
    await checkpointRows(runId, 'Currencies', masters.currencies, client_1.ImportItemStatus.IMPORTED);
    countRecord(summary.imported, 'Currencies', masters.currencies.length);
    for (const row of masters.displayLimits) {
        const sourceDisplayLimitId = (0, catalog_utils_1.cleanText)(rowValue(row, 'Limit ID'));
        const productsPerPage = (0, catalog_utils_1.parseNonNegativeInteger)(rowValue(row, 'Products Per Page'), 20).value;
        await database_1.default.displayLimit.upsert({
            where: { sourceDisplayLimitId },
            create: {
                sourceDisplayLimitId,
                productsPerPage,
                isActive: (0, catalog_utils_1.parseEnabled)(rowValue(row, 'Status')),
                adminSourceUrl: (0, catalog_utils_1.cleanText)(rowValue(row, 'Admin Source URL')) || null,
            },
            update: {
                productsPerPage,
                isActive: (0, catalog_utils_1.parseEnabled)(rowValue(row, 'Status')),
                adminSourceUrl: (0, catalog_utils_1.cleanText)(rowValue(row, 'Admin Source URL')) || null,
            },
        });
    }
    await checkpointRows(runId, 'Display Limits', masters.displayLimits, client_1.ImportItemStatus.IMPORTED);
    countRecord(summary.imported, 'Display Limits', masters.displayLimits.length);
    await asyncPool(masters.catalogues, concurrency, async (row) => {
        const sourceCatalogueId = (0, catalog_utils_1.cleanText)(rowValue(row, 'Catalogue ID'));
        const title = (0, catalog_utils_1.asText)(rowValue(row, 'Title'));
        const sourcePdfUrl = (0, catalog_utils_1.cleanText)(rowValue(row, 'PDF URL'));
        try {
            const existing = await database_1.default.catalogue.findUnique({ where: { sourceCatalogueId } });
            await database_1.default.catalogue.upsert({
                where: { sourceCatalogueId },
                create: {
                    sourceCatalogueId,
                    title,
                    slug: `${(0, catalog_utils_1.slugify)(title) || 'catalogue'}-${sourceCatalogueId}`,
                    pdfUrl: sourcePdfUrl,
                    sourcePdfUrl,
                    adminSourceUrl: (0, catalog_utils_1.cleanText)(rowValue(row, 'Admin Source URL')) || null,
                    isActive: (0, catalog_utils_1.parseEnabled)(rowValue(row, 'Status')),
                    assetStatus: client_1.AssetStatus.LINKED,
                },
                update: {
                    title,
                    sourcePdfUrl,
                    pdfUrl: existing?.assetStatus === client_1.AssetStatus.DOWNLOADED && existing.sourcePdfUrl === sourcePdfUrl
                        ? existing.pdfUrl
                        : sourcePdfUrl,
                    adminSourceUrl: (0, catalog_utils_1.cleanText)(rowValue(row, 'Admin Source URL')) || null,
                    isActive: (0, catalog_utils_1.parseEnabled)(rowValue(row, 'Status')),
                    assetStatus: existing?.assetStatus === client_1.AssetStatus.DOWNLOADED && existing.sourcePdfUrl === sourcePdfUrl
                        ? client_1.AssetStatus.DOWNLOADED
                        : client_1.AssetStatus.LINKED,
                },
            });
            countRecord(summary.imported, 'Catalogues');
            await checkpointRows(runId, 'Catalogues', [row], client_1.ImportItemStatus.IMPORTED);
        }
        catch (error) {
            countRecord(summary.failed, 'Catalogues');
            await logger.error('catalogue.failed', {
                sourceRow: rowNumber(row),
                sourceCatalogueId,
                error: error instanceof Error ? error.message : String(error),
            });
        }
    });
    await logger.event('masters.completed', {
        categories: masters.categories.length,
        authors: masters.authors.length,
        publishers: masters.publishers.length,
        catalogues: masters.catalogues.length,
    });
};
const loadMasterMaps = async () => {
    const [categories, authors, publishers] = await Promise.all([
        database_1.default.category.findMany({
            where: { sourceCategoryId: { not: null } },
            select: { id: true, name: true },
        }),
        database_1.default.author.findMany({
            where: { sourceKey: { not: null } },
            select: { id: true, sourceKey: true },
        }),
        database_1.default.publisher.findMany({
            where: { sourceKey: { not: null } },
            select: { id: true, sourceKey: true },
        }),
    ]);
    return {
        categoryByName: new Map(categories.map((category) => [(0, catalog_utils_1.lookupKey)(category.name), category.id])),
        authorBySourceKey: new Map(authors.filter((author) => author.sourceKey).map((author) => [author.sourceKey, author.id])),
        publisherBySourceKey: new Map(publishers
            .filter((publisher) => publisher.sourceKey)
            .map((publisher) => [publisher.sourceKey, publisher.id])),
    };
};
const processProduct = async (item, runId, masters, summary, logger) => {
    const { row, slug, sku } = item;
    const sourceRow = rowNumber(row);
    const sourceProductId = (0, catalog_utils_1.cleanText)(rowValue(row, 'Product ID'));
    const title = (0, catalog_utils_1.asText)(rowValue(row, 'Book Name'));
    if (!sourceProductId || !title) {
        const message = 'Product ID and Book Name are required.';
        countRecord(summary.failed, 'Products');
        await logger.error('product.failed', { sourceRow, sourceProductId, message });
        await database_1.default.catalogImportItem.upsert({
            where: { runId_sheetName_sourceRow: { runId, sheetName: 'Products', sourceRow } },
            create: {
                runId,
                sheetName: 'Products',
                sourceRow,
                externalKey: sourceProductId || null,
                status: client_1.ImportItemStatus.FAILED,
                message,
            },
            update: { status: client_1.ImportItemStatus.FAILED, message, processedAt: new Date() },
        });
        return;
    }
    const priceResult = (0, catalog_utils_1.parseMoney)(rowValue(row, 'Price'));
    const discountRaw = (0, catalog_utils_1.cleanText)(rowValue(row, 'Discount Price'));
    const discountResult = discountRaw ? (0, catalog_utils_1.parseMoney)(discountRaw) : null;
    const stockResult = (0, catalog_utils_1.parseNonNegativeInteger)(rowValue(row, 'Stock Quantity'));
    const gstResult = (0, catalog_utils_1.parsePercent)(rowValue(row, 'GST'));
    const warnings = [
        priceResult.warning,
        discountResult?.warning,
        stockResult.warning,
        gstResult.warning,
    ].filter(Boolean);
    const authorRaw = (0, catalog_utils_1.asText)(rowValue(row, 'Author'));
    const publisherRaw = (0, catalog_utils_1.asText)(rowValue(row, 'Publisher'));
    const authorId = authorRaw ? masters.authorBySourceKey.get((0, catalog_utils_1.sourceKey)('author', authorRaw)) : undefined;
    const publisherId = publisherRaw
        ? masters.publisherBySourceKey.get((0, catalog_utils_1.sourceKey)('publisher', publisherRaw))
        : undefined;
    const categoryNames = rowValue(row, 'Categories (Normalized)')
        .split('|')
        .map((value) => (0, catalog_utils_1.cleanText)(value))
        .filter(Boolean);
    const categoryIds = [
        ...new Set(categoryNames.map((name) => masters.categoryByName.get((0, catalog_utils_1.lookupKey)(name))).filter(Boolean)),
    ];
    const imageUrls = (0, catalog_utils_1.imageUrlsFromCell)(rowValue(row, 'Product Image URLs'));
    const description = (0, catalog_utils_1.asText)(rowValue(row, 'Description (Plain Text Preview)'));
    const sourceLanguage = (0, catalog_utils_1.asText)(rowValue(row, 'Language'));
    const status = (0, catalog_utils_1.parseEnabled)(rowValue(row, 'Status')) ? client_1.BookStatus.ENABLED : client_1.BookStatus.DISABLED;
    if (authorRaw && !authorId)
        warnings.push(`Author master record not found for "${authorRaw}".`);
    if (publisherRaw && !publisherId)
        warnings.push(`Publisher master record not found for "${publisherRaw}".`);
    if (categoryIds.length !== categoryNames.length) {
        warnings.push('One or more category relationships could not be resolved.');
    }
    try {
        const result = await database_1.default.$transaction(async (tx) => {
            const existing = await tx.book.findUnique({
                where: { sourceProductId },
                include: { images: { orderBy: { sortOrder: 'asc' } } },
            });
            const existingImageSources = existing?.images.map((image) => image.sourceUrl ?? image.url) ?? [];
            const imagesUnchanged = existingImageSources.length === imageUrls.length &&
                existingImageSources.every((sourceUrl, index) => sourceUrl === imageUrls[index]);
            const bookData = {
                sourceProductId,
                adminSerial: (0, catalog_utils_1.parseOptionalInteger)(rowValue(row, 'Admin Serial')),
                title,
                slug,
                sourceSlug: (0, catalog_utils_1.asText)(rowValue(row, 'SEO Slug')) || null,
                isbn: (0, catalog_utils_1.asText)(rowValue(row, 'ISBN')),
                sku,
                sourceSku: (0, catalog_utils_1.asText)(rowValue(row, 'SKU / ISBN Code')),
                barcode: (0, catalog_utils_1.asText)(rowValue(row, 'Barcode')) || null,
                description,
                additionalAttributesText: (0, catalog_utils_1.asText)(rowValue(row, 'Additional Attributes (Plain Text)')) || null,
                price: priceResult.value,
                discountPrice: discountResult?.warning ? null : discountResult?.value ?? null,
                gstRate: gstResult.value,
                stock: stockResult.value,
                format: (0, catalog_utils_1.normalizeFormat)(rowValue(row, 'Binding')),
                binding: (0, catalog_utils_1.asText)(rowValue(row, 'Binding')) || null,
                language: (0, catalog_utils_1.cleanText)(sourceLanguage) || 'UNKNOWN',
                sourceLanguage: sourceLanguage || null,
                edition: (0, catalog_utils_1.asText)(rowValue(row, 'Edition')) || null,
                classGrade: (0, catalog_utils_1.asText)(rowValue(row, 'Class / Grade')) || null,
                publicationYear: (0, catalog_utils_1.cleanText)(rowValue(row, 'Year')).slice(0, 64) || null,
                pageCount: (0, catalog_utils_1.parseOptionalInteger)(rowValue(row, 'Pages')),
                pagesText: (0, catalog_utils_1.asText)(rowValue(row, 'Pages')) || null,
                publisherId: publisherId ?? null,
                coverImage: imageUrls[0] ?? null,
                tagsRaw: (0, catalog_utils_1.asText)(rowValue(row, 'Tags (Raw)')) || null,
                sourceCategoryIds: (0, catalog_utils_1.asText)(rowValue(row, 'Category IDs')) || null,
                homepageCategoriesRaw: (0, catalog_utils_1.asText)(rowValue(row, 'Homepage Categories')) || null,
                homepageCategoryIdsRaw: (0, catalog_utils_1.asText)(rowValue(row, 'Homepage Category IDs')) || null,
                sourceInStock: (0, catalog_utils_1.asText)(rowValue(row, 'In Stock')) || null,
                sourceNewRelease: (0, catalog_utils_1.asText)(rowValue(row, 'New Release')) || null,
                publicProductUrl: (0, catalog_utils_1.asText)(rowValue(row, 'Public Product Page')) || null,
                adminEditUrl: (0, catalog_utils_1.asText)(rowValue(row, 'Admin Edit URL')) || null,
                adminImagesUrl: (0, catalog_utils_1.asText)(rowValue(row, 'Admin Images URL')) || null,
                detailSource: (0, catalog_utils_1.asText)(rowValue(row, 'Detail Source')) || null,
                status,
                isActive: status === client_1.BookStatus.ENABLED,
                displayOrder: (0, catalog_utils_1.parseOptionalInteger)(rowValue(row, 'Admin Serial')) ?? Math.max(0, sourceRow - 2),
                isNewRelease: (0, catalog_utils_1.parseAffirmative)(rowValue(row, 'New Release')),
                isFeatured: Boolean((0, catalog_utils_1.cleanText)(rowValue(row, 'Homepage Categories'))),
            };
            let bookId;
            if (existing) {
                const { coverImage: _coverImage, ...updateDataWithoutCover } = bookData;
                const updated = await tx.book.update({
                    where: { id: existing.id },
                    data: imagesUnchanged ? updateDataWithoutCover : bookData,
                });
                bookId = updated.id;
            }
            else {
                const created = await tx.book.create({ data: bookData });
                bookId = created.id;
            }
            await tx.bookInventory.upsert({
                where: { bookId },
                create: { bookId, stock: stockResult.value, reserved: 0 },
                update: { stock: stockResult.value },
            });
            await tx.bookAuthor.deleteMany({ where: { bookId } });
            if (authorId)
                await tx.bookAuthor.create({ data: { bookId, authorId } });
            await tx.bookCategory.deleteMany({ where: { bookId } });
            if (categoryIds.length > 0) {
                await tx.bookCategory.createMany({
                    data: categoryIds.map((categoryId) => ({ bookId, categoryId })),
                    skipDuplicates: true,
                });
            }
            if (!imagesUnchanged) {
                await tx.bookImage.deleteMany({ where: { bookId } });
                if (imageUrls.length > 0) {
                    await tx.bookImage.createMany({
                        data: imageUrls.map((url, index) => ({
                            bookId,
                            url,
                            sourceUrl: url,
                            altText: title,
                            sortOrder: index,
                            isPrimary: index === 0,
                            assetStatus: client_1.AssetStatus.LINKED,
                        })),
                    });
                }
            }
            const itemStatus = existing ? client_1.ImportItemStatus.UPDATED : client_1.ImportItemStatus.IMPORTED;
            await tx.catalogImportItem.upsert({
                where: { runId_sheetName_sourceRow: { runId, sheetName: 'Products', sourceRow } },
                create: {
                    runId,
                    sheetName: 'Products',
                    sourceRow,
                    externalKey: sourceProductId,
                    status: itemStatus,
                    message: warnings.length ? warnings.join(' ') : null,
                },
                update: {
                    externalKey: sourceProductId,
                    status: itemStatus,
                    message: warnings.length ? warnings.join(' ') : null,
                    processedAt: new Date(),
                },
            });
            return { existed: Boolean(existing) };
        }, { timeout: 30000 });
        countRecord(result.existed ? summary.updated : summary.imported, 'Products');
        if (warnings.length) {
            countRecord(summary.warnings, 'Product validation', warnings.length);
            await logger.event('product.warning', { sourceRow, sourceProductId, warnings });
        }
    }
    catch (error) {
        const message = error instanceof Error ? error.message : String(error);
        countRecord(summary.failed, 'Products');
        await logger.error('product.failed', { sourceRow, sourceProductId, message });
        await database_1.default.catalogImportItem.upsert({
            where: { runId_sheetName_sourceRow: { runId, sheetName: 'Products', sourceRow } },
            create: {
                runId,
                sheetName: 'Products',
                sourceRow,
                externalKey: sourceProductId,
                status: client_1.ImportItemStatus.FAILED,
                message,
            },
            update: { status: client_1.ImportItemStatus.FAILED, message, processedAt: new Date() },
        });
    }
};
const importProducts = async (workbookPath, preflight, runId, summary, logger, concurrency, batchSize) => {
    const masters = await loadMasterMaps();
    const existingBooks = await database_1.default.book.findMany({
        select: { sourceProductId: true, slug: true, sku: true },
    });
    const slugOwners = new Map(existingBooks.filter((book) => book.slug).map((book) => [book.slug, book.sourceProductId ?? book.slug]));
    const skuOwners = new Map(existingBooks
        .filter((book) => book.sku)
        .map((book) => [book.sku, book.sourceProductId ?? book.sku]));
    const completedRows = new Set((await database_1.default.catalogImportItem.findMany({
        where: {
            runId,
            sheetName: 'Products',
            status: { in: [client_1.ImportItemStatus.IMPORTED, client_1.ImportItemStatus.UPDATED, client_1.ImportItemStatus.SKIPPED] },
        },
        select: { sourceRow: true },
    })).map((item) => item.sourceRow));
    let batch = [];
    const flushBatch = async () => {
        const current = batch;
        batch = [];
        await asyncPool(current, concurrency, (item) => processProduct(item, runId, masters, summary, logger));
    };
    await streamWorkbook(workbookPath, async (sheetName, row) => {
        if (sheetName !== 'Products')
            return;
        const sourceRow = rowNumber(row);
        if (completedRows.has(sourceRow)) {
            countRecord(summary.skipped, 'Products');
            return;
        }
        const sourceProductId = (0, catalog_utils_1.cleanText)(rowValue(row, 'Product ID'));
        const rawSku = (0, catalog_utils_1.asText)(rowValue(row, 'SKU / ISBN Code')).trim();
        const baseSlug = (0, catalog_utils_1.slugify)(rowValue(row, 'SEO Slug')) || (0, catalog_utils_1.slugify)(rowValue(row, 'Book Name')) || 'book';
        let slug = (0, catalog_utils_1.operationalSlug)(rowValue(row, 'SEO Slug'), rowValue(row, 'Book Name'), sourceProductId, preflight.baseSlugCounts.get(baseSlug) ?? 1);
        if (slugOwners.has(slug) && slugOwners.get(slug) !== sourceProductId) {
            slug = (0, catalog_utils_1.operationalSlug)(slug, rowValue(row, 'Book Name'), sourceProductId, 2);
        }
        while (slugOwners.has(slug) && slugOwners.get(slug) !== sourceProductId) {
            slug = `${slug.slice(0, 177).replace(/-+$/g, '')}-${(0, catalog_utils_1.sourceKey)('slug', `${slug}:${sourceProductId}`).slice(0, 12)}`;
        }
        slugOwners.set(slug, sourceProductId);
        let sku = (0, catalog_utils_1.operationalSku)(rawSku, sourceProductId, preflight.skuCounts.get(rawSku) ?? 1);
        if (skuOwners.has(sku) && skuOwners.get(sku) !== sourceProductId) {
            sku = (0, catalog_utils_1.operationalSku)(rawSku, sourceProductId, 2);
        }
        while (skuOwners.has(sku) && skuOwners.get(sku) !== sourceProductId) {
            const suffix = `::${(0, catalog_utils_1.sourceKey)('sku', `${sku}:${sourceProductId}`).slice(0, 12)}`;
            sku = `${sku.slice(0, Math.max(1, 191 - suffix.length))}${suffix}`;
        }
        skuOwners.set(sku, sourceProductId);
        batch.push({ row, slug, sku });
        if (batch.length >= batchSize)
            await flushBatch();
    });
    await flushBatch();
    await logger.event('products.completed', {
        imported: summary.imported.Products ?? 0,
        updated: summary.updated.Products ?? 0,
        skipped: summary.skipped.Products ?? 0,
        failed: summary.failed.Products ?? 0,
    });
};
const streamGroupedSheet = async (workbookPath, targetSheet, onGroup) => {
    let currentProductId = '';
    let currentRows = [];
    const flush = async () => {
        if (!currentProductId || currentRows.length === 0)
            return;
        const rows = currentRows;
        const productId = currentProductId;
        currentRows = [];
        await onGroup(productId, rows);
    };
    await streamWorkbook(workbookPath, async (sheetName, row) => {
        if (sheetName !== targetSheet)
            return;
        const productId = (0, catalog_utils_1.cleanText)(rowValue(row, 'Product ID'));
        if (currentProductId && productId !== currentProductId)
            await flush();
        currentProductId = productId;
        currentRows.push(row);
    });
    await flush();
};
const safeCatalogHtml = (html) => (0, sanitize_html_1.default)(html, {
    allowedTags: [
        'p',
        'br',
        'strong',
        'b',
        'em',
        'i',
        'u',
        'ul',
        'ol',
        'li',
        'table',
        'thead',
        'tbody',
        'tfoot',
        'tr',
        'th',
        'td',
        'blockquote',
        'h1',
        'h2',
        'h3',
        'h4',
        'h5',
        'h6',
        'span',
        'div',
        'a',
    ],
    allowedAttributes: {
        a: ['href', 'title', 'target', 'rel'],
        table: ['border', 'cellpadding', 'cellspacing'],
        td: ['colspan', 'rowspan'],
        th: ['colspan', 'rowspan'],
        '*': ['style'],
    },
    allowedStyles: {
        '*': {
            'text-align': [/^left$/, /^right$/, /^center$/, /^justify$/],
            'font-weight': [/^bold$/, /^\d{3}$/],
            'font-style': [/^italic$/],
            'text-decoration': [/^underline$/],
        },
    },
    allowedSchemes: ['http', 'https', 'mailto'],
    transformTags: {
        a: sanitize_html_1.default.simpleTransform('a', { rel: 'noopener noreferrer' }, true),
    },
});
const plainTextFromHtml = (html) => (0, catalog_utils_1.cleanText)((0, sanitize_html_1.default)(html, { allowedTags: [], allowedAttributes: {} }));
const importProductText = async (workbookPath, preflight, runId, summary, logger) => {
    const books = await database_1.default.book.findMany({
        where: { sourceProductId: { not: null } },
        select: { id: true, sourceProductId: true },
    });
    const bookIds = new Map(books.filter((book) => book.sourceProductId).map((book) => [book.sourceProductId, book.id]));
    await streamGroupedSheet(workbookPath, 'Product Text', async (productId, rows) => {
        const bookId = bookIds.get(productId);
        if (!bookId) {
            countRecord(summary.failed, 'Product Text', rows.length);
            await logger.error('product_text.orphan', {
                productId,
                sourceRows: rows.map(rowNumber),
            });
            return;
        }
        const byField = new Map();
        for (const row of rows) {
            const field = (0, catalog_utils_1.cleanText)(rowValue(row, 'Field'));
            if (!byField.has(field))
                byField.set(field, []);
            byField.get(field)?.push(row);
        }
        const descriptionHtmlOriginal = (byField.get('Description HTML') ?? [])
            .sort((a, b) => Number(rowValue(a, 'Part Number')) - Number(rowValue(b, 'Part Number')))
            .map((row) => (0, catalog_utils_1.asText)(rowValue(row, 'Original Content')))
            .join('');
        const attributesHtmlOriginal = (byField.get('Additional Attributes HTML') ?? [])
            .sort((a, b) => Number(rowValue(a, 'Part Number')) - Number(rowValue(b, 'Part Number')))
            .map((row) => (0, catalog_utils_1.asText)(rowValue(row, 'Original Content')))
            .join('');
        try {
            await database_1.default.$transaction(async (tx) => {
                await tx.bookText.deleteMany({ where: { bookId } });
                await tx.bookText.createMany({
                    data: rows.map((row) => ({
                        bookId,
                        field: (0, catalog_utils_1.cleanText)(rowValue(row, 'Field')),
                        partNumber: (0, catalog_utils_1.parseNonNegativeInteger)(rowValue(row, 'Part Number'), 1).value,
                        originalContent: (0, catalog_utils_1.asText)(rowValue(row, 'Original Content')),
                        sourceRow: rowNumber(row),
                    })),
                });
                const book = await tx.book.findUnique({ where: { id: bookId }, select: { description: true } });
                const derivedDescription = descriptionHtmlOriginal
                    ? plainTextFromHtml(descriptionHtmlOriginal)
                    : '';
                await tx.book.update({
                    where: { id: bookId },
                    data: {
                        descriptionHtml: descriptionHtmlOriginal ? safeCatalogHtml(descriptionHtmlOriginal) : null,
                        additionalAttributesHtml: attributesHtmlOriginal
                            ? safeCatalogHtml(attributesHtmlOriginal)
                            : null,
                        description: book?.description || derivedDescription,
                    },
                });
                await tx.catalogImportItem.deleteMany({
                    where: {
                        runId,
                        sheetName: 'Product Text',
                        sourceRow: { in: rows.map(rowNumber) },
                    },
                });
                await tx.catalogImportItem.createMany({
                    data: rows.map((row) => ({
                        runId,
                        sheetName: 'Product Text',
                        sourceRow: rowNumber(row),
                        externalKey: productId,
                        status: client_1.ImportItemStatus.IMPORTED,
                    })),
                });
            }, { timeout: 30000 });
            countRecord(summary.imported, 'Product Text', rows.length);
        }
        catch (error) {
            countRecord(summary.failed, 'Product Text', rows.length);
            await logger.error('product_text.failed', {
                productId,
                sourceRows: rows.map(rowNumber),
                error: error instanceof Error ? error.message : String(error),
            });
        }
    });
    const booksWithoutText = books.filter((book) => book.sourceProductId && !preflight.productTextProductIds.has(book.sourceProductId));
    await asyncPool(booksWithoutText, 4, async (book) => {
        await database_1.default.$transaction([
            database_1.default.bookText.deleteMany({ where: { bookId: book.id } }),
            database_1.default.book.update({
                where: { id: book.id },
                data: { descriptionHtml: null, additionalAttributesHtml: null },
            }),
        ]);
    });
    await logger.event('product_text.completed', {
        imported: summary.imported['Product Text'] ?? 0,
        failed: summary.failed['Product Text'] ?? 0,
    });
};
const importCustomAttributes = async (workbookPath, preflight, runId, summary, logger) => {
    const books = await database_1.default.book.findMany({
        where: { sourceProductId: { not: null } },
        select: { id: true, sourceProductId: true },
    });
    const bookIds = new Map(books.filter((book) => book.sourceProductId).map((book) => [book.sourceProductId, book.id]));
    await streamGroupedSheet(workbookPath, 'Custom Attributes', async (productId, rows) => {
        const bookId = bookIds.get(productId);
        if (!bookId) {
            countRecord(summary.failed, 'Custom Attributes', rows.length);
            await logger.error('custom_attributes.orphan', {
                productId,
                sourceRows: rows.map(rowNumber),
            });
            return;
        }
        try {
            await database_1.default.$transaction(async (tx) => {
                await tx.bookAttribute.deleteMany({ where: { bookId } });
                await tx.bookAttribute.createMany({
                    data: rows.map((row, index) => ({
                        bookId,
                        name: (0, catalog_utils_1.asText)(rowValue(row, 'Attribute Name')),
                        value: (0, catalog_utils_1.asText)(rowValue(row, 'Attribute Value')),
                        sourceRow: rowNumber(row),
                        sortOrder: index,
                    })),
                });
                await tx.catalogImportItem.deleteMany({
                    where: {
                        runId,
                        sheetName: 'Custom Attributes',
                        sourceRow: { in: rows.map(rowNumber) },
                    },
                });
                await tx.catalogImportItem.createMany({
                    data: rows.map((row) => ({
                        runId,
                        sheetName: 'Custom Attributes',
                        sourceRow: rowNumber(row),
                        externalKey: productId,
                        status: client_1.ImportItemStatus.IMPORTED,
                    })),
                });
            }, { timeout: 30000 });
            countRecord(summary.imported, 'Custom Attributes', rows.length);
        }
        catch (error) {
            countRecord(summary.failed, 'Custom Attributes', rows.length);
            await logger.error('custom_attributes.failed', {
                productId,
                sourceRows: rows.map(rowNumber),
                error: error instanceof Error ? error.message : String(error),
            });
        }
    });
    const booksWithoutAttributes = books.filter((book) => book.sourceProductId && !preflight.attributeProductIds.has(book.sourceProductId));
    await asyncPool(booksWithoutAttributes, 4, async (book) => {
        await database_1.default.bookAttribute.deleteMany({ where: { bookId: book.id } });
    });
    await logger.event('custom_attributes.completed', {
        imported: summary.imported['Custom Attributes'] ?? 0,
        failed: summary.failed['Custom Attributes'] ?? 0,
    });
};
const extensionForImage = (contentType, url, bytes) => {
    const normalizedType = contentType.toLocaleLowerCase('en');
    if (normalizedType.includes('png') || (bytes[0] === 0x89 && bytes[1] === 0x50))
        return '.png';
    if (normalizedType.includes('webp') || String.fromCharCode(...bytes.slice(0, 4)) === 'RIFF') {
        return '.webp';
    }
    if (normalizedType.includes('gif') || String.fromCharCode(...bytes.slice(0, 3)) === 'GIF')
        return '.gif';
    if (normalizedType.includes('avif'))
        return '.avif';
    if (normalizedType.includes('jpeg') || normalizedType.includes('jpg') || (bytes[0] === 0xff && bytes[1] === 0xd8)) {
        return '.jpg';
    }
    const pathnameExtension = path_1.default.extname(new URL(url).pathname).toLocaleLowerCase('en');
    if (['.jpg', '.jpeg', '.png', '.gif', '.webp', '.avif'].includes(pathnameExtension)) {
        return pathnameExtension === '.jpeg' ? '.jpg' : pathnameExtension;
    }
    return '.img';
};
const downloadAsset = async (url, targetWithoutExtension, kind) => {
    const response = await fetch(url, {
        redirect: 'follow',
        signal: AbortSignal.timeout(45000),
        headers: { 'user-agent': 'IndianBooksWorldwide-CatalogImporter/1.0' },
    });
    if (!response.ok)
        throw new Error(`HTTP ${response.status} ${response.statusText}`);
    const contentType = response.headers.get('content-type') ?? '';
    const bytes = new Uint8Array(await response.arrayBuffer());
    if (bytes.length === 0)
        throw new Error('Downloaded asset is empty.');
    let extension;
    if (kind === 'pdf') {
        const signature = String.fromCharCode(...bytes.slice(0, 5));
        if (signature !== '%PDF-')
            throw new Error(`Downloaded file is not a valid PDF (${contentType}).`);
        extension = '.pdf';
    }
    else {
        extension = extensionForImage(contentType, url, bytes);
        if (extension === '.img' && !contentType.toLocaleLowerCase('en').startsWith('image/')) {
            throw new Error(`Downloaded file is not a recognized image (${contentType}).`);
        }
    }
    const absolutePath = `${targetWithoutExtension}${extension}`;
    await promises_1.default.mkdir(path_1.default.dirname(absolutePath), { recursive: true });
    await promises_1.default.writeFile(absolutePath, bytes);
    return { absolutePath, contentType };
};
const publicAssetUrl = (baseUrl, storageKey) => `${baseUrl.replace(/\/$/, '')}/${storageKey
    .split('/')
    .map((segment) => encodeURIComponent(segment))
    .join('/')}`;
const importAssets = async (options, summary, logger) => {
    const [images, catalogues] = await Promise.all([
        database_1.default.bookImage.findMany({
            where: { book: { sourceProductId: { not: null } } },
            include: { book: { select: { id: true, sourceProductId: true } } },
            orderBy: [{ bookId: 'asc' }, { sortOrder: 'asc' }],
        }),
        database_1.default.catalogue.findMany({ orderBy: { sourceCatalogueId: 'asc' } }),
    ]);
    const placeholderStorageKey = 'books/placeholder.jpg';
    const placeholderPath = path_1.default.join(options.storageDir, ...placeholderStorageKey.split('/'));
    await promises_1.default.access(placeholderPath);
    const placeholderUrl = publicAssetUrl(options.assetPublicBaseUrl, placeholderStorageKey);
    await asyncPool(images, options.concurrency, async (image) => {
        const sourceUrl = image.sourceUrl ?? image.url;
        const sourceProductId = image.book.sourceProductId;
        const existingStoragePath = image.storageKey
            ? path_1.default.join(options.storageDir, ...image.storageKey.split('/'))
            : null;
        if (image.assetStatus === client_1.AssetStatus.DOWNLOADED && existingStoragePath) {
            try {
                await promises_1.default.access(existingStoragePath);
                countRecord(summary.skipped, 'Images Downloaded');
                return;
            }
            catch {
                // Re-download a missing local file.
            }
        }
        try {
            const basePath = path_1.default.join(options.storageDir, 'books', sourceProductId, String(image.sortOrder + 1));
            const downloaded = await downloadAsset(sourceUrl, basePath, 'image');
            const storageKey = path_1.default.relative(options.storageDir, downloaded.absolutePath).split(path_1.default.sep).join('/');
            const url = publicAssetUrl(options.assetPublicBaseUrl, storageKey);
            await database_1.default.$transaction([
                database_1.default.bookImage.update({
                    where: { id: image.id },
                    data: { url, storageKey, assetStatus: client_1.AssetStatus.DOWNLOADED },
                }),
                ...(image.isPrimary
                    ? [database_1.default.book.update({ where: { id: image.book.id }, data: { coverImage: url } })]
                    : []),
            ]);
            countRecord(summary.imported, 'Images Downloaded');
        }
        catch (error) {
            countRecord(summary.failed, 'Images Downloaded');
            await database_1.default.$transaction([
                database_1.default.bookImage.update({
                    where: { id: image.id },
                    data: {
                        url: placeholderUrl,
                        storageKey: placeholderStorageKey,
                        assetStatus: client_1.AssetStatus.MISSING,
                    },
                }),
                ...(image.isPrimary
                    ? [database_1.default.book.update({ where: { id: image.book.id }, data: { coverImage: placeholderUrl } })]
                    : []),
            ]);
            await logger.error('image.download_failed', {
                sourceProductId,
                sourceUrl,
                error: error instanceof Error ? error.message : String(error),
            });
        }
    });
    await asyncPool(catalogues, options.concurrency, async (catalogue) => {
        const existingStoragePath = catalogue.storageKey
            ? path_1.default.join(options.storageDir, ...catalogue.storageKey.split('/'))
            : null;
        if (catalogue.assetStatus === client_1.AssetStatus.DOWNLOADED && existingStoragePath) {
            try {
                await promises_1.default.access(existingStoragePath);
                countRecord(summary.skipped, 'PDFs Downloaded');
                return;
            }
            catch {
                // Re-download a missing local file.
            }
        }
        try {
            const basePath = path_1.default.join(options.storageDir, 'catalogues', `${catalogue.sourceCatalogueId}-${(0, catalog_utils_1.slugify)(catalogue.title) || 'catalogue'}`);
            const downloaded = await downloadAsset(catalogue.sourcePdfUrl, basePath, 'pdf');
            const storageKey = path_1.default.relative(options.storageDir, downloaded.absolutePath).split(path_1.default.sep).join('/');
            const pdfUrl = publicAssetUrl(options.assetPublicBaseUrl, storageKey);
            await database_1.default.catalogue.update({
                where: { id: catalogue.id },
                data: { pdfUrl, storageKey, assetStatus: client_1.AssetStatus.DOWNLOADED },
            });
            countRecord(summary.imported, 'PDFs Downloaded');
        }
        catch (error) {
            countRecord(summary.failed, 'PDFs Downloaded');
            await database_1.default.catalogue.update({
                where: { id: catalogue.id },
                data: { assetStatus: client_1.AssetStatus.FAILED },
            });
            await logger.error('catalogue_pdf.download_failed', {
                sourceCatalogueId: catalogue.sourceCatalogueId,
                sourceUrl: catalogue.sourcePdfUrl,
                error: error instanceof Error ? error.message : String(error),
            });
        }
    });
    await logger.event('assets.download.completed', {
        imagesDownloaded: summary.imported['Images Downloaded'] ?? 0,
        imagesFailed: summary.failed['Images Downloaded'] ?? 0,
        pdfsDownloaded: summary.imported['PDFs Downloaded'] ?? 0,
        pdfsFailed: summary.failed['PDFs Downloaded'] ?? 0,
    });
};
const applyCatalogueMap = async (catalogueMapPath, summary, logger) => {
    if (!catalogueMapPath) {
        countRecord(summary.missing, 'Explicit product-to-catalogue mapping', 1);
        await logger.event('catalogue_map.skipped', {
            reason: 'The workbook has no product-to-catalogue or category-to-catalogue mapping. No semantic links were inferred.',
        });
        return;
    }
    const raw = await promises_1.default.readFile(path_1.default.resolve(catalogueMapPath), 'utf8');
    const mapping = JSON.parse(raw);
    const [catalogues, categories] = await Promise.all([
        database_1.default.catalogue.findMany({ select: { id: true, sourceCatalogueId: true } }),
        database_1.default.category.findMany({ select: { id: true, name: true } }),
    ]);
    const catalogueBySourceId = new Map(catalogues.map((catalogue) => [catalogue.sourceCatalogueId, catalogue.id]));
    const categoryByName = new Map(categories.map((category) => [(0, catalog_utils_1.lookupKey)(category.name), category.id]));
    for (const [sourceCatalogueId, categoryNames] of Object.entries(mapping)) {
        const catalogueId = catalogueBySourceId.get(sourceCatalogueId);
        if (!catalogueId)
            throw new Error(`Catalogue map references unknown Catalogue ID ${sourceCatalogueId}.`);
        const categoryIds = categoryNames.map((name) => {
            const categoryId = categoryByName.get((0, catalog_utils_1.lookupKey)(name));
            if (!categoryId)
                throw new Error(`Catalogue map references unknown category "${name}".`);
            return categoryId;
        });
        await database_1.default.$transaction(async (tx) => {
            await tx.categoryCatalogue.deleteMany({ where: { catalogueId } });
            await tx.bookCatalogue.deleteMany({ where: { catalogueId } });
            if (categoryIds.length === 0)
                return;
            await tx.categoryCatalogue.createMany({
                data: categoryIds.map((categoryId) => ({ categoryId, catalogueId })),
                skipDuplicates: true,
            });
            const bookCategories = await tx.bookCategory.findMany({
                where: { categoryId: { in: categoryIds } },
                select: { bookId: true },
                distinct: ['bookId'],
            });
            await tx.bookCatalogue.createMany({
                data: bookCategories.map(({ bookId }) => ({ bookId, catalogueId })),
                skipDuplicates: true,
            });
        });
    }
    const productLinks = await database_1.default.bookCatalogue.count();
    countRecord(summary.imported, 'Product Catalogue Links', productLinks);
    await logger.event('catalogue_map.completed', {
        mappedCatalogues: Object.keys(mapping).length,
        productLinks,
    });
};
const verifyImport = async (preflight) => {
    const [productCount, categoryCount, authorCount, publisherCount, attributeCount, productTextCount, catalogueCount, imageCount, productsWithoutCategories, productCatalogueLinks, downloadedImages, downloadedPdfs, books,] = await Promise.all([
        database_1.default.book.count({ where: { sourceProductId: { not: null } } }),
        database_1.default.category.count({ where: { sourceCategoryId: { not: null } } }),
        database_1.default.author.count({ where: { sourceKey: { not: null } } }),
        database_1.default.publisher.count({ where: { sourceKey: { not: null } } }),
        database_1.default.bookAttribute.count({ where: { book: { sourceProductId: { not: null } } } }),
        database_1.default.bookText.count({ where: { book: { sourceProductId: { not: null } } } }),
        database_1.default.catalogue.count(),
        database_1.default.bookImage.count({ where: { book: { sourceProductId: { not: null } } } }),
        database_1.default.book.count({
            where: { sourceProductId: { not: null }, bookCategories: { none: {} } },
        }),
        database_1.default.bookCatalogue.count(),
        database_1.default.bookImage.count({ where: { assetStatus: client_1.AssetStatus.DOWNLOADED } }),
        database_1.default.catalogue.count({ where: { assetStatus: client_1.AssetStatus.DOWNLOADED } }),
        database_1.default.book.findMany({
            where: { sourceProductId: { not: null } },
            select: { sourceProductId: true, sku: true },
        }),
    ]);
    const skuCounts = new Map();
    for (const book of books)
        increment(skuCounts, book.sku ?? '');
    const duplicateOperationalSkuGroups = duplicateGroupCount(skuCounts);
    return {
        products: { actual: productCount, expected: EXPECTED_COUNTS.Products, passed: productCount === 3083 },
        categories: { actual: categoryCount, expected: EXPECTED_COUNTS.Categories, passed: categoryCount === 47 },
        authors: { actual: authorCount, expected: EXPECTED_COUNTS.Authors, passed: authorCount === 1072 },
        publishers: { actual: publisherCount, expected: EXPECTED_COUNTS.Publishers, passed: publisherCount === 300 },
        customAttributes: {
            actual: attributeCount,
            expected: EXPECTED_COUNTS['Custom Attributes'],
            passed: attributeCount === 23166,
        },
        productText: {
            actual: productTextCount,
            expected: preflight.productTextRows,
            passed: productTextCount === preflight.productTextRows,
        },
        catalogues: { actual: catalogueCount, expected: EXPECTED_COUNTS.Catalogues, passed: catalogueCount === 43 },
        images: { actual: imageCount, expected: preflight.imageUrlCount, passed: imageCount === preflight.imageUrlCount },
        productsWithoutCategories,
        duplicateOperationalSkuGroups,
        productCatalogueLinks,
        downloadedImages,
        downloadedPdfs,
        sourceBlankAuthors: preflight.missingFields.Author ?? 0,
        sourceBlankPublishers: preflight.missingFields.Publisher ?? 0,
        sourceMissingImages: preflight.sheetRows.Products - preflight.productsWithImages,
    };
};
const totalCount = (record) => Object.values(record).reduce((sum, value) => sum + value, 0);
const runCatalogImport = async (inputOptions) => {
    const startedAtMs = Date.now();
    const workbookPath = path_1.default.resolve(inputOptions.workbookPath);
    await promises_1.default.access(workbookPath);
    const sourceFingerprint = await workbookFingerprint(workbookPath);
    const logsDir = path_1.default.resolve(inputOptions.logsDir ?? path_1.default.join(process.cwd(), 'logs', 'catalog-import'));
    const storageDir = path_1.default.resolve(inputOptions.storageDir ?? path_1.default.join(process.cwd(), 'storage'));
    const logger = new ImportLogger(logsDir, sourceFingerprint);
    await logger.initialize();
    const options = {
        dryRun: inputOptions.dryRun ?? false,
        resume: inputOptions.resume ?? true,
        assetMode: inputOptions.assetMode ?? 'download',
        concurrency: Math.max(1, Math.min(12, inputOptions.concurrency ?? 4)),
        batchSize: Math.max(1, Math.min(500, inputOptions.batchSize ?? 50)),
        storageDir,
        assetPublicBaseUrl: inputOptions.assetPublicBaseUrl ?? process.env.ASSET_PUBLIC_BASE_URL ?? '/assets',
    };
    const summary = {
        sourceWorkbook: workbookPath,
        sourceFingerprint,
        dryRun: options.dryRun,
        startedAt: new Date(startedAtMs).toISOString(),
        sheetRows: {},
        imported: {},
        updated: {},
        failed: {},
        skipped: {},
        warnings: {},
        duplicates: {},
        missing: {},
        logPath: logger.logPath,
        errorLogPath: logger.errorLogPath,
    };
    let runId;
    try {
        await logger.event('import.started', { workbookPath, sourceFingerprint, options });
        const preflight = await preflightWorkbook(workbookPath, logger);
        summary.sheetRows = preflight.sheetRows;
        summary.duplicates = {
            'ISBN groups': duplicateGroupCount(preflight.isbnCounts),
            'Source SKU groups': duplicateGroupCount(preflight.skuCounts),
            'Source slug groups': duplicateGroupCount(preflight.baseSlugCounts),
        };
        summary.missing = { ...preflight.missingFields };
        summary.warnings = {
            'Invalid price rows': preflight.invalidPriceRows.length,
            'Invalid stock rows': preflight.invalidStockRows.length,
            'Discount above price rows': preflight.discountAbovePriceRows.length,
        };
        if (options.dryRun) {
            summary.imported = {
                'Products validated': preflight.sheetRows.Products,
                'Categories validated': preflight.sheetRows.Categories,
                'Authors validated': preflight.sheetRows.Authors,
                'Publishers validated': preflight.sheetRows.Publishers,
                'Custom Attributes validated': preflight.sheetRows['Custom Attributes'],
                'Catalogues validated': preflight.sheetRows.Catalogues,
                'Image URLs validated': preflight.imageUrlCount,
            };
            summary.completedAt = new Date().toISOString();
            summary.executionTimeMs = Date.now() - startedAtMs;
            await logger.event('dry_run.completed', { summary });
            await logger.writeSummary(summary);
            return summary;
        }
        await database_1.default.$connect();
        const resumableRun = options.resume
            ? await database_1.default.catalogImportRun.findFirst({
                where: { sourceFingerprint, status: client_1.ImportRunStatus.RUNNING },
                orderBy: { startedAt: 'desc' },
            })
            : null;
        const run = resumableRun ??
            (await database_1.default.catalogImportRun.create({
                data: {
                    sourceFingerprint,
                    workbookPath,
                    status: client_1.ImportRunStatus.RUNNING,
                    phase: 'PREFLIGHT_COMPLETE',
                    options: options,
                    logPath: logger.logPath,
                    errorLogPath: logger.errorLogPath,
                },
            }));
        runId = run.id;
        summary.runId = runId;
        await database_1.default.catalogImportRun.update({ where: { id: runId }, data: { phase: 'MASTERS' } });
        await importMasters(workbookPath, runId, summary, logger, options.concurrency);
        await database_1.default.catalogImportRun.update({ where: { id: runId }, data: { phase: 'PRODUCTS' } });
        await importProducts(workbookPath, preflight, runId, summary, logger, options.concurrency, options.batchSize);
        await database_1.default.catalogImportRun.update({ where: { id: runId }, data: { phase: 'PRODUCT_TEXT' } });
        await importProductText(workbookPath, preflight, runId, summary, logger);
        await database_1.default.catalogImportRun.update({
            where: { id: runId },
            data: { phase: 'CUSTOM_ATTRIBUTES' },
        });
        await importCustomAttributes(workbookPath, preflight, runId, summary, logger);
        await database_1.default.catalogImportRun.update({ where: { id: runId }, data: { phase: 'CATALOGUE_MAP' } });
        await applyCatalogueMap(inputOptions.catalogueMapPath, summary, logger);
        await database_1.default.catalogImportRun.update({ where: { id: runId }, data: { phase: 'ASSETS' } });
        await importAssets({
            assetMode: options.assetMode,
            storageDir: options.storageDir,
            assetPublicBaseUrl: options.assetPublicBaseUrl,
            concurrency: options.concurrency,
        }, summary, logger);
        await database_1.default.catalogImportRun.update({ where: { id: runId }, data: { phase: 'VERIFY' } });
        summary.verification = await verifyImport(preflight);
        summary.completedAt = new Date().toISOString();
        summary.executionTimeMs = Date.now() - startedAtMs;
        const status = totalCount(summary.failed) > 0
            ? client_1.ImportRunStatus.COMPLETED_WITH_ERRORS
            : client_1.ImportRunStatus.COMPLETED;
        await database_1.default.catalogImportRun.update({
            where: { id: runId },
            data: {
                status,
                phase: 'COMPLETE',
                counters: summary,
                completedAt: new Date(),
                executionTimeMs: summary.executionTimeMs,
            },
        });
        await logger.event('import.completed', { status, summary });
        await logger.writeSummary(summary);
        return summary;
    }
    catch (error) {
        const message = error instanceof Error ? error.message : String(error);
        countRecord(summary.failed, 'Fatal import errors');
        summary.completedAt = new Date().toISOString();
        summary.executionTimeMs = Date.now() - startedAtMs;
        await logger.error('import.failed', { message });
        if (runId) {
            await database_1.default.catalogImportRun
                .update({
                where: { id: runId },
                data: {
                    status: client_1.ImportRunStatus.FAILED,
                    counters: summary,
                    completedAt: new Date(),
                    executionTimeMs: summary.executionTimeMs,
                },
            })
                .catch(() => undefined);
        }
        await logger.writeSummary(summary);
        throw error;
    }
    finally {
        await logger.flush();
        if (!options.dryRun)
            await database_1.default.$disconnect();
    }
};
exports.runCatalogImport = runCatalogImport;
//# sourceMappingURL=catalogImport.service.js.map