"use strict";

var _interopRequireDefault = require("@babel/runtime/helpers/interopRequireDefault");
exports.__esModule = true;
exports.ImportState = void 0;
var _defineProperty2 = _interopRequireDefault(require("@babel/runtime/helpers/defineProperty"));
var _mobx = require("mobx");
var _model = require("../../model");
var _ConditionalModalState = require("../ConditionalModalState");
var _types = require("@wix/bex-utils/@wix/ambassador-hub-v1-job/types");
exports.WritePolicy = _types.WritePolicy;
var _parseCsv = require("./parseCsv");
var _rewriteCsv = require("./rewriteCsv");
var _hubJobClient = require("./hubJobClient");
var _importJobPoller = require("./importJobPoller");
var _detectFieldType = require("./detectFieldType");
var _fieldEdit = require("./fieldEdit");
/** Outcome of a server-side import job, derived from the job's progress. */

/**
 * Optional snapshot taken before the import runs, returning a `restore` used to
 * undo the import on cancel. See {@link BackupResult.restore} for the revert
 * contract under upsert.
 */

/**
 * Consumer hook for resolving a failed import job's message. An import runs
 * server-side, so a failure arrives on a 200 (the poll succeeds; the job
 * doesn't) with no HTTP error to carry the application error. Cairo hands the
 * consumer `run` — an operation that rejects with a synthetic, HTTP-error-shaped
 * error carrying the job's application error — and the consumer wraps it with
 * their own `withErrorHandler` + error-code map (their codes, their
 * translations), keeping the map coupled to the `withErrorHandler` call as
 * everywhere else (e.g. `fetchData`). The map is keyed by application-error code
 * (the v2 errorHandler shape, like `getExportAsyncJobErrors`):
 *
 *   onJobError: (run) =>
 *     errorHandler.withErrorHandler(run, {
 *       [jobErrorCode]: (error) => ({ message: translate(...) }),
 *     })
 *
 * `withErrorHandler` attaches the map to the error object and re-rejects it, so
 * the progress step resolves a consumer-specific message via `getResolvedError`.
 * When omitted, the error still surfaces but resolves to the generic message.
 */

// `NewFieldType`, `detectColumnType`, etc. are re-exported from the package via
// the `ImportState` barrel (`./index`), which also exports `./detectFieldType`.

// Re-export the write-policy enum so consumers (and the cairo UI) reference it
// from `@wix/bex-core` rather than the bundled ambassador path.

// Re-export the destination type for platformized consumers that supply their
// own variant (e.g. `{ storesCatalogProducts: {} }`) — same reason as above.

// Per-type configuration for a new field, captured from the "Edit field" editor
// (CAIRO-4398). All optional — an unedited field carries none and is created
// from detected defaults only. `displayName` is intentionally not here: the
// (possibly user-overridden) name is deduped and surfaced as
// `NewFieldDefinition.displayName`.

// A new field to add to the collection schema during import. `key` is decided by
// Cairo (derived from the column header — or the user's name override — deduped)
// and is also the key the column's values are written under, so the consumer
// must add the field under exactly this key. `config` carries any per-type
// configuration the field editor produced; create the field with it.

/**
 * Consumer-supplied callback that adds the given new fields to the collection
 * schema in a single call, before the rows are uploaded. Mirrors the cm-import
 * `beforeImport`/`updateSchema` flow: Cairo decides the fields (and their keys),
 * the consumer performs one schema mutation (e.g. `updateSchema(collectionId,
 * ...addField(key, config))`). Its presence is also the opt-in for the "create
 * new field" default — see `getDefaultMapping`.
 *
 * Optionally returns the collection's updated fields map, mirroring the schema's
 * `FieldActions`. When it does, the import merges that map into the table's field
 * source on success so the new columns appear with no manual reload (CAIRO-4400).
 * Returning `void` still imports fine — the columns just wait for the next schema
 * load.
 */

/**
 * How a CSV column is mapped:
 * - `existing` — into an existing collection field by id
 * - `new` — into a new field created during import. `fieldType` is the detected
 *   type, or the type the user picked in the field editor. `displayName` and
 *   `config` are present only once the user edited the pending field via the
 *   "Edit field" editor — `displayName` overrides the header-derived name and
 *   `config` carries the per-type configuration.
 * - `null` — don't import (skip)
 */

/**
 * The import inputs a consumer supplies. Satisfied both by a plain object (the
 * manual case) and by a live schema reference (`useSchemaImport` in
 * `@wix/patterns`) whose members are getters read on demand — so the async
 * schema load needn't resolve before `ImportState` is built. `ImportState` holds
 * it by reference (never spreads it) so those getters stay live.
 */

// How long to keep the completed progress bar (100%) on screen before closing
// the modal on a clean import. Without it, an import of very few rows finishes
// near-instantly and the modal appears to vanish without ever showing the bar
// reaching completion.
const COMPLETION_LINGER_MS = 600;

