import { clearData } from '../clear.js';
import { inputData, opt } from '../containers/app.js';
import {
  annotations,
  convertPageWarn,
  layoutDataTables,
  layoutRegions,
  ocrAll,
  ocrAllRaw,
  pageMetricsAll,
} from '../containers/dataContainer.js';
import { FontCont } from '../containers/fontContainer.js';
import { ImageCache } from '../containers/imageContainer.js';
import { extractInternalPDFText } from '../extractPDFText.js';
import {
  enableFontOpt,
  loadBuiltInFontsRaw,
  optimizeFontContainerAll, setDefaultFontAuto,
} from '../fontContainerMain.js';
import { runFontOptimization } from '../fontEval.js';
import { calcCharMetricsFromPages } from '../fontStatistics.js';
import { gs } from '../generalWorkerMain.js';
import { imageUtils, ImageWrapper } from '../objects/imageObjects.js';
import { addCircularRefsDataTables, LayoutDataTablePage, LayoutPage } from '../objects/layoutObjects.js';
import { OcrPage, addCircularRefsOcr, updateOcrFormat } from '../objects/ocrObjects.js';
import { PageMetrics } from '../objects/pageMetricsObjects.js';
import { checkCharWarn, convertOCR } from '../recognizeConvert.js';
import { importImageFileToBase64 } from '../utils/imageUtils.js';
import {
  readOcrFile, clearObjectProperties, objectAssignDefined, readTextFile,
} from '../utils/miscUtils.js';
import { importOCRFiles } from './importOCR.js';

/**
 * Standardize file-like inputs between platforms.
 * If run in the browser, URLs are fetched and converted to `File` objects.
 * If using Node.js, file paths are converted into `FileNode` objects,
 * which have properties and methods similar to the browser `File` interface.
 * @param {Array<File>|FileList|Array<string>} files
 * @returns {Promise<Array<File>|Array<FileNode>>}
 */
export async function standardizeFiles(files) {
  if (typeof files[0] === 'string') {
    if (typeof process !== 'undefined') {
      const { wrapFilesNode } = await import('./nodeAdapter.js');
      return wrapFilesNode(/** @type {Array<string>} */(files));
    }

    // Fetch all URLs and convert the responses to Blobs
    const blobPromises = files.map((url) => fetch(url).then((response) => {
      if (!response.ok) {
        console.log(response);
        throw new Error(`Failed to fetch ${url}: ${response.statusText}`);
      }
      return response.blob().then((blob) => ({ blob, url }));
    }));

    // Wait for all fetches to complete
    const blobsAndUrls = await Promise.all(blobPromises);

    // Extract file name from URL and convert Blobs to File objects
    return blobsAndUrls.map(({ blob, url }) => {
      const fileName = url.split('/').pop();
      // A valid filename is necessary, as the import function uses the filename.
      if (!fileName) throw new Error(`Failed to extract file name from URL: ${url}`);
      return new File([blob], fileName, { type: blob.type });
    });
  }

  if (globalThis.FileList && files instanceof FileList) {
    return Array.from(files);
  }

  return /** @type {Array<File>} */ (files);
}

/**
 * Sorts single array of files into pdf, image, ocr, and unsupported files.
 * Used for browser interface, where files of multiple types may be uploaded using the same input.
 * @param {Array<File>|Array<FileNode>|FileList} files
 * @returns
 */
