/**
 * Эпизодическая память (ЭП).
 *
 * ЭП — массив со строгой исторической последовательностью кадров эпизодов.
 * Порядок не перемешивается; допускаются операции изменения и удаления без изменения порядка.
 *
 * Правило (кадр эпизода): стимул (FinalImage) → ответное действие (actions_image) → эффект (Effect).
 * Effect = getDiffImportance() до активации стимула минус getDiffImportance() при первом стимуле
 * после совершения действий. Период ожидания задаётся в пульсах (RESPONSE_WAITING_PULSES), не таймером.
 *
 * В кадре: perceptionNodeId, stimulusImageId, situationId, actionImageId, effect, responseStimulusImageId.
 * AnswerID + responseStimulusImageId — «учительское» правило: как отвечать на действие.
 * Открытый кадр: effect === null. После закрытия (closeLastFrame) effect всегда число в диапазоне [-10, +10].
 * Истечение ожидания без реакции: кадр не удаляется — в правило проставляются нули (ОД, ответный стимул), кадр закрывается, абстракции/ментальные правила обрабатываются как при любом закрытии.
 * Кадры с незаполненным effect в сохранённом снимке остаются на оптимизацию во время сна.
 *
 * ПРЕЕМСТВЕННОСТЬ ОЦЕНКИ ЭФФЕКТА (ЭП ↔ автоматизмы):
 * Один и тот же эффект вычисляется в ЭП (closeLastFrame: effect = clamp(diffAfter - diffBefore) в [-10,+10]) и передаётся
 * в штатный автоматизм ветки через addUsefulnessSample(automatizmId, last.effect). Оценка эффекта
 * единая: не дублируется, не расходится. Usefulness автоматизма усредняется по тем же значениям,
 * что и кадры ЭП (рефлекс или автоматизм сработал → кадр закрыт → один effect → одна запись в автоматизм).
 */