// The Wix Data collection's id field key. A CSV column mapped to this field lets
// the user choose how rows that collide on `_id` are written (writePolicy).
const ID_FIELD_KEY = '_id';
function normalize(value) {
  return value.trim().toLowerCase();
}

// camelCase key for a field created from a column header
// (e.g. "Product Name" -> "productName").
function toFieldKey(header) {
  const words = header.trim().split(/[^a-zA-Z0-9]+/).filter(Boolean);
  if (words.length === 0) {
    return 'field';
  }
  return words.map((word, index) => index === 0 ? word.charAt(0).toLowerCase() + word.slice(1) : word.charAt(0).toUpperCase() + word.slice(1)).join('');
}

// A display name + key for a field created from a column header, unique against
// both `usedNames` (existing field display names + names already assigned to
// earlier new columns) and `usedKeys` (existing field ids + keys already
// assigned). Mirrors cm-import `generateFieldNameAndKey`: when the name or its
// derived key collides, suffix the *display name* as `"<header>-<n>"` and
// re-derive the key from it — so the created field's name and key are both
// unique (otherwise two columns with the same header would create fields showing
// the same name).
function uniqueFieldNameAndKey(header, usedNames, usedKeys) {
  let displayName = header;
  for (let suffix = 1;; suffix++) {
    const key = toFieldKey(displayName);
    if (!usedNames.has(displayName) && !usedKeys.has(key)) {
      return {
        displayName,
        key
      };
    }
    displayName = `${header}-${suffix}`;
  }
}
class ImportState {
  constructor(props) {
    // The import inputs, held by reference so a live schema-backed config's getters
    // stay live (never spread). The public getters below read from it on demand.
    (0, _defineProperty2.default)(this, "config", void 0);
    (0, _defineProperty2.default)(this, "collection", void 0);
    (0, _defineProperty2.default)(this, "importModalState", void 0);
    (0, _defineProperty2.default)(this, "translate", void 0);
    (0, _defineProperty2.default)(this, "errorHandler", void 0);
    (0, _defineProperty2.default)(this, "showToast", void 0);
    (0, _defineProperty2.default)(this, "downloadCsv", void 0);
    (0, _defineProperty2.default)(this, "hubJobClient", void 0);
    (0, _defineProperty2.default)(this, "_detectColumnType", void 0);
    (0, _defineProperty2.default)(this, "discardChangesModal", new _ConditionalModalState.ConditionalModalState());
    (0, _defineProperty2.default)(this, "currentStep", 'upload');
    (0, _defineProperty2.default)(this, "importStatus", {
      ..._model.queryStatus.idle
    });
    (0, _defineProperty2.default)(this, "fileName", '');
    (0, _defineProperty2.default)(this, "csvHeaders", []);
    (0, _defineProperty2.default)(this, "csvRows", []);
    // Header display order for the configuration step, snapshotted when the file
    // is parsed. The column auto-mapped to `_id` floats to the top (CAIRO-4418);
    // the order then stays fixed while the user edits mappings, so clearing or
    // remapping the `_id` column can't reorder the row under the user's cursor.
    // Recomputed only on a new file (`applyParsedCsv`) or a full `reset()`.
    (0, _defineProperty2.default)(this, "orderedHeaders", []);
    (0, _defineProperty2.default)(this, "mappingByHeader", {});
    // How rows that collide on `_id` are written. Only meaningful (and only sent
    // to the server) when a CSV column is mapped to the `_id` field — see
    // `idMappedHeader` and `_destination`. Defaults to the server default
    // (`OVERWRITE` = upsert by `_id`).
    //
    // Retention: the choice is deliberately kept while editing mappings within a
    // file — remapping `_id` away and back keeps the user's selection rather than
    // silently reverting it. It resets to the default only on a *new file* upload
    // (`applyParsedCsv`) or a full `reset()`.
    (0, _defineProperty2.default)(this, "writePolicy", _types.WritePolicy.OVERWRITE);
    (0, _defineProperty2.default)(this, "processedCount", 0);
    (0, _defineProperty2.default)(this, "totalCount", 0);
    (0, _defineProperty2.default)(this, "result", {
      succeeded: 0,
      failed: 0
    });
    (0, _defineProperty2.default)(this, "isBackingUp", false);
    (0, _defineProperty2.default)(this, "isRestoring", false);
    (0, _defineProperty2.default)(this, "restoreFn", null);
    (0, _defineProperty2.default)(this, "_cancelled", false);
    (0, _defineProperty2.default)(this, "_abortController", null);
    // Id of the running data-movement job, set once submitted — used to cancel it
    // (`terminateJob`) if the user closes mid-import.
    (0, _defineProperty2.default)(this, "_jobId", null);
    // The column whose pending new field the field editor is currently editing.
    // Not observable — transient wiring between `beginFieldEdit` and the editor's
    // `applyFieldEdit` callback, never rendered.
    (0, _defineProperty2.default)(this, "_editingHeader", null);
    // Holds `updateSchema`'s returned fields map between field creation (pre-upload)
    // and success. Kept private/non-observable so it can't update columns mid-import;
    // it's promoted to the observable `createdFieldsMap` only on success.
    (0, _defineProperty2.default)(this, "_pendingFieldsMap", void 0);
    this.config = props.config;
    this.collection = props.collection;
    this.importModalState = props.importModalState;
    this.translate = props.translate;
    this.errorHandler = props.errorHandler;
    this.showToast = props.showToast;
    this.downloadCsv = props.downloadCsv;
    this._detectColumnType = props.detectColumnType ?? _detectFieldType.detectColumnType;
    this.hubJobClient = (0, _hubJobClient.createHubJobClient)({
      httpClient: props.httpClient,
      errorHandler: props.errorHandler
    });
    (0, _mobx.makeObservable)(this, {
      currentStep: _mobx.observable.ref,
      importStatus: _mobx.observable.ref,
      fileName: _mobx.observable.ref,
      csvHeaders: _mobx.observable.ref,
      csvRows: _mobx.observable.ref,
      orderedHeaders: _mobx.observable.ref,
      mappingByHeader: _mobx.observable.ref,
      writePolicy: _mobx.observable.ref,
      processedCount: _mobx.observable.ref,
      totalCount: _mobx.observable.ref,
      result: _mobx.observable.ref,
      isBackingUp: _mobx.observable.ref,
      isRestoring: _mobx.observable.ref,
      hasParsedRows: _mobx.computed,
      hasMappings: _mobx.computed,
      hasNoMappedData: _mobx.computed,
      selectedFieldIds: _mobx.computed,
      idMappedHeader: _mobx.computed,
      setWritePolicy: _mobx.action.bound,
      onUploadContinue: _mobx.action.bound,
      goToUpload: _mobx.action.bound,
      setMapping: _mobx.action.bound,
      setNewFieldMapping: _mobx.action.bound,
      setNewFieldConfig: _mobx.action.bound,
      beginFieldEdit: _mobx.action.bound,
      applyFieldEdit: _mobx.action.bound,
      close: _mobx.action.bound,
      onFileSelected: _mobx.action.bound,
      startImport: _mobx.action.bound,
      reset: _mobx.action.bound
    });
  }