export async function sortInputFiles(files) {
  // Sort files into (1) HOCR files, (2) image files, or (3) unsupported using extension.
  /** @type {Array<File|FileNode>} */
  const imageFilesAll = [];
  /** @type {Array<File|FileNode>} */
  const ocrFilesAll = [];
  /** @type {Array<File|FileNode>} */
  const pdfFilesAll = [];
  /** @type {Array<File|FileNode>} */
  const scribeFilesAll = [];
  /** @type {Array<File|FileNode>} */
  const unsupportedFilesAll = [];
  const unsupportedExt = {};
  for (let i = 0; i < files.length; i++) {
    const file = files[i];
    const fileExt = file.name.match(/\.([^.]+)$/)?.[1].toLowerCase() || '';
    const fileNameLower = file.name.toLowerCase();

    if (fileNameLower.endsWith('.scribe.json')) {
      scribeFilesAll.push(file);
    // TODO: Investigate whether other file formats are supported (without additional changes)
    // Tesseract.js definitely supports more formats, so if the .pdfs we make also support a format,
    // then we should be able to expand the list of supported types without issue.
    // Update: It looks like .bmp does not work.
    } else if (['png', 'jpeg', 'jpg'].includes(fileExt)) {
      imageFilesAll.push(file);
      // All .gz files are assumed to be OCR data (xml) since all other file types can be compressed already
    } else if (['hocr', 'xml', 'html', 'gz', 'stext', 'json', 'txt', 'docx'].includes(fileExt)) {
      ocrFilesAll.push(file);
    } else if (['scribe'].includes(fileExt)) {
      scribeFilesAll.push(file);
    } else if (['pdf'].includes(fileExt)) {
      pdfFilesAll.push(file);
    } else {
      // Check if file without an extension could be a textract JSON file.
      // This is currently a hack and should be re-implemented in a better way.
      // Notably, (1) this only works for Textract JSON files stored in specific object types, and
      // (2) this reads the file content as text and then discards it after checking the content,
      // which is not ideal for performance.
      if ([''].includes(fileExt)) {
        try {
          const content = await readOcrFile(file);
          if (/"AnalyzeDocumentModelVersion"/i.test(content)) {
            ocrFilesAll.push(file);
            continue;
          }
        } catch (error) { /* empty */ }
      }

      unsupportedFilesAll.push(file);
      unsupportedExt[fileExt] = true;
    }
  }

  if (unsupportedFilesAll.length > 0) {
    const errorText = `Import includes unsupported file types: ${Object.keys(unsupportedExt).join(', ')}`;
    opt.warningHandler(errorText);
  }

  imageFilesAll.sort((a, b) => ((a.name > b.name) ? 1 : ((b.name > a.name) ? -1 : 0)));
  ocrFilesAll.sort((a, b) => ((a.name > b.name) ? 1 : ((b.name > a.name) ? -1 : 0)));

  return {
    pdfFiles: pdfFilesAll, imageFiles: imageFilesAll, ocrFiles: ocrFilesAll, scribeFiles: scribeFilesAll,
  };
}

/**
 * Read image files and return array of `ImageWrapper` objects,
 * without modifying any global state.
 * @param {Array<File>|FileList|Array<string>} files
 */
export const importImageFilesP = async (files) => {
  const files2 = await standardizeFiles(files);
  /** @type {ImageWrapper[]} */
  const images = [];
  for (let i = 0; i < files2.length; i++) {
    images[i] = await importImageFileToBase64(files2[i]).then(async (imgStr) => {
      const imgWrapper = new ImageWrapper(i, imgStr, 'native', false, false);
      return imgWrapper;
    });
  }
  return images;
};

/**
 * Read a .scribe file and restore session data.
 * @param {string | File | FileNode | ArrayBuffer} scribeFile
 */
const restoreSessionFromFile = async (scribeFile) => {
  const scribeRestoreStr = await readOcrFile(scribeFile);
  /** @type {ScribeSaveData} */
  const scribeRestoreObj = JSON.parse(scribeRestoreStr);
  if (scribeRestoreObj.fontState) {
    objectAssignDefined(FontCont.state, scribeRestoreObj.fontState);
    await runFontOptimization(ocrAll.active);
  }
  if (scribeRestoreObj.layoutRegions) {
    // existingLayout = true;
    layoutRegions.pages = scribeRestoreObj.layoutRegions;
  }
  if (scribeRestoreObj.layoutDataTables) {
    // existingLayoutDataTable = true;
    addCircularRefsDataTables(scribeRestoreObj.layoutDataTables);
    layoutDataTables.pages = scribeRestoreObj.layoutDataTables;
  }
  if (scribeRestoreObj.annotations) {
    annotations.pages = scribeRestoreObj.annotations;
  }

  const oemName = 'User Upload';
  if (!ocrAll[oemName]) ocrAll[oemName] = Array(inputData.pageCount);
  updateOcrFormat(scribeRestoreObj.ocr);
  addCircularRefsOcr(scribeRestoreObj.ocr);
  ocrAll[oemName] = scribeRestoreObj.ocr;
  ocrAll.active = ocrAll[oemName];

  for (let i = 0; i < ocrAll[oemName].length; i++) {
    if (!ocrAll[oemName][i]) {
      ocrAll[oemName][i] = new OcrPage(i, { height: 1920, width: 1080 });
    }
    inputData.xmlMode[i] = true;
    pageMetricsAll[i] = new PageMetrics(ocrAll[oemName][i].dims);
    pageMetricsAll[i].angle = ocrAll[oemName][i].angle;
  }
};