(function (global) {
  'use strict';

  /** Длительность периода ожидания в пульсах (вызов EpisodicMemory_onPulse из puls.js). */
  var response_waiting_period = 15;
  var pulsesWaiting = 0;
  /** Показать прогресс-бар 100% ещё N пульсов после закрытия кадра с ответом. */
  var barShowCompletePulsesLeft = 0;

  global.EpisodicMemory_waitingStartedAt = null;
  global.EpisodicMemory_testMode = false;
  global.EpisodicMemory_pulsesWaiting = 0;
  /** ID автоматизма ФО, чей effect будет усреднён при закрытии текущего открытого кадра (обнуляется после записи). */
  global.EpisodicMemory_pendingFeedbackAutomatizmId = 0;
  /** ID цикла осмысления, в котором был запущен автоматизм (период ожидания кадра ЭП). */
  global.EpisodicMemory_waitingAutomatismCycleId = 0;
  /** Effect только что закрытого кадра (для ур.2 при ответе оператора). */
  global.EpisodicMemory_lastClosedFrameEffect = null;

  if (!Array.isArray(global.Episodes)) {
    global.Episodes = [];
  }

  /**
   * Шкала эффекта закрытого кадра ЭП: всегда конечное число в [-10, +10].
   * Совпадает по смыслу с шкалой семантической значимости; выбросы от дельты diff отрезаются.
   */
  var EPISODE_EFFECT_MIN = -10;
  var EPISODE_EFFECT_MAX = 10;

  function clampEpisodeFrameEffect(n) {
    if (typeof n !== 'number' || isNaN(n)) return 0;
    if (n < EPISODE_EFFECT_MIN) return EPISODE_EFFECT_MIN;
    if (n > EPISODE_EFFECT_MAX) return EPISODE_EFFECT_MAX;
    return n;
  }

  /**
   * Проверить, идёт ли период ожидания ответа (незакрытый кадр, начатый в этой сессии, счётчик пульсов не достиг лимита).
   * Если последний кадр загружен из IndexedDB с effect === null, waitingStartedAt остаётся null — не считаем это активным ожиданием,
   * чтобы первый клик после обновления страницы создал новый кадр с прогресс-баром от 0%, а не закрыл старый с показом 100%.
   */
  function isInWaitingPeriod() {
    if (global.EpisodicMemory_waitingStartedAt == null) return false;
    var ep = global.Episodes;
    if (!ep.length) return false;
    var last = ep[ep.length - 1];
    if (last.effect !== null && last.effect !== undefined) return false;
    return pulsesWaiting < response_waiting_period;
  }

  /**
   * Закрыть последний кадр: вычислить effect и опционально записать ответный стимул.
   * ПРЕЕМСТВЕННОСТЬ: тот же effect записывается в кадр ЭП и передаётся в штатный автоматизм ветки
   * (addUsefulnessSample) — единая оценка эффекта для ЭП и автоматизма.
   *
   * Абстракции (`abstract_images.js`): в конце вызывается AbstractImages_cloneFromEpisodeFrame(last);
   * к этому моменту effect уже число в [-10,+10], а «дыры» в правиле при необходимости
   * заранее заполнены нулями — см. episodicFrameFillRuleZerosForIncompleteClose.
   *
   * @param {number|null} responseStimulusImageId — ID образа стимула в ответ (или null)
   */
  function closeLastFrame(responseStimulusImageId) {
    var ep = global.Episodes;
    if (!ep.length) return;
    var last = ep[ep.length - 1];
    if (last.effect !== null && last.effect !== undefined) return;
    var waitCycleId = typeof global.EpisodicMemory_waitingAutomatismCycleId === 'number'
      ? global.EpisodicMemory_waitingAutomatismCycleId : 0;
    // Effect закрытого кадра никогда не остаётся нечисловым: при нормальном diffBefore — clamp(Δdiff), иначе 0 (нейтрально).
    if (typeof global.getDiffImportance === 'function') {
      var diffAfter = global.getDiffImportance();
      var before = last.diffBefore;
      if (typeof before === 'number' && !isNaN(before)) {
        last.effect = clampEpisodeFrameEffect(diffAfter - before);
      } else {
        last.effect = 0;
      }
    } else {
      last.effect = 0;
    }
    global.EpisodicMemory_lastClosedFrameEffect = last.effect;
    if (responseStimulusImageId != null && waitCycleId && typeof global.Consciousness_getCycle === 'function') {
      var oc = global.Consciousness_getCycle(waitCycleId);
      if (oc && oc.awaitingAutomatismEpisodeFeedback) {
        oc.automatismOperatorAnswerReceived = true;
        oc.lastAutomatismEpisodeEffect = last.effect;
        if (typeof global.Info_setField === 'function') {
          global.Info_setField('isOperatorAnswerReceived', 1);
        } else if (global.InfoPicture) {
          global.InfoPicture.isOperatorAnswerReceived = 1;
        }
        if (typeof global.Consciousness_cycleAppendLog === 'function') {
          var pcEp = typeof global.cpuls === 'number' ? global.cpuls : 0;
          var effStr = String(last.effect);
          global.Consciousness_cycleAppendLog(oc, 'ЭП: закрыт кадр ответом оператора, automatismOperatorAnswerReceived=1, effect=' + effStr + '.', pcEp, { force: true });
        }
        // Цикл может быть уже expired / не главный (info_automatismWithEpisodeFeedbackAndExpireCycle) — runOneStep не вызовет ур.2.
        if (typeof global.conscience_level_2 === 'function') {
          var cxOr = oc.context || {};
          global.conscience_level_2({
            sourceCycle: oc,
            actualId: cxOr.actual_stimul_ID,
            branchId: cxOr.branchId,
            well: cxOr.well,
            emotionId: cxOr.emotionId,
            activityID: cxOr.activityID,
            _operatorAnswerCycleOnly: true
          });
        }
      }
    }
    if (responseStimulusImageId != null) {
      last.responseStimulusImageId = responseStimulusImageId;
      if (global.EpisodicMemory_testMode && typeof global.show_alert === 'function') {
        var respDetail = typeof global.PerceptionTree_getStimulusNamesForFinalImageId === 'function'
          ? global.PerceptionTree_getStimulusNamesForFinalImageId(responseStimulusImageId)
          : ('ID ' + responseStimulusImageId);
        global.show_alert('Стимул в периоде ожидания: ' + respDetail + '<br>Кадр эпизода завершен и записан.', 0);
      }
    }
    // Преемственность оценки эффекта: last.effect → автоматизм, запускавшийся в ФО (feedbackAutomatizmId на кадре), иначе штатный ветки.
    if (last.perceptionNodeId != null && typeof global.Automatizms_addUsefulnessSample === 'function') {
      var feedbackAutoId = (typeof last.feedbackAutomatizmId === 'number' && last.feedbackAutomatizmId > 0)
        ? last.feedbackAutomatizmId : 0;
      if (!feedbackAutoId && typeof global.EpisodicMemory_pendingFeedbackAutomatizmId === 'number' &&
          global.EpisodicMemory_pendingFeedbackAutomatizmId > 0) {
        feedbackAutoId = global.EpisodicMemory_pendingFeedbackAutomatizmId;
      }
      if (feedbackAutoId) {
        global.Automatizms_addUsefulnessSample(feedbackAutoId, last.effect);
      } else if (typeof global.Automatizms_getDefaultAutomatizmForBranch === 'function') {
        var staff = global.Automatizms_getDefaultAutomatizmForBranch(last.perceptionNodeId);
        if (staff && staff.id) {
          global.Automatizms_addUsefulnessSample(staff.id, last.effect);
        }
      }
      if (typeof global.PerceptionTree_updateActiveBranchView === 'function') {
        global.PerceptionTree_updateActiveBranchView();
      }
    }
    /**
     * Гештальт (ур.4): буфер резонанса и обновление записей по закрытому кадру.
     * Вызывается до сброса EpisodicMemory_pendingFeedbackAutomatizmId / waitingAutomatismCycleId,
     * чтобы передать ID автоматизма из ожидания.
     *
     * Сценарии:
     *   — Ответ оператора в периоде ожидания: waitCycleId > 0, effect число, в lastFrame — ветка/ситуация/ОД.
     *   — Закрытие без ответного стимула (responseStimulusImageId == null): эффект всё равно число; гештальт
     *     получает сигнал для возможной переинтерпретации (слабый/отрицательный effect).
     *   — Закрытие по новому стимулу ОР без ожидания автоматизма: waitCycleId === 0 — только push в резонанс
     *     при положительном effect (кросс-цикловая аналогия для других целей).
     */
    if (typeof global.Gestalt_onEpisodeFeedbackClosed === 'function') {
      try {
        var feedbackAutoIdForGestalt = 0;
        if (typeof last.feedbackAutomatizmId === 'number' && last.feedbackAutomatizmId > 0) {
          feedbackAutoIdForGestalt = last.feedbackAutomatizmId;
        } else if (typeof global.EpisodicMemory_pendingFeedbackAutomatizmId === 'number' &&
            global.EpisodicMemory_pendingFeedbackAutomatizmId > 0) {
          feedbackAutoIdForGestalt = global.EpisodicMemory_pendingFeedbackAutomatizmId;
        }
        global.Gestalt_onEpisodeFeedbackClosed({
          waitCycleId: waitCycleId,
          lastFrame: last,
          automatismId: feedbackAutoIdForGestalt,
          effect: last.effect
        });
      } catch (eGestalt) {}
    }
    global.EpisodicMemory_pendingFeedbackAutomatizmId = 0;
    global.EpisodicMemory_waitingAutomatismCycleId = 0;
    if (last.feedbackAutomatizmId !== undefined) delete last.feedbackAutomatizmId;
    if (waitCycleId && typeof global.Consciousness_getCycle === 'function') {
      var cWF = global.Consciousness_getCycle(waitCycleId);
      if (cWF) cWF.waitingFeedbackAutomatizmId = 0;
    }
    if (typeof global.InfoDet_updateActiveWaiting === 'function') {
      global.InfoDet_updateActiveWaiting(false);
    }

    if (waitCycleId && typeof global.Consciousness_getCycle === 'function') {
      var cEnd = global.Consciousness_getCycle(waitCycleId);
      // Без ответного стимула usefulness образа цели (cycle.goalImageId) не меняется — пайплайн ур.2 не вызывается.
      if (cEnd && cEnd.awaitingAutomatismEpisodeFeedback && !cEnd.automatismOperatorAnswerReceived) {
        cEnd.awaitingAutomatismEpisodeFeedback = false;
        cEnd.expired = true;
        cEnd.isMainCycle = false;
        if (typeof global.Consciousness_cycleAppendLog === 'function') {
          var pc = typeof global.cpuls === 'number' ? global.cpuls : 0;
          global.Consciousness_cycleAppendLog(cEnd, 'Период ожидания закрыт без ответного стимула — цикл expired.', pc, { force: true });
        }
        if (typeof global.Info_updateView === 'function') global.Info_updateView();
      }
    }

    // Абстракции (beast/_9_Conscience/abstract_images.js): при каждом закрытии кадра —
    // усиление/создание записи восприятия по (perceptionNodeId, stimulusImageId);
    // клон действия — только если actionImageId ненулевой (0 после «нуляции» дыры = нет клона ОД).
    var abstractClone = null;
    if (typeof global.AbstractImages_cloneFromEpisodeFrame === 'function') {
      try {
        abstractClone = global.AbstractImages_cloneFromEpisodeFrame(last);
      } catch (e) {
        if (typeof global.console !== 'undefined' && global.console.warn) {
          global.console.warn('AbstractImages_cloneFromEpisodeFrame', e);
        }
      }
    }
    // mental_automatism.js: усреднение ментального правила по abstractPerceptionId | abstractActionId и effect.
    if (abstractClone && typeof global.MentalRules_registerFromEpisodeFrame === 'function') {
      try {
        global.MentalRules_registerFromEpisodeFrame(last, abstractClone);
      } catch (e) {}
    }
    if (typeof global.AbstractImages_flushIndexedDB === 'function') {
      try {
        global.AbstractImages_flushIndexedDB();
      } catch (e) {}
    }
    last.diffBefore = undefined;
    pulsesWaiting = 0;
    global.EpisodicMemory_pulsesWaiting = 0;
    global.EpisodicMemory_waitingStartedAt = null;
    barShowCompletePulsesLeft = 2;
    global.EpisodicMemory_barShowCompletePulsesLeft = 2;
    epDirty = true;
  }

  /**
   * Сохранить эпизод (вызывается из ОР при новом наиболее актуальном стимуле).
   * Сначала при необходимости закрывается предыдущий кадр (effect + ответный стимул).
   * Затем добавляется «пустой» кадр; закрытие по таймауту выполняется по пульсам (EpisodicMemory_onPulse).
   *
   * @param {number} perceptionNodeId — ID конечной ветки дерева восприятия
   * @param {number} stimulusImageId — ID образа стимулов (FinalImage)
   */
  function save_episodic_memory(perceptionNodeId, stimulusImageId) {
    var ep = global.Episodes;
    var now = Date.now();
    barShowCompletePulsesLeft = 0;
    global.EpisodicMemory_barShowCompletePulsesLeft = 0;

    if (ep.length > 0) {
      var prev = ep[ep.length - 1];
      if (prev.effect === null || prev.effect === undefined) {
        // Новый победитель ОР закрывает предыдущий открытый кадр: ответный стимул = stimulusImageId.
        // Если в предыдущем кадре ещё нет ОД или ответа — в полях правила проставляются 0, затем closeLastFrame.
        episodicFrameFillRuleZerosForIncompleteClose(prev);
        closeLastFrame(stimulusImageId);
      }
    }

    var diffBefore = (typeof global.PrevDiffForEpisode === 'number' && !isNaN(global.PrevDiffForEpisode))
      ? global.PrevDiffForEpisode
      : (typeof global.getDiffImportance === 'function' ? global.getDiffImportance() : null);
    var situationId = 0;
    if (typeof global.Info_initIfNeeded === 'function') {
      try {
        var info = global.Info_initIfNeeded();
        if (info && typeof info.situationId === 'number') {
          situationId = info.situationId || 0;
        }
      } catch (e) {}
    }
    ep.push({
      perceptionNodeId: perceptionNodeId || 0,
      stimulusImageId: stimulusImageId || 0,
      situationId: situationId,
      actionImageId: null,
      diffBefore: diffBefore,
      effect: null,
      responseStimulusImageId: null,
      createdAt: now
    });
    epDirty = true;
    pulsesWaiting = 0;
    global.EpisodicMemory_pulsesWaiting = 0;
    global.EpisodicMemory_waitingStartedAt = now;
    barShowCompletePulsesLeft = 0;
    global.EpisodicMemory_barShowCompletePulsesLeft = 0;
  }

  /**
   * Вызывается из puls.js каждый пульс. Считает пульсы ожидания и при достижении лимита закрывает кадр без ответа.
   * Таймеры не используются — логика привязана к пульсу, при зависании вкладки не догоняет.
   *
   * Два исхода по истечении response_waiting_period:
   * — не было реакции (actionImageId пустой): нули в ОД и ответном стимуле, closeLastFrame (кадр в истории, абстракция восприятия);
   * — реакция была, ответа оператора нет: closeLastFrame(null) без удаления кадра.
   */
  function EpisodicMemory_onPulse() {
    if (barShowCompletePulsesLeft > 0) {
      barShowCompletePulsesLeft--;
      global.EpisodicMemory_barShowCompletePulsesLeft = barShowCompletePulsesLeft;
      if (barShowCompletePulsesLeft === 0) {
        global.EpisodicMemory_waitingStartedAt = null;
      }
      return;
    }
    var ep = global.Episodes;
    if (!ep.length) return;
    var last = ep[ep.length - 1];
    if (last.effect !== null && last.effect !== undefined) return;
    pulsesWaiting++;
    global.EpisodicMemory_pulsesWaiting = pulsesWaiting;
    if (pulsesWaiting < response_waiting_period) return;
    pulsesWaiting = 0;
    global.EpisodicMemory_pulsesWaiting = 0;
    global.EpisodicMemory_waitingStartedAt = null;
    // Таймаут без моторной реакции: раньше кадр удаляли (ep.pop); теперь — закрытие с нулевым ОД/ответом и полным пайплайном ЭП+абстракции.
    if (last.actionImageId === null || last.actionImageId === undefined) {
      if (global.EpisodicMemory_testMode && typeof global.show_alert === 'function' && last.stimulusImageId) {
        var stimDetail2 = typeof global.PerceptionTree_getStimulusNamesForFinalImageId === 'function'
          ? global.PerceptionTree_getStimulusNamesForFinalImageId(last.stimulusImageId)
          : ('ID ' + last.stimulusImageId);
        global.show_alert('На стимул (' + stimDetail2 + ') реакция не возникла.<br>Кадр ЭП закрывается: в правиле — нули за отсутствующие ОД и ответный стимул; абстракция восприятия усиливается по эффекту.', 0);
      }
      if (typeof global.InfoDet_updateOperatorIgnoring === 'function') {
        global.InfoDet_updateOperatorIgnoring(true);
      }
      if (typeof global.InfoDet_updateOperatorAnswer === 'function') {
        global.InfoDet_updateOperatorAnswer(false);
      }
      episodicFrameFillRuleZerosForIncompleteClose(last);
      closeLastFrame(null);
      if (typeof global.Consciousness_getMainCycle === 'function') {
        var mcNoAct = global.Consciousness_getMainCycle();
        if (mcNoAct) mcNoAct.waitingFeedbackAutomatizmId = 0;
      }
    } else {
      // Реакция была зафиксирована; ответный стимул не поступил — кадр закрывается с уже известным actionImageId.
      // Период ожидания закончился после реакции без ответного стимула —
      // оператор проигнорировал действие.
      if (typeof global.InfoDet_updateOperatorIgnoring === 'function') {
        global.InfoDet_updateOperatorIgnoring(true);
      }
      // Ответ оператора не получен в отведённый период.
      if (typeof global.InfoDet_updateOperatorAnswer === 'function') {
        global.InfoDet_updateOperatorAnswer(false);
      }
      closeLastFrame(null);
    }
  }

  /**
   * Дополнить последний кадр ID образа действия (AnswerID).
   * @param {number} actionImageId — ID образа действия (ОД)
   */
  function setLastFrameActionImageId(actionImageId) {
    var ep = global.Episodes;
    if (!ep.length || actionImageId == null) return;
    var last = ep[ep.length - 1];
    if (last.actionImageId !== null) return;
    last.actionImageId = actionImageId;
    epDirty = true;
  }

  /**
   * Зафиксировать ответное действие в последнем кадре по actionId.
   * Использует stimulusImageId уже записанный в кадре (не текущие стимулы),
   * чтобы кадр заполнялся и при отложенном рефлексе или когда стимулы уже сняты.
   * Вызывается из startAction после срабатывания реакции.
   *
   * @param {number} actionId — ID действия (BasicActions)
   */
  function setLastFrameActionFromActionId(actionId) {
    if (!actionId) return;
    var ep = global.Episodes;
    if (!ep.length) return;
    var last = ep[ep.length - 1];
    if (last.actionImageId !== null) return;
    var stimulusImageId = last.stimulusImageId;
    if (!stimulusImageId || typeof global.ActionImages_registerReaction !== 'function') return;
    var odId = 0;
    try {
      odId = global.ActionImages_registerReaction(stimulusImageId, actionId);
    } catch (e) {}
    if (odId) {
      last.actionImageId = odId;
      if (typeof global.EpisodicMemory_pendingFeedbackAutomatizmId === 'number' &&
          global.EpisodicMemory_pendingFeedbackAutomatizmId > 0) {
        last.feedbackAutomatizmId = global.EpisodicMemory_pendingFeedbackAutomatizmId;
        global.EpisodicMemory_pendingFeedbackAutomatizmId = 0;
      }
      epDirty = true;
      // Создание автоматизма по образу действия; effect будет записан при закрытии кадра (преемственность оценки).
      var pnId = last.perceptionNodeId;
      if (pnId && typeof global.Automatizms_createAutomatizm === 'function') {
        var aId = global.Automatizms_createAutomatizm(pnId, odId);
        if (aId) {
          var list = (typeof global.Automatizms_getAutomatizmsByBranchId === 'function')
            ? global.Automatizms_getAutomatizmsByBranchId(pnId) : [];
          if (list && list.length === 1 && typeof global.Automatizms_setDefaultAutomatizm === 'function') {
            global.Automatizms_setDefaultAutomatizm(aId);
          }
          if (typeof global.PerceptionTree_updateActiveBranchView === 'function') {
            global.PerceptionTree_updateActiveBranchView();
          }
        }
      }
      if (global.EpisodicMemory_testMode && typeof global.show_alert === 'function') {
        var stimDetail = typeof global.PerceptionTree_getStimulusNamesForFinalImageId === 'function'
          ? global.PerceptionTree_getStimulusNamesForFinalImageId(stimulusImageId)
          : ('ID ' + stimulusImageId);
        var actDetail = typeof global.PerceptionTree_getActionNamesForActionImage === 'function'
          ? global.PerceptionTree_getActionNamesForActionImage(odId)
          : ('ID ' + odId);
        global.show_alert('Активирован образ стимула: ' + stimDetail + '<br>Вызвавшая реакция: ' + actDetail + '<br>Период ожидания ответа начат.', 0);
      }
    }
  }

  var epDirty = false;
  var lastEpSavedSig = '';

  /**
   * Компактная сериализация кадра: perceptionNode|stimulus|situation|action|effect|response.
   * Пустой сегмент при разборе → null. Явная строка "0" для action/response сохраняется как число 0
   * (отсутствие ОД или ответа в правиле); см. parseIntOrNull — не использовать (x||null), иначе 0 теряется.
   */
  var EP_SEP = '|';

  /**
   * Перед closeLastFrame: заменить отсутствующие части «учительского» правила на 0, чтобы кадр оставался валидной записью.
   * Открытый кадр хранит null в незаполненных полях; 0 после закрытия значит «компонент не был» (таймаут, обрыв сценария).
   */
  function episodicFrameFillRuleZerosForIncompleteClose(frame) {
    if (!frame || frame.effect !== null && frame.effect !== undefined) return;
    if (frame.actionImageId === null || frame.actionImageId === undefined) {
      frame.actionImageId = 0;
    }
    if (frame.responseStimulusImageId === null || frame.responseStimulusImageId === undefined) {
      frame.responseStimulusImageId = 0;
    }
  }

  function serializeState() {
    var lines = [];
    if (Array.isArray(global.Episodes)) {
      for (var i = 0; i < global.Episodes.length; i++) {
        var f = global.Episodes[i];
        if (!f) continue;
        var a = f.perceptionNodeId != null ? String(f.perceptionNodeId) : '';
        var b = f.stimulusImageId != null ? String(f.stimulusImageId) : '';
        var c = f.situationId != null ? String(f.situationId) : '';
        var d = f.actionImageId != null ? String(f.actionImageId) : '';
        var e = (f.effect !== null && f.effect !== undefined) ? String(f.effect) : '';
        var r = f.responseStimulusImageId != null ? String(f.responseStimulusImageId) : '';
        lines.push(a + EP_SEP + b + EP_SEP + c + EP_SEP + d + EP_SEP + e + EP_SEP + r);
      }
    }
    return {
      compact: lines.join('\n'),
      response_waiting_period: response_waiting_period
    };
  }

  function parseCompactLine(line) {
    var parts = line.split(EP_SEP);
    // Старые строки могли быть в формате без situationId (5 полей).
    // Новый формат: perceptionNodeId|stimulusImageId|situationId|actionImageId|effect|responseStimulusImageId
    var p = parts[0];
    var s = parts[1];
    var sit = parts.length > 5 ? parts[2] : ''; // если старый формат, situationId считается 0
    var a = parts.length > 5 ? parts[3] : (parts[2] || '');
    var ef = parts.length > 5 ? parts[4] : (parts[3] || '');
    var r = parts.length > 5 ? parts[5] : (parts[4] || '');
    var eff = ef === '' ? null : parseFloat(ef);
    if (eff !== null && isNaN(eff)) eff = null;
    /** Пустая строка → null; иначе целое (в т.ч. 0). Нужно для кадров с нулевым ОД/ответом после таймаута. */
    function parseIntOrNull(str) {
      if (str === '') return null;
      var x = parseInt(str, 10);
      return isNaN(x) ? null : x;
    }
    return {
      perceptionNodeId: p === '' ? 0 : parseInt(p, 10) || 0,
      stimulusImageId: s === '' ? 0 : parseInt(s, 10) || 0,
      situationId: sit === '' ? 0 : (parseInt(sit, 10) || 0),
      actionImageId: parseIntOrNull(a),
      effect: eff,
      responseStimulusImageId: parseIntOrNull(r),
      createdAt: 0
    };
  }

  function applyState(state) {
    if (!state) return;
    var list = [];
    if (typeof state.compact === 'string' && state.compact.length >= 0) {
      var lineArr = state.compact.split('\n');
      for (var i = 0; i < lineArr.length; i++) {
        var line = lineArr[i].trim();
        if (!line) continue;
        list.push(parseCompactLine(line));
      }
    } else if (Array.isArray(state.episodes)) {
      for (var j = 0; j < state.episodes.length; j++) {
        var o = state.episodes[j];
        if (!o) continue;
        list.push({
          perceptionNodeId: o.perceptionNodeId != null ? o.perceptionNodeId : 0,
          stimulusImageId: o.stimulusImageId != null ? o.stimulusImageId : 0,
          situationId: o.situationId != null ? o.situationId : 0,
          actionImageId: o.actionImageId != null ? o.actionImageId : null,
          effect: o.effect != null ? o.effect : null,
          responseStimulusImageId: o.responseStimulusImageId != null ? o.responseStimulusImageId : null,
          createdAt: 0
        });
      }
    }
    global.Episodes = list;
    if (typeof state.response_waiting_period === 'number' && state.response_waiting_period > 0) {
      response_waiting_period = state.response_waiting_period;
    }
  }

  var epDb = null;
  var EP_DB_NAME = 'BEAST_Episodes_DB';
  var EP_DB_VERSION = 1;
  var EP_STORE = 'episodes_state';
  var EP_KEY = 'episodes_singleton';

  function openEpDb() {
    if (!global.indexedDB || !global.IndexedDBModule) {
      return Promise.reject(new Error('IndexedDB не поддерживается или модуль не загружен.'));
    }
    if (epDb) return Promise.resolve(epDb);
    return global.IndexedDBModule.openDatabase({
      dbName: EP_DB_NAME,
      version: EP_DB_VERSION,
      stores: [{ name: EP_STORE, options: { keyPath: 'id' } }]
    }).then(function (db) {
      epDb = db;
      return db;
    });
  }

  function saveEpStateToIndexedDB(state) {
    if (!global.IndexedDBModule) return;
    state = state || serializeState();
    function doPut(db) {
      return global.IndexedDBModule.put({
        db: db,
        storeName: EP_STORE,
        value: { id: EP_KEY, state: state, ts: Date.now() }
      }).catch(function () {});
    }
    if (epDb) {
      doPut(epDb);
      return;
    }
    openEpDb().then(doPut).catch(function () {});
  }

  function loadEpStateFromIndexedDB() {
    if (!global.IndexedDBModule) return;
    openEpDb()
      .then(function (db) {
        return global.IndexedDBModule.get({
          db: db,
          storeName: EP_STORE,
          key: EP_KEY
        });
      })
      .then(function (record) {
        if (record && record.state) {
          applyState(record.state);
          lastEpSavedSig = JSON.stringify(serializeState());
        }
      })
      .catch(function () {});
  }

  if (typeof global.registerIndexedDBSaver === 'function') {
    global.registerIndexedDBSaver(function () {
      if (!epDirty) return;
      epDirty = false;
      var state = serializeState();
      var sig = JSON.stringify(state);
      if (sig === lastEpSavedSig) return;
      lastEpSavedSig = sig;
      saveEpStateToIndexedDB(state);
    });
  }

  function clearAllEpisodicMemory() {
    global.Episodes = [];
    global.EpisodicMemory_pendingFeedbackAutomatizmId = 0;
    global.EpisodicMemory_waitingAutomatismCycleId = 0;
    global.EpisodicMemory_lastClosedFrameEffect = null;
    global.EpisodicMemory_waitingStartedAt = null;
    pulsesWaiting = 0;
    global.EpisodicMemory_pulsesWaiting = 0;
    barShowCompletePulsesLeft = 0;
    global.EpisodicMemory_barShowCompletePulsesLeft = 0;
    epDirty = true;
    lastEpSavedSig = '';
    saveEpStateToIndexedDB(serializeState());
  }

  loadEpStateFromIndexedDB();

  if (global.document) {
    function bindClearButton() {
      var btn = global.document.getElementById('btnClearEpisodicMemory');
      if (!btn || btn._epBound) return;
      btn._epBound = true;
      btn.addEventListener('click', function () {
        if (typeof global.show_confirm !== 'function') {
          if (global.confirm('Точно удалить всю эпизодическую память?')) clearAllEpisodicMemory();
          return;
        }
        global.show_confirm('Точно удалить всю эпизодическую память?', function (result) {
          if (result === 'OK') clearAllEpisodicMemory();
        });
      });
    }
    function bindTestModeButton() {
      var btn = global.document.getElementById('btnEpTestMode');
      if (!btn || btn._epTestBound) return;
      btn._epTestBound = true;
      btn.addEventListener('click', function () {
        global.EpisodicMemory_testMode = !global.EpisodicMemory_testMode;
        if (global.EpisodicMemory_testMode) {
          btn.classList.add('active');
        } else {
          btn.classList.remove('active');
        }
      });
    }
    function bindButtons() {
      bindClearButton();
      bindTestModeButton();
    }
    if (global.document.readyState === 'loading') {
      global.document.addEventListener('DOMContentLoaded', bindButtons);
    } else {
      bindButtons();
    }
  }

  global.save_episodic_memory = save_episodic_memory;
  /**
   * Выборка всех кадров эпизодической памяти (Правил) по ветке восприятия и ветке ситуации.
   * @param {number} perceptionNodeId - ID ветки дерева восприятия (обязателен, >0)
   * @param {number} situationId - ID образа ситуации (0 = не учитывать ситуацию)
   * @returns {Object[]} новый массив кадров (shallow copy)
   */
  function EpisodicMemory_getFramesByPerceptionAndSituation(perceptionNodeId, situationId) {
    perceptionNodeId = parseInt(perceptionNodeId, 10) || 0;
    situationId = parseInt(situationId, 10) || 0;
    if (!perceptionNodeId || !Array.isArray(global.Episodes)) return [];
    var out = [];
    for (var i = 0; i < global.Episodes.length; i++) {
      var f = global.Episodes[i];
      if (!f) continue;
      if ((f.perceptionNodeId || 0) !== perceptionNodeId) continue;
      if (situationId && (f.situationId || 0) !== situationId) continue;
      // shallow copy, чтобы не ломать оригинал
      out.push({
        perceptionNodeId: f.perceptionNodeId || 0,
        stimulusImageId: f.stimulusImageId || 0,
        situationId: f.situationId || 0,
        actionImageId: f.actionImageId != null ? f.actionImageId : null,
        effect: f.effect != null ? f.effect : null,
        responseStimulusImageId: f.responseStimulusImageId != null ? f.responseStimulusImageId : null,
        createdAt: f.createdAt || 0
      });
    }
    return out;
  }

  /**
   * Выборка всех кадров эпизодической памяти (Правил) по ID стимула (FinalImageId).
   * @param {number} stimulusImageId - ID образа стимула (FinalImage)
   * @returns {Object[]} новый массив кадров (shallow copy)
   */
  function EpisodicMemory_getFramesByStimulus(stimulusImageId) {
    stimulusImageId = parseInt(stimulusImageId, 10) || 0;
    if (!stimulusImageId || !Array.isArray(global.Episodes)) return [];
    var out = [];
    for (var i = 0; i < global.Episodes.length; i++) {
      var f = global.Episodes[i];
      if (!f) continue;
      if ((f.stimulusImageId || 0) !== stimulusImageId) continue;
      out.push({
        perceptionNodeId: f.perceptionNodeId || 0,
        stimulusImageId: f.stimulusImageId || 0,
        situationId: f.situationId || 0,
        actionImageId: f.actionImageId != null ? f.actionImageId : null,
        effect: f.effect != null ? f.effect : null,
        responseStimulusImageId: f.responseStimulusImageId != null ? f.responseStimulusImageId : null,
        createdAt: f.createdAt || 0
      });
    }
    return out;
  }

  /**
   * Все кадры ЭП, где ответный стимул оператора совпадает с заданным FinalImageId.
   *
   * Семантика поля responseStimulusImageId в кадре: «после реакции Beast оператор ответил образом с этим id».
   * Пассивное фантазирование (info_fantasizming) подбирает кадры, где этот id совпадает с текущим фокусом
   * цепочки, чтобы строить ассоциативное продолжение по уже пережитым учительским эпизодам.
   *
   * @param {number} responseStimulusImageId — обычно FinalImageId стимула-ответа; в цепочке фантазма на шаге N+1
   *   часто передаётся actionImageId с шага N (как следующий «якорь» поиска — см. fantasizming.js).
   * @returns {Object[]} копии полей + _episodeIndex (индекс в global.Episodes)
   */
  function EpisodicMemory_getFramesByResponseStimulus(responseStimulusImageId) {
    responseStimulusImageId = parseInt(responseStimulusImageId, 10) || 0;
    if (!responseStimulusImageId || !Array.isArray(global.Episodes)) return [];
    var out = [];
    for (var i = 0; i < global.Episodes.length; i++) {
      var f = global.Episodes[i];
      if (!f) continue;
      var rs = f.responseStimulusImageId != null ? parseInt(f.responseStimulusImageId, 10) : 0;
      if (rs !== responseStimulusImageId) continue;
      out.push({
        _episodeIndex: i,
        perceptionNodeId: f.perceptionNodeId || 0,
        stimulusImageId: f.stimulusImageId || 0,
        situationId: f.situationId || 0,
        actionImageId: f.actionImageId != null ? f.actionImageId : null,
        effect: f.effect != null ? f.effect : null,
        responseStimulusImageId: f.responseStimulusImageId != null ? f.responseStimulusImageId : null
      });
    }
    return out;
  }

  /**
   * Кадр с максимальным |effect| среди переданных (учительское/эпизодическое правило с ненулевым ОД).
   *
   * Игнорирует кадры без числового effect или без actionImageId. Используется там, где важна сильная
   * эмоционально-оценочная окраска эпизода независимо от знака эффекта (в т.ч. пассивное фантазирование).
   *
   * @param {Object[]} frames — обычно результат EpisodicMemory_getFramesByResponseStimulus
   * @returns {Object|null}
   */
  function EpisodicMemory_pickBestFrameByAbsEffect(frames) {
    if (!Array.isArray(frames) || !frames.length) return null;
    var best = null;
    var bestAbs = -1;
    for (var i = 0; i < frames.length; i++) {
      var f = frames[i];
      if (!f || typeof f.effect !== 'number' || isNaN(f.effect)) continue;
      var aid = f.actionImageId != null ? parseInt(f.actionImageId, 10) : 0;
      if (!aid) continue;
      var ab = Math.abs(f.effect);
      if (best === null || ab > bestAbs) {
        bestAbs = ab;
        best = f;
      }
    }
    return best;
  }

  /**
   * Из массива кадров выбрать одно с максимальным положительным effect и ненулевым actionImageId.
   */
  function EpisodicMemory_pickBestPositiveFrame(frames) {
    if (!Array.isArray(frames) || !frames.length) return null;
    var best = null;
    var bestEff = 0;
    for (var i = 0; i < frames.length; i++) {
      var f = frames[i];
      if (!f || typeof f.effect !== 'number' || f.effect <= 0 || f.actionImageId == null || !f.actionImageId) continue;
      if (best === null || f.effect > bestEff) {
        bestEff = f.effect;
        best = f;
      }
    }
    return best;
  }

  /**
   * Как pickBestPositiveFrame, но только кадры с заданным стимулом (актуальный FinalImageId осмысления).
   */
  function EpisodicMemory_pickBestPositiveFrameForStimulus(frames, stimulusImageId) {
    stimulusImageId = parseInt(stimulusImageId, 10) || 0;
    if (!stimulusImageId || !Array.isArray(frames) || !frames.length) return null;
    var best = null;
    var bestEff = 0;
    for (var i = 0; i < frames.length; i++) {
      var f = frames[i];
      if (!f || (f.stimulusImageId || 0) !== stimulusImageId) continue;
      if (typeof f.effect !== 'number' || f.effect <= 0 || f.actionImageId == null || !f.actionImageId) continue;
      if (best === null || f.effect > bestEff) {
        bestEff = f.effect;
        best = f;
      }
    }
    return best;
  }

  /**
   * Найти в ленте Episodes цепочку кадров, соответствующую стеку образов цели (порядок в массиве Episodes).
   * goalIdsBottomToTop — элементы Target_rule_stack: [дно … вершина], как последовательность ходов/правил
   * (аналог партии: ход → ответ партнёра → следующий ход зависит от цепочки предыдущих кадров ЭП).
   * Кадры между шагами могут пропускаться: ищется первое подходящее правило, затем после него — следующее и т.д.
   * @param {number[]} goalIdsBottomToTop
   * @returns {Object[]|null} кадры-оригиналы из Episodes (те же объекты, что в массиве памяти)
   */
  function EpisodicMemory_findChainForGoalStack(goalIdsBottomToTop) {
    if (!Array.isArray(goalIdsBottomToTop) || !goalIdsBottomToTop.length) return null;
    if (!Array.isArray(global.Episodes) || !global.Episodes.length) return null;

    function signatureForGoalId(gid) {
      var id = parseInt(gid, 10) || 0;
      if (!id || typeof global.Goals_getGoalById !== 'function') return null;
      var g = global.Goals_getGoalById(id);
      if (!g) return null;
      var r = g.episodicRule;
      if (r && typeof r === 'object') {
        return {
          perceptionNodeId: r.perceptionNodeId || g.perceptionNodeId || 0,
          situationId: r.situationId != null ? r.situationId : (g.situationId || 0),
          stimulusImageId: r.stimulusImageId || g.stimulusImageId || 0,
          actionImageId: r.actionImageId != null ? r.actionImageId : g.actionImageId
        };
      }
      return {
        perceptionNodeId: g.perceptionNodeId || 0,
        situationId: g.situationId != null ? g.situationId : 0,
        stimulusImageId: g.stimulusImageId || 0,
        actionImageId: g.actionImageId != null ? g.actionImageId : 0
      };
    }

    function matches(f, sig) {
      if (!f || !sig) return false;
      if ((f.perceptionNodeId || 0) !== (sig.perceptionNodeId || 0)) return false;
      if ((sig.situationId || 0) !== 0 && (f.situationId || 0) !== (sig.situationId || 0)) return false;
      if ((sig.stimulusImageId || 0) !== 0 && (f.stimulusImageId || 0) !== (sig.stimulusImageId || 0)) return false;
      if ((sig.actionImageId || 0) !== 0 && (f.actionImageId || 0) !== (sig.actionImageId || 0)) return false;
      if (typeof f.effect !== 'number' || f.effect <= 0) return false;
      return true;
    }

    var sigs = [];
    for (var s = 0; s < goalIdsBottomToTop.length; s++) {
      var sg = signatureForGoalId(goalIdsBottomToTop[s]);
      if (!sg) return null;
      sigs.push(sg);
    }

    var ep = global.Episodes;
    var bestChain = null;
    var bestSum = -Infinity;

    for (var start = 0; start < ep.length; start++) {
      var idx = start;
      var matched = [];
      for (var j = 0; j < sigs.length; j++) {
        var found = false;
        while (idx < ep.length) {
          var fr = ep[idx];
          idx += 1;
          if (matches(fr, sigs[j])) {
            matched.push(fr);
            found = true;
            break;
          }
        }
        if (!found) {
          matched = null;
          break;
        }
      }
      if (matched && matched.length === sigs.length) {
        var sum = 0;
        for (var m = 0; m < matched.length; m++) sum += matched[m].effect || 0;
        if (sum > bestSum) {
          bestSum = sum;
          bestChain = matched.slice();
        }
      }
    }

    return bestChain;
  }

  /** Минимальное число подряд кадров с reaction (actionImageId) в суффиксе Episodes для приоритета цепочки. */
  var EPISODIC_CHAIN_PRIORITY_MIN_FRAMES = 2;
  global.EpisodicMemory_chainPriorityMinFrames = EPISODIC_CHAIN_PRIORITY_MIN_FRAMES;

  /**
   * Множество FinalImageId стимулов, входящих в «хвостовую» цепочку ЭП: подряд с конца массива кадры,
   * у каждого записана реакция (actionImageId). Учитываются stimulusImageId и responseStimulusImageId кадров.
   * Цепочка из одного кадра не считается — нужно ≥ EpisodicMemory_chainPriorityMinFrames (по умолчанию 2).
   * @returns {Object|null} объект-словарь { "id": true } или null, если цепочки нет
   */
  function EpisodicMemory_getChainPriorityStimulusSet() {
    var ep = global.Episodes;
    var minN = typeof global.EpisodicMemory_chainPriorityMinFrames === 'number' && global.EpisodicMemory_chainPriorityMinFrames >= 2
      ? global.EpisodicMemory_chainPriorityMinFrames
      : EPISODIC_CHAIN_PRIORITY_MIN_FRAMES;
    if (!Array.isArray(ep) || ep.length < minN) return null;
    var run = 0;
    var i = ep.length - 1;
    while (i >= 0) {
      var f = ep[i];
      if (!f || f.actionImageId == null || !f.actionImageId) break;
      run++;
      i--;
    }
    if (run < minN) return null;
    var set = Object.create(null);
    for (var j = ep.length - run; j < ep.length; j++) {
      var fr = ep[j];
      if (!fr) continue;
      if (fr.stimulusImageId) set[String(fr.stimulusImageId)] = true;
      if (fr.responseStimulusImageId) set[String(fr.responseStimulusImageId)] = true;
    }
    return set;
  }

  /**
   * Стимул (FinalImageId) принадлежит активной цепочке кадров ЭП с реакциями — для приоритета в ОР/ФО.
   */
  function EpisodicMemory_isStimulusEpisodicChainPriority(stimulusFinalImageId) {
    var id = parseInt(stimulusFinalImageId, 10) || 0;
    if (!id) return false;
    var s = EpisodicMemory_getChainPriorityStimulusSet();
    return !!(s && s[String(id)]);
  }

  global.EpisodicMemory_setLastFrameActionImageId = setLastFrameActionImageId;
  global.EpisodicMemory_setLastFrameActionFromActionId = setLastFrameActionFromActionId;
  global.EpisodicMemory_clearAll = clearAllEpisodicMemory;
  global.EpisodicMemory_serializeState = serializeState;
  global.EpisodicMemory_applyState = applyState;
  global.EpisodicMemory_response_waiting_period = response_waiting_period;
  global.EpisodicMemory_isInWaitingPeriod = isInWaitingPeriod;
  global.EpisodicMemory_closeLastFrameWithResponse = closeLastFrame;
  global.EpisodicMemory_onPulse = EpisodicMemory_onPulse;
  global.EpisodicMemory_barShowCompletePulsesLeft = 0;
  global.EpisodicMemory_getFramesByPerceptionAndSituation = EpisodicMemory_getFramesByPerceptionAndSituation;
  global.EpisodicMemory_getFramesByStimulus = EpisodicMemory_getFramesByStimulus;
  global.EpisodicMemory_getFramesByResponseStimulus = EpisodicMemory_getFramesByResponseStimulus;
  global.EpisodicMemory_pickBestFrameByAbsEffect = EpisodicMemory_pickBestFrameByAbsEffect;
  global.EpisodicMemory_pickBestPositiveFrame = EpisodicMemory_pickBestPositiveFrame;
  global.EpisodicMemory_pickBestPositiveFrameForStimulus = EpisodicMemory_pickBestPositiveFrameForStimulus;
  global.EpisodicMemory_findChainForGoalStack = EpisodicMemory_findChainForGoalStack;
  global.EpisodicMemory_getChainPriorityStimulusSet = EpisodicMemory_getChainPriorityStimulusSet;
  global.EpisodicMemory_isStimulusEpisodicChainPriority = EpisodicMemory_isStimulusEpisodicChainPriority;

  /**
   * «Пустой» кадр для доработки во сне (sleep_dream_process): есть стимул и ОД, нет ответного стимула и нет числового effect.
   */
  function EpisodicMemory_isEmptyFrameForSleep(f) {
    if (!f) return false;
    var stim = f.stimulusImageId || 0;
    var act = f.actionImageId != null ? parseInt(f.actionImageId, 10) : 0;
    if (stim <= 0 || act <= 0) return false;
    var rs = f.responseStimulusImageId != null ? parseInt(f.responseStimulusImageId, 10) : 0;
    if (rs > 0) return false;
    if (f.effect !== null && f.effect !== undefined) return false;
    return true;
  }

  /**
   * Индексы пустых кадров в Episodes от новых к старым — порядок перебора сновидений.
   */
  function EpisodicMemory_collectEmptyFrameIndicesNewestFirst() {
    var ep = global.Episodes;
    if (!Array.isArray(ep) || !ep.length) return [];
    var out = [];
    for (var i = ep.length - 1; i >= 0; i--) {
      if (EpisodicMemory_isEmptyFrameForSleep(ep[i])) out.push(i);
    }
    return out;
  }

  global.EpisodicMemory_isEmptyFrameForSleep = EpisodicMemory_isEmptyFrameForSleep;
  global.EpisodicMemory_collectEmptyFrameIndicesNewestFirst = EpisodicMemory_collectEmptyFrameIndicesNewestFirst;

  function EpisodicMemory_markDirty() {
    epDirty = true;
  }
  global.EpisodicMemory_markDirty = EpisodicMemory_markDirty;

})(window);