  // Import inputs read live from `config` — a live schema-backed config exposes
  // these as getters, a manual config as plain values; both read the same way.

  /** Existing collection fields, for column auto-mapping. */
  get fields() {
    return this.config.fields;
  }

  /** Wix Data collection the import writes into (unset for platformized). */
  get collectionId() {
    return this.config.collectionId;
  }
  get destination() {
    return this.config.destination;
  }
  get backup() {
    return this.config.backup;
  }
  get updateSchema() {
    return this.config.updateSchema;
  }
  get onJobError() {
    return this.config.onJobError;
  }

  // The collection's id-field key; a schema-backed config may report a non-`_id`
  // identifier. Defaults to the Wix Data `_id`.
  get idFieldKey() {
    return this.config.idFieldKey ?? ID_FIELD_KEY;
  }
  init() {
    return () => {
      // Unmounting mid-import stops the poll loop (it checks `_cancelled`) so it
      // doesn't run to terminal holding references. We deliberately do NOT abort
      // the AbortController: during a cancel-triggered restore it holds the
      // restore controller, and aborting it would cancel an in-progress restore.
      // The server-side job is also left running — an unmount isn't an explicit
      // user cancel.
      this._cancelled = true;
    };
  }
  get hasParsedRows() {
    return this.csvHeaders.length > 0 && this.csvRows.length > 0;
  }
  get hasMappings() {
    return Object.values(this.mappingByHeader).some(value => value != null);
  }

  /**
   * True when there are mappings but none of the mapped columns contain any
   * data in any row (including the case of zero data rows). Drives the
   * non-blocking "no rows found" warning in the configuration step. Returns
   * false when nothing is mapped yet — that case is handled by the disabled
   * import button instead.
   */
  get hasNoMappedData() {
    if (!this.hasMappings) {
      return false;
    }
    const mappedIndices = this.csvHeaders.reduce((indices, header, index) => {
      if (this.mappingByHeader[header] != null) {
        indices.push(index);
      }
      return indices;
    }, []);
    return !this.csvRows.some(row => mappedIndices.some(index => (row[index] ?? '').trim() !== ''));
  }
  get selectedFieldIds() {
    return new Set(Object.values(this.mappingByHeader).filter(m => (m == null ? void 0 : m.kind) === 'existing').map(m => m.fieldId));
  }

  /**
   * The CSV header currently mapped to the collection's `_id` field, or `null`
   * when no column targets it. Drives both the "When items share the same ID"
   * control (only shown on this row) and whether `writePolicy` is sent at all —
   * with no `_id` mapped, every row is a fresh insert and collisions can't occur.
   */
  get idMappedHeader() {
    const idFieldKey = this.idFieldKey;
    const entry = Object.entries(this.mappingByHeader).find(([, mapping]) => (mapping == null ? void 0 : mapping.kind) === 'existing' && mapping.fieldId === idFieldKey);
    return entry ? entry[0] : null;
  }
  setWritePolicy(policy) {
    this.writePolicy = policy;
  }