/**
 * Restore session data from legacy HOCR exports.
 * Originally HOCR was used as the primary format for saving and restoring sessions,
 * which lead to a significant amount of session data being stored in HOCR files.
 * This function extracts that data and restores it to the current session format.
 * Eventually this function can be deprecated and removed.
 * Users should use the .scribe format for saving and restoring sessions instead.
 * @param {Awaited<ReturnType<importOCRFiles>>} ocrData
 */
const restoreSessionFromLegacyHocr = async (ocrData) => {
  let existingOpt = false;

  objectAssignDefined(FontCont.state, ocrData.fontState);

  // Restore font metrics and optimize font from previous session (if applicable)
  if (ocrData.fontState.charMetrics && Object.keys(ocrData.fontState.charMetrics).length > 0) {
    const fontPromise = loadBuiltInFontsRaw();

    existingOpt = true;

    await gs.schedulerReady;
    setDefaultFontAuto(FontCont.state.charMetrics);

    // If `ocrData.enableOpt` is `false`, then the metrics are present but ignored.
    // This occurs if optimization was found to decrease accuracy for both sans and serif,
    // not simply because the user disabled optimization in the view settings.
    // If no `enableOpt` property exists but metrics are present, then optimization is enabled.
    if (ocrData.enableOpt === 'false') {
      FontCont.state.enableOpt = false;
    } else {
      await fontPromise;
      if (!FontCont.raw) throw new Error('Raw font data not found.');
      FontCont.opt = await optimizeFontContainerAll(FontCont.raw, FontCont.state.charMetrics);
      FontCont.state.enableOpt = true;
      await enableFontOpt(true);
    }
  }

  // Restore layout data from previous session (if applicable)
  if (ocrData.layoutObj) {
    for (let i = 0; i < ocrData.layoutObj.length; i++) {
      layoutRegions.pages[i] = ocrData.layoutObj[i];
    }
  }

  if (ocrData.layoutDataTableObj) {
    for (let i = 0; i < ocrData.layoutDataTableObj.length; i++) {
      layoutDataTables.pages[i] = ocrData.layoutDataTableObj[i];
    }
  }

  return {
    existingOpt,
  };
};

/**
 * An object with this shape can be used to provide input to the `importFiles` function,
 * without needing that function to figure out the file types.
 * This is required when using ArrayBuffer inputs.
 * @public
 * @typedef {Object} SortedInputFiles
 * @property {Array<File>|Array<string>|Array<ArrayBuffer>} [pdfFiles]
 * @property {Array<File>|Array<string>|Array<ArrayBuffer>} [imageFiles]
 * @property {Array<File>|Array<string>|Array<ArrayBuffer>} [ocrFiles]
 * @property {Array<File>|Array<string>|Array<ArrayBuffer>} [scribeFiles]
 */

/**
 * Import files for processing.
 * An object with `pdfFiles`, `imageFiles`, and `ocrFiles` arrays can be provided to import multiple types of files.
 * Alternatively, for `File` objects (browser) and file paths (Node.js), a single array can be provided, which is sorted based on extension.
 * @public
 * @param {Array<File>|FileList|Array<string>|SortedInputFiles} files
 */