  /**
   * Whether columns may be mapped to a brand-new field created during import.
   * When the config sets `canAddFields` (e.g. a schema's create-field permission)
   * that gates it; otherwise the presence of `updateSchema` is the opt-in (see
   * `getDefaultMapping`).
   */
  get canCreateNewField() {
    return this.config.canAddFields ?? this.config.updateSchema != null;
  }

  /**
   * Whether the write-policy (id-collision) choice is meaningful for this import.
   * `writePolicy` is a `wixDataCollection`-only concept, and `_destination` only
   * attaches it when Cairo builds that destination itself. A consumer-supplied
   * (platformized) `destination` is sent verbatim with no place to carry it, so
   * the choice would be a no-op — the UI hides the control in that case. Fixed at
   * construction (depends only on the `destination` prop), so it isn't an
   * observable.
   */
  get canConfigureWritePolicy() {
    return this.destination == null;
  }
  reset() {
    var _this$_abortControlle;
    (_this$_abortControlle = this._abortController) == null || _this$_abortControlle.abort();
    this._abortController = null;
    this.currentStep = 'upload';
    this.importStatus = {
      ..._model.queryStatus.idle
    };
    this.fileName = '';
    this.csvHeaders = [];
    this.csvRows = [];
    this.orderedHeaders = [];
    this.mappingByHeader = {};
    this.writePolicy = _types.WritePolicy.OVERWRITE;
    this.processedCount = 0;
    this.totalCount = 0;
    this.result = {
      succeeded: 0,
      failed: 0
    };
    this._pendingFieldsMap = undefined;
    this.isBackingUp = false;
    this.isRestoring = false;
    this.restoreFn = null;
    this._cancelled = false;
    this._jobId = null;
  }
  async close() {
    // While on the progress step we treat close as a cancel — terminate the job
    // and restore the backup — UNLESS the import already succeeded. On the
    // success path `startImport` lingers ~600ms on the completed bar with the
    // step still 'progress'; closing during that window must NOT restore (it
    // would undo a clean import). A successful close just closes the modal and
    // lets the success path do its collection refresh.
    if (this.currentStep === 'progress' && !this.importStatus.isSuccess) {
      var _this$_abortControlle2;
      this._cancelled = true;
      (_this$_abortControlle2 = this._abortController) == null || _this$_abortControlle2.abort();

      // Stop the server-side job. It does not roll back rows already written —
      // that's what the backup restore below is for.
      if (this._jobId) {
        try {
          await this.hubJobClient.terminateJob(this._jobId);
        } catch (error) {
          console.error('Terminate import job failed', error);
        }
      }
      if (!this.isBackingUp) {
        await this._restoreBackup();
      }
      this.collection.clearResultAndMoveToStart({
        force: true
      });
    }
    this.importModalState.close();
  }
  async _restoreBackup() {
    const restoreFn = this.restoreFn;
    if (!restoreFn) {
      return;
    }
    const restoreController = new AbortController();
    this._abortController = restoreController;
    this.isRestoring = true;
    try {
      await restoreFn(restoreController.signal);
    } catch (error) {
      console.error('Restore failed', error);
    }
    (0, _mobx.runInAction)(() => {
      this.isRestoring = false;
      this.restoreFn = null;
    });
  }
  goToUpload() {
    this.currentStep = 'upload';
  }
  async discardAndGoBack() {
    this.discardChangesModal.open('goBack');
    const {
      result
    } = await this.discardChangesModal.modal.promise();
    if (result === 'confirmed') {
      this.goToUpload();
    }
  }
  async discardAndClose() {
    this.discardChangesModal.open('close');
    const {
      result
    } = await this.discardChangesModal.modal.promise();
    if (result === 'confirmed') {
      this.close();
    }
  }
  onUploadContinue() {
    if (this.hasParsedRows) {
      this.currentStep = 'configuration';
    }
  }
  getDefaultMapping(headers) {
    const mapping = {};
    const fields = this.fields.map(field => ({
      id: field.id ?? '',
      header: field.header ?? ''
    }));
    headers.forEach((header, index) => {
      const normalizedHeader = normalize(header);
      const matched = fields.find(field => normalize(field.id) === normalizedHeader || normalize(field.header) === normalizedHeader);
      if (matched != null && matched.id) {
        mapping[header] = {
          kind: 'existing',
          fieldId: matched.id
        };
      } else {
        // Unmatched columns default to creating a new field (type inferred from
        // the column's data) when the consumer supplied `updateSchema`;
        // otherwise they default to skip.
        mapping[header] = this.canCreateNewField ? {
          kind: 'new',
          fieldType: this._detectColumnType(this._columnValues(index))
        } : null;
      }
    });
    return mapping;
  }

  // The column's values, capped at the detection sample size — detection only
  // looks at the first `TYPE_SAMPLE_SIZE` values, so avoid copying the whole
  // column (a wide CSV would otherwise allocate rows × columns strings).
  _columnValues(columnIndex) {
    return this.csvRows.slice(0, _detectFieldType.TYPE_SAMPLE_SIZE).map(row => row[columnIndex] ?? '');
  }

  // Puts the `_id`-mapped column first (CAIRO-4418), preserving CSV order for the
  // rest. Returns a fresh array so the snapshot doesn't alias `csvHeaders`.
  _orderHeadersWithIdFirst(headers) {
    const idHeader = this.idMappedHeader;
    return idHeader != null ? [idHeader, ...headers.filter(header => header !== idHeader)] : [...headers];
  }
  applyParsedCsv(fileName, parsedCsv) {
    this.fileName = fileName;
    this.csvHeaders = parsedCsv.headers;
    this.csvRows = parsedCsv.rows;
    this.mappingByHeader = this.getDefaultMapping(parsedCsv.headers);
    // Snapshot the row order for this file with the auto-mapped `_id` column
    // first. Reads `idMappedHeader`, so it must run after `mappingByHeader` is
    // set. Kept stable until the next file so mapping edits don't reorder rows.
    this.orderedHeaders = this._orderHeadersWithIdFirst(parsedCsv.headers);
    // A new file is a fresh import: start from the default policy so a prior
    // file's Skip choice doesn't silently carry over (only the new-file path
    // resets — remapping `_id` within the same file keeps the user's choice).
    this.writePolicy = _types.WritePolicy.OVERWRITE;
    this.currentStep = 'configuration';
  }
  async onFileSelected(file) {
    if (file == null) {
      return;
    }
    const bytes = await this._readFileBytes(file);
    if (bytes == null) {
      this._showFileReadErrorToast(file);
      return;
    }
    const content = this._decodeUtf8(bytes);
    if (content == null) {
      this._rejectFile(file, 'cairo.import.encodingFailed');
      return;
    }
    const parsedCsv = (0, _parseCsv.parseCsv)(content);
    if ('code' in parsedCsv) {
      this._rejectFile(file, 'cairo.import.processFailed');
      return;
    }
    (0, _mobx.runInAction)(() => {
      this.applyParsedCsv(file.name, parsedCsv);
    });
  }
  async _readFileBytes(file) {
    try {
      return await file.arrayBuffer();
    } catch (e) {
      console.error(e);
      return null;
    }
  }
  _decodeUtf8(bytes) {
    try {
      // `fatal: true` throws on any byte sequence that isn't valid UTF-8,
      // so encoding is validated against the raw bytes before decoding —
      // unlike `File.text()`, which silently substitutes U+FFFD.
      return new TextDecoder('utf-8', {
        fatal: true
      }).decode(bytes);
    } catch (e) {
      console.error(e);
      return null;
    }
  }
  _rejectFile(file, messageKey) {
    var _this$showToast;
    (0, _mobx.runInAction)(() => {
      this.fileName = file.name;
      this.csvHeaders = [];
      this.csvRows = [];
      this.orderedHeaders = [];
      this.mappingByHeader = {};
      this.currentStep = 'upload';
    });
    (_this$showToast = this.showToast) == null || _this$showToast.call(this, {
      type: 'ERROR',
      message: this.translate(messageKey)
    });
  }
  _showFileReadErrorToast(file) {
    var _this$showToast2;
    (_this$showToast2 = this.showToast) == null || _this$showToast2.call(this, {
      type: 'ERROR',
      message: this.translate('cairo.import.fileReadFailed'),
      action: {
        text: this.translate('cairo.import.tryAgain'),
        onClick: () => this.onFileSelected(file),
        removeToastOnClick: true
      }
    });
  }
  _showPartialSuccessToast() {
    var _this$showToast3;
    (_this$showToast3 = this.showToast) == null || _this$showToast3.call(this, {
      type: 'WARNING',
      message: this.translate('cairo.import.partialSuccessToast', {
        failed: String(this.result.failed),
        imported: String(this.result.succeeded)
      }),
      action: {
        text: this.translate('cairo.import.downloadFailedRows'),
        onClick: () => this._downloadFailedRows(),
        removeToastOnClick: true
      }
    });
  }

  /**
   * Builds a plain, re-importable CSV of the rows the server rejected — the
   * original headers plus just the failed rows — and hands it to `downloadCsv`.
   * The failed rows come from the job's movement logs (which row failed), mapped
   * back to the parsed `csvRows` we still hold in memory.
   */
  async _downloadFailedRows() {
    var _this$downloadCsv;
    const jobId = this._jobId;
    if (!jobId) {
      return;
    }
    let indices;
    try {
      indices = await this._fetchFailedRowIndices(jobId);
    } catch (error) {
      console.error('Fetching failed rows report failed', error);
      return;
    }
    const failedRows = indices.map(index => this.csvRows[index]).filter(row => row != null);
    if (failedRows.length === 0) {
      return;
    }
    const content = (0, _parseCsv.unparseCsv)(this.csvHeaders, failedRows);
    (_this$downloadCsv = this.downloadCsv) == null || _this$downloadCsv.call(this, content, `failed-${this.fileName || 'import.csv'}`);
  }