export async function importFiles(files) {
  if (!files) throw new Error('No files provided.');

  clearData();
  gs.getGeneralScheduler();

  /** @type {Array<File|FileNode|ArrayBuffer>} */
  let pdfFiles = [];
  /** @type {Array<File|FileNode|ArrayBuffer>} */
  let imageFiles = [];
  /** @type {Array<File|FileNode|ArrayBuffer>} */
  let ocrFiles = [];
  /** @type {Array<File|FileNode|ArrayBuffer>} */
  let scribeFiles = [];
  // These statements contain many ts-ignore comments, because the TypeScript interpreter apparently cannot properly narrow arrays.
  // See: https://github.com/microsoft/TypeScript/issues/42384
  if ('pdfFiles' in files || 'imageFiles' in files || 'ocrFiles' in files || 'scribeFiles' in files) {
    if (files.pdfFiles && files.pdfFiles[0] instanceof ArrayBuffer) {
      // @ts-ignore
      pdfFiles = files.pdfFiles;
    } else if (files.pdfFiles) {
      // @ts-ignore
      pdfFiles = await standardizeFiles(files.pdfFiles);
    }
    if (files.imageFiles && files.imageFiles[0] instanceof ArrayBuffer) {
      // @ts-ignore
      imageFiles = files.imageFiles;
    } else if (files.imageFiles) {
      // @ts-ignore
      imageFiles = await standardizeFiles(files.imageFiles);
    }
    if (files.ocrFiles && files.ocrFiles[0] instanceof ArrayBuffer) {
      // @ts-ignore
      ocrFiles = files.ocrFiles;
    } else if (files.ocrFiles) {
      // @ts-ignore
      ocrFiles = await standardizeFiles(files.ocrFiles);
    }
    if (files.scribeFiles && files.scribeFiles[0] instanceof ArrayBuffer) {
      // @ts-ignore
      scribeFiles = files.scribeFiles;
    } else if (files.scribeFiles) {
      // @ts-ignore
      scribeFiles = await standardizeFiles(files.scribeFiles);
    }
  } else {
    // @ts-ignore
    const filesStand = await standardizeFiles(files);
    if (files[0] instanceof ArrayBuffer) throw new Error('ArrayBuffer inputs must be sorted by file type.');
    ({
      pdfFiles, imageFiles, ocrFiles, scribeFiles,
    } = await sortInputFiles(filesStand));
  }

  if (pdfFiles.length === 0 && imageFiles.length === 0 && ocrFiles.length === 0 && scribeFiles.length === 0) {
    const errorText = 'No supported files found.';
    opt.errorHandler(errorText);
    return;
  } if (pdfFiles.length > 0 && imageFiles.length > 0) {
    const errorText = 'PDF and image files cannot be imported together. Only first PDF file will be imported.';
    opt.warningHandler(errorText);
    pdfFiles.length = 1;
    imageFiles.length = 0;
  } else if (pdfFiles.length > 1) {
    const errorText = 'Multiple PDF files are not supported. Only first PDF file will be imported.';
    opt.warningHandler(errorText);
    pdfFiles.length = 1;
    imageFiles.length = 0;
  }

  if (pdfFiles[0] && !(pdfFiles[0] instanceof ArrayBuffer)) {
    inputData.inputFileNames = [pdfFiles[0].name];
  } else if (imageFiles[0] && !(imageFiles[0] instanceof ArrayBuffer)) {
    // @ts-ignore
    inputData.inputFileNames = imageFiles.map((x) => x.name);
  }

  // Set default download name
  if (pdfFiles.length > 0 && 'name' in pdfFiles[0]) {
    inputData.defaultDownloadFileName = `${pdfFiles[0].name.replace(/\.\w{1,6}$/, '')}.pdf`;
  } else if (imageFiles.length > 0 && 'name' in imageFiles[0]) {
    inputData.defaultDownloadFileName = `${imageFiles[0].name.replace(/\.\w{1,6}$/, '')}.pdf`;
  } else if (ocrFiles.length > 0 && 'name' in ocrFiles[0]) {
    inputData.defaultDownloadFileName = `${ocrFiles[0].name.replace(/\.\w{1,6}$/, '')}.pdf`;
  } else if (scribeFiles.length > 0 && 'name' in scribeFiles[0]) {
    inputData.defaultDownloadFileName = `${scribeFiles[0].name.replace(/\.\w{1,6}$/, '')}.pdf`;
  }

  inputData.pdfMode = pdfFiles.length === 1;
  inputData.imageMode = !!(imageFiles.length > 0 && !inputData.pdfMode);
  ImageCache.inputModes.image = !!(imageFiles.length > 0 && !inputData.pdfMode);

  if (scribeFiles[0]) {
    await restoreSessionFromFile(scribeFiles[0]);
  }

  const xmlModeImport = ocrFiles.length > 0;

  let pageCount;
  let pageCountImage;
  /** @type {TextSource} */
  let format;
  let reimportHocrMode = false;

  if (inputData.pdfMode) {
    // If no XML data is provided, page sizes are calculated using muPDF alone
    await ImageCache.openMainPDF(pdfFiles[0]);

    pageCountImage = ImageCache.pageCount;
    ImageCache.loadCount = ImageCache.pageCount;
  } else if (inputData.imageMode) {
    pageCountImage = imageFiles.length;
  }

  let existingOpt = false;
  const oemName = 'User Upload';
  if (xmlModeImport) {
    // Initialize a new array on `ocrAll` if one does not already exist
    if (!ocrAll[oemName]) ocrAll[oemName] = Array(inputData.pageCount);
    ocrAll.active = ocrAll[oemName];

    const ocrData = await importOCRFiles(Array.from(ocrFiles));

    format = /** @type {("hocr" | "abbyy" | "alto" | "stext" | "textract" | "text")} */ (ocrData.format);

    // The text import function requires built-in fonts to be loaded.
    if (['text', 'docx'].includes(format)) {
      await loadBuiltInFontsRaw();
    }

    ocrAllRaw.active = ocrData.hocrRaw;
    // Subset OCR data to avoid uncaught error that occurs when there are more pages of OCR data than image data.
    // While this should be rare, it appears to be fairly common with Archive.org documents.
    // TODO: Add warning message displayed to user for this.
    // Textract JSON data is returned in arbitrary chunks (multiple pages may be in one file, or one page may be in multiple files).
    // Therefore, it is impossible to know how many pages of OCR data there are based only on the length of `ocrAllRaw.active`.
    if (pageCountImage && ocrAllRaw.active.length > pageCountImage && ocrData.format !== 'textract' && ocrData.format !== 'google_doc_ai') {
      console.log(`Identified ${ocrAllRaw.active.length} pages of OCR data but ${pageCountImage} pages of image/pdf data. Only first ${pageCountImage} pages will be used.`);
      ocrAllRaw.active = ocrAllRaw.active.slice(0, pageCountImage);
    }

    format = /** @type {("hocr" | "abbyy" | "alto" | "stext" | "textract" | "text")} */ (ocrData.format);
    reimportHocrMode = ocrData.reimportHocrMode;

    if (ocrData.reimportHocrMode) {
      const restoreRes = await restoreSessionFromLegacyHocr(ocrData);
      existingOpt = restoreRes.existingOpt;
    }
  }

  let pageCountOcr = ocrAllRaw.active?.length || ocrAll.active?.length || 0;

  // For Textract, `ocrAllRaw.active[0]` is a string containing the Textract JSON data for all pages.
  // This ad-hoc solution counts the number of "PAGE" blocks in the Textract JSON data.
  if (format === 'textract' && ocrAllRaw.active?.length) {
    pageCountOcr = ocrAllRaw.active[0].match(/"BLOCKTYPE":\s*"PAGE"/ig)?.length || pageCountOcr;
  }

  if (format === 'google_doc_ai' && ocrAllRaw.active?.length) {
    pageCountOcr = ocrAllRaw.active[0].match(/"pageNumber":\s*\d+/g)?.length || pageCountOcr;
  }

  // If both OCR data and image data are present, confirm they have the same number of pages
  if (xmlModeImport && (inputData.imageMode || inputData.pdfMode)) {
    if (pageCountImage !== pageCountOcr) {
      const warningHTML = `Page mismatch detected. Image data has ${pageCountImage} pages while OCR data has ${pageCountOcr} pages.`;
      opt.warningHandler(warningHTML);
    }
  }

  inputData.pageCount = pageCountImage ?? pageCountOcr;

  ocrAllRaw.active = ocrAllRaw.active || Array(pageCount);

  for (let i = 0; i < inputData.pageCount; i++) {
    if (!layoutRegions.pages[i]) {
      layoutRegions.pages[i] = new LayoutPage(i);
    }
  }

  for (let i = 0; i < inputData.pageCount; i++) {
    if (!layoutDataTables.pages[i]) {
      layoutDataTables.pages[i] = new LayoutDataTablePage(i);
    }
  }

  for (let i = 0; i < inputData.pageCount; i++) {
    if (!annotations.pages[i]) {
      annotations.pages[i] = [];
    }
  }

  // Render first page for PDF only
  if (inputData.pdfMode && !xmlModeImport) {
    opt.progressHandler({ n: 0, type: 'importPDF', info: { } });
  }

  if (inputData.imageMode) {
    ImageCache.pageCount = inputData.pageCount;
    for (let i = 0; i < inputData.pageCount; i++) {
      ImageCache.nativeSrc[i] = await importImageFileToBase64(imageFiles[i]).then(async (imgStr) => {
        const imgWrapper = new ImageWrapper(i, imgStr, 'native', false, false);
        const imageDims = await imageUtils.getDims(imgWrapper);
        pageMetricsAll[i] = new PageMetrics(imageDims);
        return imgWrapper;
      });
      ImageCache.loadCount++;
      opt.progressHandler({ n: i, type: 'importImage', info: { } });
    }
  }

  // Re-apply page angles from .scribe data after PDF/image loading overwrites pageMetrics.
  // The PDF/image loading creates new PageMetrics with correct dimensions but angle=null.
  if (scribeFiles[0] && ocrAll.active) {
    for (let i = 0; i < ocrAll.active.length; i++) {
      if (ocrAll.active[i]?.angle != null && pageMetricsAll[i]) {
        pageMetricsAll[i].angle = ocrAll.active[i].angle;
      }
    }
  }

  if (xmlModeImport) {
    // Process OCR using web worker, reading from file first if that has not been done already
    await convertOCR(ocrAllRaw.active, true, format, oemName, reimportHocrMode, pageMetricsAll).then(async () => {
      // Skip this step if optimization info was already restored from a previous session,
      // or if using stext/textract (which are character-level but not visually accurate).
      if (!existingOpt && !opt.skipFontOpt && !['stext', 'textract', 'google_vision', 'google_doc_ai', 'azure_doc_intel'].includes(format)) {
        await checkCharWarn(convertPageWarn);
        const charMetrics = calcCharMetricsFromPages(ocrAll.active);

        if (Object.keys(charMetrics).length > 0) {
          clearObjectProperties(FontCont.state.charMetrics);
          Object.assign(FontCont.state.charMetrics, charMetrics);
        }
        await runFontOptimization(ocrAll.active);
      }
    });
  } else if (!scribeFiles[0] && inputData.pdfMode && (opt.usePDFText.native.main || opt.usePDFText.native.supp || opt.usePDFText.ocr.main || opt.usePDFText.ocr.supp || opt.keepPDFTextAlways)) {
    await extractInternalPDFText();
    if (inputData.pdfType === 'text' && opt.usePDFText.native.main || inputData.pdfType === 'ocr' && opt.usePDFText.ocr.main) {
      if (inputData.pdfType === 'text') FontCont.state.enableCleanToNimbusMono = true;
    }
  }
}

/**
 * Import supplemental OCR files, such as an alternate OCR version or ground truth data.
 * This function should not be used to import the main OCR files.
 * @param {Array<File>|FileList|Array<string>} files
 * @param {string} ocrName - Name of the OCR version (e.g. "Ground Truth")
 */
export async function importFilesSupp(files, ocrName) {
  if (!files || files.length === 0) return;

  if (!ocrAll[ocrName]) ocrAll[ocrName] = Array(inputData.pageCount);

  const curFiles = await standardizeFiles(files);

  /** @type {Array<File|FileNode>} */
  const ocrFilesAll = [];
  for (let i = 0; i < curFiles.length; i++) ocrFilesAll.push(curFiles[i]);

  ocrFilesAll.sort((a, b) => ((a.name > b.name) ? 1 : ((b.name > a.name) ? -1 : 0)));

  const ocrData = await importOCRFiles(ocrFilesAll);

  const pageCountHOCR = ocrData.hocrRaw.length;

  // If both OCR data and image data are present, confirm they have the same number of pages
  if (ImageCache.pageCount > 0 && ImageCache.pageCount !== pageCountHOCR) {
    const warningHTML = `Page mismatch detected. Image data has ${ImageCache.pageCount} pages while OCR data has ${pageCountHOCR} pages.`;
    opt.warningHandler(warningHTML);
  }

  const format = /** @type {("hocr" | "abbyy" | "stext" | "textract" | "text")} */ (ocrData.format);

  await convertOCR(ocrData.hocrRaw, false, format, ocrName, ocrData.reimportHocrMode, pageMetricsAll);
}