  // Pages through the job's movement logs and returns the `csvRows` indices of
  // the rows that failed. data-hub's CSV decoder
  // (wix-data-hub: packages/wix-data-hub-executor-c/src/plugins/formats/csv/
  // csv-binary-decoder.ts) parses with the `csv-parse` library (`columns: true`)
  // and sets `sourceItemId = "row ${++index + 1}"`, incrementing once **per
  // record** — not per physical line. So N is the 1-based record number with
  // the header consumed as columns: the first data record is `"row 2"` →
  // `csvRows[0]`, i.e. `csvRows[N - 2]`. Because `csv-parse` is RFC-4180,
  // quoted cells containing embedded newlines are a single record, so they do
  // NOT shift the numbering. Out-of-range indices are guarded below.
  async _fetchFailedRowIndices(jobId) {
    const indices = [];
    let cursor;
    do {
      var _pagingMetadata$curso;
      const {
        logs,
        pagingMetadata
      } = await this.hubJobClient.listMovementLogs(jobId, cursor ? {
        cursor
      } : undefined);
      for (const log of logs ?? []) {
        var _exec;
        if (!log.failure || log.sourceItemId == null) {
          continue;
        }
        const fileLine = Number((_exec = /\d+/.exec(log.sourceItemId)) == null ? void 0 : _exec[0]);
        const index = fileLine - 2;
        if (Number.isInteger(index) && index >= 0 && index < this.csvRows.length) {
          indices.push(index);
        }
      }
      cursor = (pagingMetadata == null ? void 0 : pagingMetadata.hasNext) && ((_pagingMetadata$curso = pagingMetadata.cursors) == null ? void 0 : _pagingMetadata$curso.next) || undefined;
    } while (cursor);
    return indices;
  }
  setMapping(header, fieldId) {
    this.mappingByHeader = {
      ...this.mappingByHeader,
      [header]: fieldId == null ? null : {
        kind: 'existing',
        fieldId
      }
    };
  }
  setNewFieldMapping(header) {
    const index = this.csvHeaders.indexOf(header);
    const fieldType = index >= 0 ? this._detectColumnType(this._columnValues(index)) : 'text';
    this.mappingByHeader = {
      ...this.mappingByHeader,
      [header]: {
        kind: 'new',
        fieldType
      }
    };
  }

  /**
   * Persists the result of the "Edit field" editor for a column's pending new
   * field: the (possibly overridden) type, an optional display-name override,
   * and the per-type configuration. No-op if the column isn't currently mapped
   * to a new field.
   */
  setNewFieldConfig(header, edit) {
    const mapping = this.mappingByHeader[header];
    if ((mapping == null ? void 0 : mapping.kind) !== 'new') {
      return;
    }
    this.mappingByHeader = {
      ...this.mappingByHeader,
      [header]: {
        kind: 'new',
        fieldType: edit.fieldType,
        ...(edit.displayName != null && {
          displayName: edit.displayName
        }),
        ...(edit.config && {
          config: edit.config
        })
      }
    };
  }

  /**
   * Marks which column's pending new field the field editor is currently
   * editing, so a later `applyFieldEdit` (the editor's `onSubmit`) knows where to
   * write the result.
   */
  beginFieldEdit(header) {
    this._editingHeader = header;
  }

  /**
   * `onSubmit` handler for the field editor: maps the edited field spec into the
   * column being edited (set via `beginFieldEdit`). No-op if nothing is being
   * edited.
   */
  applyFieldEdit(field) {
    if (this._editingHeader == null) {
      return;
    }
    this.setNewFieldConfig(this._editingHeader, (0, _fieldEdit.toFieldEdit)(field));
  }

  /**
   * Runs the server-side import: align the CSV to the resolved field keys,
   * upload it, submit a data-movement job, then poll until terminal. Throws on
   * a failed job so `startImport` surfaces the error state; partial success is
   * reported via `result.failed` and the partial toast.
   */
  async _runImportJob(resolvedMapping) {
    const csv = (0, _rewriteCsv.rewriteParsedCsvForImport)(this.csvHeaders, this.csvRows, resolvedMapping);
    const file = new File([csv], this.fileName || 'import.csv', {
      type: 'text/csv'
    });
    const fileId = await this.hubJobClient.uploadFile(file);
    if (this._cancelled) {
      return this.result;
    }
    const jobId = await this.hubJobClient.submitImportJob({
      fileId,
      name: this.fileName,
      destination: this._destination()
    });
    (0, _mobx.runInAction)(() => {
      this._jobId = jobId;
    });
    if (this._cancelled) {
      // The user cancelled while `submitImportJob` was in flight, so `close()`
      // saw no `_jobId` and couldn't terminate it. Terminate it here, otherwise
      // the job keeps importing server-side after cancel (and would overwrite a
      // restored backup).
      await this.hubJobClient.terminateJob(jobId).catch(() => undefined);
      return this.result;
    }
    const job = await (0, _importJobPoller.pollImportJob)(id => this.hubJobClient.getJob(id), jobId, {
      isCancelled: () => this._cancelled,
      onProgress: polled => this._applyJobProgress(polled)
    });

    // `null` means cancelled mid-poll — leave whatever progress we have.
    if (!job) {
      return this.result;
    }
    if (job.status === _types.JobStatus.FAILED) {
      var _job$error;
      // The job ran server-side, so a failure arrives on a 200 (the poll
      // succeeded; the job didn't) with no HTTP error to carry the application
      // error. Build a synthetic, HTTP-error-shaped error (the error handler
      // reads the code from `response.data.details.applicationError`) and hand
      // the throwing operation to the consumer's `onJobError` so THEY wrap it
      // with their own `withErrorHandler` + error-code map — `job.error` is the
      // consumer's application error (their import business logic), so only they
      // can map its codes, and the map stays coupled to the `withErrorHandler`
      // call as everywhere else (e.g. `fetchData`). The map is attached to the
      // error object, which the progress step resolves via `getResolvedError`.
      // Without a handler the same error still throws, falling back to the
      // generic message.
      const error = Object.assign(new Error(`ImportJob.${((_job$error = job.error) == null ? void 0 : _job$error.code) ?? 'failed'}`), {
        response: {
          data: {
            details: {
              applicationError: job.error
            }
          }
        }
      });
      const run = () => Promise.reject(error);
      if (this.onJobError) {
        // The consumer wraps `run` with `withErrorHandler`; it re-rejects with
        // `error` (now carrying their map), which propagates from here.
        await this.onJobError(run);
      }
      throw error;
    }
    this._applyJobProgress(job);
    return this.result;
  }

  // Destination the job writes into. A platformized consumer supplies its own
  // variant (e.g. `{ storesCatalogProducts: {} }`), which is sent verbatim.
  // Otherwise this is the consumer's Wix Data collection, built from
  // `collectionId`; `writePolicy` is sent only when a CSV column is mapped to
  // `_id` (the only case collisions can occur), otherwise it's omitted and the
  // server applies its default (upsert by `_id`).
  _destination() {
    if (this.destination) {
      return this.destination;
    }
    return {
      wixDataCollection: {
        collectionId: this.collectionId,
        ...(this.idMappedHeader != null && {
          writePolicy: this.writePolicy
        })
      }
    };
  }
  _applyJobProgress(job) {
    const progress = job.progress;
    const succeeded = (progress == null ? void 0 : progress.itemsSuccessful) ?? 0;
    const failed = (progress == null ? void 0 : progress.itemsRejected) ?? 0;
    const current = (progress == null ? void 0 : progress.current) ?? succeeded + failed;

    // `totalCount` is fixed up front in `startImport` from the CSV row count
    // (one uploaded row per CSV row); polling only advances `processedCount`.
    (0, _mobx.runInAction)(() => {
      this.processedCount = Math.min(current, this.totalCount);
      this.result = {
        succeeded,
        failed
      };
    });
  }
  async _runBackup() {
    const backup = this.backup;
    if (!backup) {
      return false;
    }
    try {
      const result = await backup(this._abortController.signal);
      if (this._cancelled) {
        return true;
      }
      if (result != null && result.restore) {
        this.restoreFn = result.restore;
      }
    } catch (error) {
      if (this._cancelled) {
        return true;
      }
      (0, _mobx.runInAction)(() => {
        this.isBackingUp = false;
        this.importStatus = {
          ..._model.queryStatus.error,
          error
        };
      });
      return true;
    }
    (0, _mobx.runInAction)(() => {
      this.isBackingUp = false;
    });
    return false;
  }
  _lingerOnCompletion() {
    return new Promise(resolve => setTimeout(resolve, COMPLETION_LINGER_MS));
  }

  /**
   * Resolves every column to the final destination key used in the upload
   * payload: an existing field id, a new field's (Cairo-decided) key, or `null`
   * to skip. All new fields are added to the schema in a single `updateSchema`
   * call before the rows upload — mirroring the cm-import `beforeImport` flow.
   * `updateSchema` may throw — `startImport` surfaces that as the error state.
   */
  async _resolveFieldMapping() {
    const resolved = {};
    const newFields = [];
    // Seed with existing field ids/display names so derived keys and names never
    // collide with them or with each other.
    const usedKeys = new Set(this.fields.map(field => field.id).filter(id => !!id));
    const usedNames = new Set(this.fields.map(field => field.header).filter(header => !!header));
    for (const header of this.csvHeaders) {
      const mapping = this.mappingByHeader[header];
      if (mapping == null) {
        resolved[header] = null;
      } else if (mapping.kind === 'existing') {
        resolved[header] = mapping.fieldId;
      } else if (this.updateSchema) {
        var _mapping$displayName;
        // The user's name override (from the field editor) seeds the unique
        // name/key derivation; otherwise fall back to the column header.
        const baseName = ((_mapping$displayName = mapping.displayName) == null ? void 0 : _mapping$displayName.trim()) || header;
        const {
          displayName,
          key
        } = uniqueFieldNameAndKey(baseName, usedNames, usedKeys);
        usedKeys.add(key);
        usedNames.add(displayName);
        newFields.push({
          key,
          type: mapping.fieldType,
          displayName,
          ...(mapping.config && {
            config: mapping.config
          })
        });
        resolved[header] = key;
      } else {
        // "new" mapping without an `updateSchema` callback — nothing to map into.
        resolved[header] = null;
      }
    }
    if (newFields.length > 0 && this.updateSchema) {
      // Stash the returned fields map to merge into the table on success; kept off
      // the observable until then so columns don't update mid-import. Guard the
      // empty map: SchemaState.mergeFields is a full replace, so merging `{}` would
      // wipe every column — treat a missing/empty return as "nothing to merge".
      const updated = await this.updateSchema(newFields);
      this._pendingFieldsMap = updated && Object.keys(updated).length > 0 ? updated : undefined;
    }
    return resolved;
  }
  async startImport() {
    if (!this.hasMappings) {
      return;
    }
    this._cancelled = false;
    this._abortController = new AbortController();

    // One uploaded row per CSV row (the rewrite drops columns, not rows), so the
    // total is known up front from the parsed CSV and stays fixed through polling.
    const totalCount = this.csvRows.length;
    (0, _mobx.runInAction)(() => {
      this.currentStep = 'progress';
      this.totalCount = totalCount;
      this.processedCount = 0;
      this.isBackingUp = !!this.backup;
      // Clear any error/result from a previous attempt so "Try again" starts
      // from a clean slate (e.g. after a backup failure).
      this.importStatus = {
        ..._model.queryStatus.idle
      };
      this.result = {
        succeeded: 0,
        failed: 0
      };
      this._jobId = null;
      // Drop any fields map from a prior attempt so a no-new-fields "Try again"
      // can't merge stale fields.
      this._pendingFieldsMap = undefined;
    });
    const backupFailed = await this._runBackup();
    if (backupFailed) {
      return;
    }
    if (this._cancelled) {
      return;
    }

    // Create any new fields and resolve every column to its destination key
    // before uploading. A creation failure surfaces as the error state with a
    // "Try again" CTA, like an upload/job failure.
    let resolvedMapping;
    try {
      resolvedMapping = await this._resolveFieldMapping();
    } catch (error) {
      (0, _mobx.runInAction)(() => {
        this.importStatus = {
          ..._model.queryStatus.error,
          error
        };
      });
      return;
    }
    if (this._cancelled) {
      return;
    }
    const promise = this._runImportJob(resolvedMapping);
    this.importStatus = {
      ..._model.queryStatus.loading,
      promise
    };

    // Assigned only on the success path below, so the post-import merge (after the
    // try/catch) fires only for a completed import — without re-checking status
    // there. Reading `_pendingFieldsMap` directly wouldn't do: it's set pre-upload,
    // so it stays truthy even when the job later fails.
    let createdFields;
    try {
      await promise;
      if (this._cancelled) {
        return;
      }
      (0, _mobx.runInAction)(() => {
        this.importStatus = {
          ..._model.queryStatus.success,
          data: this.result
        };
      });

      // On a clean import, hold the completed (100%) progress bar on screen
      // briefly so the completion is perceptible even for very few rows. Skip
      // the wait when there are failed rows — that path closes and surfaces a
      // toast instead.
      if (this.result.failed === 0) {
        await this._lingerOnCompletion();
        if (this._cancelled) {
          return;
        }
      }

      // Success path only. `_pendingFieldsMap` holds this run's returned map — it's
      // cleared at the start of `startImport` (the stale-guard) and set by
      // `_resolveFieldMapping`; snapshotting here keeps the merge success-gated.
      createdFields = this._pendingFieldsMap;
      (0, _mobx.runInAction)(() => {
        // Rows the server rejected are reported via a toast (the downloadable
        // failed-rows report is added by the failed-rows-report task).
        if (this.result.failed > 0) {
          this._showPartialSuccessToast();
        }
        this.importModalState.close();
        this.collection.clearResultAndMoveToStart({
          force: true
        });
      });
    } catch (error) {
      (0, _mobx.runInAction)(() => {
        this.importStatus = {
          ..._model.queryStatus.error,
          error
        };
      });
    }

    // Merge the created fields into the table so the new columns appear with no
    // manual reload (CAIRO-4400). Called imperatively here, outside the try/catch
    // above, so a merge failure can't flip a completed import into the error state
    // (whose "Try again" re-imports every row). `createdFields` is set only on the
    // success path and only when fields were created.
    if (createdFields) {
      try {
        var _this$config$mergeFie, _this$config;
        (_this$config$mergeFie = (_this$config = this.config).mergeFields) == null || _this$config$mergeFie.call(_this$config, createdFields);
      } catch (error) {
        // eslint-disable-next-line no-console
        console.error('ImportState config.mergeFields failed', error);
      }
    }
  }
}
exports.ImportState = ImportState;
//# sourceMappingURL=ImportState.js.map