const RECORDER_CONFIG = {
  consultationCompanionUrl: "http://127.0.0.1:8765",
  consultationChunkMs: 15000,
  ...(window.MEDTOOLS_CONFIG || {})
};
const DEFAULT_COMPANION_URL = RECORDER_CONFIG.consultationCompanionUrl || "http://127.0.0.1:8765";
const DEFAULT_CHUNK_MS = normalizeChunkMs(RECORDER_CONFIG.consultationChunkMs);
const LEGACY_SLOW_CHUNK_MS = 30000;
const CHUNK_OVERLAP_MS = 4000;
const WAV_SAMPLE_RATE = 16000;
const FLUSH_RETRY_MS = 10000;
const FETCH_TIMEOUT_MS = 45000;
const CHUNK_SEND_TIMEOUT_MS = 120000;
const FINISH_TIMEOUT_MS = 90000;
const STATUS_TIMEOUT_MS = 20000;
const HEALTH_TIMEOUT_MS = 15000;
const MIC_PREFLIGHT_SAMPLE_MS = 2200;
const TRANSCRIPTION_TEST_SAMPLE_MS = 8000;
const TRANSCRIPTION_TEST_POLL_MS = 1200;
const TRANSCRIPTION_TEST_TIMEOUT_MS = 120000;
const READINESS_FRESH_MS = 2 * 60 * 60 * 1000;
const TRANSCRIPTION_TEST_FILE_CHUNK_MS = 60000;
const TRANSCRIPTION_TEST_MAX_FILE_BYTES = 9.5 * 1024 * 1024;
const MIC_PREFLIGHT_ACTIVE_FRAME_DBFS = -46;
const MIC_PREFLIGHT_WARN_RMS_DBFS = -38;
const MIC_PREFLIGHT_WARN_ACTIVE_RATIO = 0.08;
const AUDIO_METER_INTERVAL_MS = 80;
const FIRST_QUIET_AUDIO_WARNING_MS = 7000;
const QUIET_AUDIO_WARNING_MS = 20000;
const QUIET_AUDIO_REPEAT_MS = 60000;
const AUDIBLE_LEVEL_PERCENT = 4;
const AUDIBLE_PEAK_PERCENT = 7;
const ENCODING_WAIT_MS = 15000;
const LONG_RECORDING_REMINDER_MINUTES = 45;
const LONG_RECORDING_REPEAT_MINUTES = 15;
const STOPPED_FINISH_REMINDER_MINUTES = 5;
const STOPPED_FINISH_REPEAT_MINUTES = 10;
const LOCAL_LONG_RECORDING_REMINDER_MS = LONG_RECORDING_REMINDER_MINUTES * 60 * 1000;
const LOCAL_LONG_RECORDING_REPEAT_MS = LONG_RECORDING_REPEAT_MINUTES * 60 * 1000;
const OFFLINE_NOTIFICATION_COOLDOWN_MS = 5 * 60 * 1000;
const SAFETY_SNAPSHOT_INTERVAL_MS = 2500;
const MIN_SAFETY_SNAPSHOT_MS = 1500;
const MEDIA_RECORDER_STALL_WARNING_MS = 12000;
const MEDIA_RECORDER_STALL_REPEAT_MS = 45000;
const MEDIA_RECORDER_STOP_TIMEOUT_MS = 4000;
const LIVE_RECORDER_MIME_TYPE = "audio/wav";
const ALLOW_MEDIA_RECORDER_LIVE_CHUNKS = false;
const DRAFT_REVIEW_SAVE_DEBOUNCE_MS = 350;
const FULL_WINDOW_SIZE = { width: 440, height: 680 };
const COMPACT_WINDOW_SIZE = { width: 390, height: 150 };
const RECORDER_URL_PARAMS = new URLSearchParams(window.location.search);
const RECORDER_EMBEDDED = RECORDER_URL_PARAMS.get("embedded") === "1";
const RECORDER_INITIAL_COMPACT = RECORDER_URL_PARAMS.get("compact") === "1";
const RECORDER_SAFE_BOOT = RECORDER_URL_PARAMS.get("safe") === "1";
const STORAGE_KEYS_RECORDER = {
  companionUrl: "medtools.consultation.companionUrl",
  chunkMs: "medtools.consultation.chunkMs",
  microphoneDeviceId: "medtools.consultation.microphoneDeviceId"
};
const LEGACY_STORAGE_KEYS_RECORDER = [
  "medtools.consultation.localSecret",
  "medtools.consultation.companionUrl"
];
const LICENSE_STORAGE_KEYS = {
  deviceId: "medtools.deviceId",
  license: "medtools.license"
};
const RUNTIME_STATE_KEY = "medtools.consultation.runtimeState";
const LONG_RECORDING_ALARM = "medtools.consultation.longRecording";
const STOPPED_FINISH_ALARM = "medtools.consultation.stoppedFinish";
const RECORDER_DB_NAME = "medtools-consultation-recorder";
const RECORDER_DB_VERSION = 3;
const QUEUE_STORE = "queuedChunks";
const STATE_STORE = "sessionState";
const SAFETY_STORE = "safetySnapshots";
const ARCHIVE_STORE = "recoveryArchives";
const MAX_RECOVERY_ARCHIVES = 5;

const elsRecorder = {};
let mediaStream = null;
let audioContext = null;
let audioSource = null;
let audioAnalyser = null;
let pcmProcessor = null;
let pcmSilentGain = null;
let audioMeterTimer = null;
let mediaRecorder = null;
let mediaRecorderStopPromise = null;
let mediaRecorderStartedAtMs = 0;
let lastMediaRecorderChunkEndMs = 0;
let mediaRecorderFragments = [];
let mediaRecorderBufferStartMs = 0;
let mediaRecorderBufferEndMs = 0;
let mediaRecorderBufferBytes = 0;
let lastMediaRecorderRequestAt = 0;
let lastMediaRecorderFragmentAt = 0;
let lastMediaRecorderStallWarningAt = 0;
let pcmFallbackMode = false;
let lastPcmFallbackAt = 0;
let recordingActive = false;
let startInProgress = false;
let startCancelRequested = false;
let pcmBuffers = [];
let pcmTotalSamples = 0;
let pcmStartSample = 0;
let emittedChunkCount = 0;
let sessionId = "";
let chunkIndex = 0;
let startedAtMs = 0;
let lastKnownRecordingSeconds = 0;
let durationTimer = null;
let pollTimer = null;
let flushTimer = null;
let flushInProgress = false;
let latestTranscript = "";
let latestSegments = [];
let latestDraft = null;
let latestMetrics = null;
let chunkMs = DEFAULT_CHUNK_MS;
let startPayload = null;
let remoteSessionStarted = false;
let pendingChunks = [];
let queueBlockedReason = "";
let finishRequested = false;
let clientFinishedAt = "";
let consultationFinished = false;
let healthTimer = null;
let lastServerOkAt = 0;
let lastTranscriptLength = 0;
let lastAutoFillDraftSignature = "";
let autoFillEnabled = false;
let appliedLakmusValues = {};
let draftReviewValues = {};
let draftReviewSaveTimer = null;
let selectedMicrophoneDeviceId = "";
let smoothedAudioLevel = 0;
let lastAudioMeterAt = 0;
let lastAudibleAudioAt = 0;
let lastQuietAudioWarningAt = 0;
let encodingTasks = new Set();
let longRecordingReminderTimer = null;
let lastOfflineNotificationAt = 0;
let safetySnapshotTimer = null;
let latestSafetySave = null;
let lastQueuedAudioInfo = null;
let lastAudioSignalInfo = null;
let lastReadinessCheck = null;
let compactMode = false;
let replayAudio = null;
let transcriptionTestActive = false;
let firstTranscriptRevealed = false;
let hostStatusState = {
  statusClass: "idle",
  statusLabel: "Очікує",
  duration: "00:00",
  audioLevel: 0,
  microphoneLabel: "мікрофон не вибрано",
  recordedPercent: 0,
  processedPercent: 0,
  processedLabel: "00:00 / 00:00",
  recordedLabel: "00:00",
  transcribedLabel: "00:00"
};

document.addEventListener("DOMContentLoaded", initRecorder);
window.addEventListener("message", handleEmbeddedHostMessage);
window.addEventListener("pagehide", handleRecorderPageHide);
document.addEventListener("visibilitychange", handleRecorderVisibilityChange);
window.addEventListener("beforeunload", (event) => {
  if (hasUnsafeRecorderState()) {
    event.preventDefault();
    event.returnValue = "";
  }
});

async function initRecorder() {
  cacheRecorderElements();
  bindRecorderEvents();
  document.body.classList.toggle("embedded-mode", RECORDER_EMBEDDED);
  if (RECORDER_INITIAL_COMPACT) {
    setCompactMode(true);
  } else if (RECORDER_EMBEDDED) {
    notifyEmbeddedHost({ type: "RESIZE", compactMode: false });
  }
  await restoreRecorderSettings();
  const restoredState = RECORDER_SAFE_BOOT ? "idle" : await restorePendingSession();
  if (RECORDER_SAFE_BOOT) {
    setStatus("Безпечний запуск: стару чергу не завантажено. Можна протестувати новий запис.");
  }
  renderReadinessStatus();
  renderRecorderState(restoredState || "idle");
}

function cacheRecorderElements() {
  elsRecorder.chunkMs = document.getElementById("chunkMs");
  elsRecorder.collapseButton = document.getElementById("collapseButton");
  elsRecorder.hideButton = document.getElementById("hideButton");
  elsRecorder.checkServerButton = document.getElementById("checkServerButton");
  elsRecorder.micTestButton = document.getElementById("micTestButton");
  elsRecorder.startButton = document.getElementById("startButton");
  elsRecorder.testTranscriptionButton = document.getElementById("testTranscriptionButton");
  elsRecorder.testFileButton = document.getElementById("testFileButton");
  elsRecorder.testFileInput = document.getElementById("testFileInput");
  elsRecorder.stopButton = document.getElementById("stopButton");
  elsRecorder.finishButton = document.getElementById("finishButton");
  elsRecorder.exportButton = document.getElementById("exportButton");
  elsRecorder.showReviewButton = document.getElementById("showReviewButton");
  elsRecorder.copyRecorderDiagnosticsButton = document.getElementById("copyRecorderDiagnosticsButton");
  elsRecorder.clearStaleQueueButton = document.getElementById("clearStaleQueueButton");
  elsRecorder.fillLakmusButton = document.getElementById("fillLakmusButton");
  elsRecorder.checkLakmusFieldsButton = document.getElementById("checkLakmusFieldsButton");
  elsRecorder.copyDraftButton = document.getElementById("copyDraftButton");
  elsRecorder.saveCorrectionsButton = document.getElementById("saveCorrectionsButton");
  elsRecorder.copyTranscriptButton = document.getElementById("copyTranscriptButton");
  elsRecorder.autoFillLakmus = document.getElementById("autoFillLakmus");
  elsRecorder.statusLine = document.getElementById("statusLine");
  elsRecorder.readinessStatus = document.getElementById("readinessStatus");
  elsRecorder.safetyStatusText = document.getElementById("safetyStatusText");
  elsRecorder.eventLog = document.getElementById("eventLog");
  elsRecorder.liveTranscriptPreview = document.getElementById("liveTranscriptPreview");
  elsRecorder.liveTranscriptPreviewMeta = document.getElementById("liveTranscriptPreviewMeta");
  elsRecorder.liveTranscriptPreviewText = document.getElementById("liveTranscriptPreviewText");
  elsRecorder.audioLevelText = document.getElementById("audioLevelText");
  elsRecorder.audioSignalVerdict = document.getElementById("audioSignalVerdict");
  elsRecorder.audioLevelBar = document.getElementById("audioLevelBar");
  elsRecorder.audioPeakMark = document.getElementById("audioPeakMark");
  elsRecorder.microphoneSelect = document.getElementById("microphoneSelect");
  elsRecorder.refreshMicrophonesButton = document.getElementById("refreshMicrophonesButton");
  elsRecorder.activeMicrophoneName = document.getElementById("activeMicrophoneName");
  elsRecorder.compactStatus = document.getElementById("compactStatus");
  elsRecorder.compactDuration = document.getElementById("compactDuration");
  elsRecorder.compactAudioBar = document.getElementById("compactAudioBar");
  elsRecorder.compactRecordingBar = document.getElementById("compactRecordingBar");
  elsRecorder.compactTranscribedBar = document.getElementById("compactTranscribedBar");
  elsRecorder.compactMicrophoneName = document.getElementById("compactMicrophoneName");
  elsRecorder.recordingBadge = document.getElementById("recordingBadge");
  elsRecorder.serverState = document.getElementById("serverState");
  elsRecorder.chunkCount = document.getElementById("chunkCount");
  elsRecorder.pendingCount = document.getElementById("pendingCount");
  elsRecorder.segmentCount = document.getElementById("segmentCount");
  elsRecorder.durationValue = document.getElementById("durationValue");
  elsRecorder.processingProgressText = document.getElementById("processingProgressText");
  elsRecorder.recordingProgressText = document.getElementById("recordingProgressText");
  elsRecorder.transcribedProgressText = document.getElementById("transcribedProgressText");
  elsRecorder.recordingProgressBar = document.getElementById("recordingProgressBar");
  elsRecorder.transcribedProgressBar = document.getElementById("transcribedProgressBar");
  elsRecorder.chunkPipelineStatus = document.getElementById("chunkPipelineStatus");
  elsRecorder.qualitySummary = document.getElementById("qualitySummary");
  elsRecorder.processingState = document.getElementById("processingState");
  elsRecorder.transcriptDetails = document.getElementById("transcriptDetails");
  elsRecorder.transcriptOutput = document.getElementById("transcriptOutput");
  elsRecorder.draftAdvice = document.getElementById("draftAdvice");
  elsRecorder.draftEvidence = document.getElementById("draftEvidence");
  elsRecorder.draftInputs = Array.from(document.querySelectorAll("[data-draft-key]"));
}

function bindRecorderEvents() {
  elsRecorder.collapseButton.addEventListener("click", toggleCompactMode);
  elsRecorder.hideButton.addEventListener("click", hideRecorderWindow);
  elsRecorder.checkServerButton.addEventListener("click", checkServer);
  elsRecorder.micTestButton.addEventListener("click", runMicrophoneSignalTest);
  elsRecorder.startButton.addEventListener("click", startRecording);
  elsRecorder.testTranscriptionButton.addEventListener("click", runTranscriptionTest);
  elsRecorder.testFileButton.addEventListener("click", chooseTranscriptionTestFile);
  elsRecorder.testFileInput.addEventListener("change", runFileTranscriptionTest);
  elsRecorder.stopButton.addEventListener("click", stopRecording);
  elsRecorder.finishButton.addEventListener("click", finishConsultation);
  elsRecorder.exportButton.addEventListener("click", exportTranscript);
  elsRecorder.showReviewButton.addEventListener("click", showTranscriptAndFields);
  elsRecorder.copyRecorderDiagnosticsButton.addEventListener("click", copyRecorderDiagnostics);
  elsRecorder.clearStaleQueueButton.addEventListener("click", clearStaleQueueWithConfirmation);
  elsRecorder.fillLakmusButton.addEventListener("click", fillVisibleLakmusFields);
  elsRecorder.checkLakmusFieldsButton.addEventListener("click", checkVisibleLakmusFields);
  elsRecorder.copyDraftButton.addEventListener("click", copyLakmusDraft);
  elsRecorder.saveCorrectionsButton?.addEventListener("click", saveDraftCorrections);
  elsRecorder.copyTranscriptButton.addEventListener("click", copyFullTranscript);
  elsRecorder.refreshMicrophonesButton.addEventListener("click", () => refreshMicrophoneDevices({ requestLabels: true }));
  elsRecorder.microphoneSelect.addEventListener("change", handleMicrophoneSelectionChange);
  for (const input of elsRecorder.draftInputs || []) {
    input.addEventListener("input", handleDraftReviewInput);
  }
  elsRecorder.autoFillLakmus.addEventListener("change", () => {
    autoFillEnabled = elsRecorder.autoFillLakmus.checked;
    if (autoFillEnabled) {
      maybeAutoFillVisibleLakmusFields({ force: true }).catch(() => {});
    }
  });
  if (navigator.mediaDevices?.addEventListener) {
    navigator.mediaDevices.addEventListener("devicechange", () => {
      refreshMicrophoneDevices({ keepSelection: true }).catch(() => {});
    });
  }
}

function toggleCompactMode() {
  setCompactMode(!compactMode);
}

function setCompactMode(nextCompactMode) {
  compactMode = Boolean(nextCompactMode);
  document.body.classList.toggle("compact-mode", compactMode);
  elsRecorder.collapseButton.textContent = compactMode ? "Розгорнути" : "Згорнути";
  notifyEmbeddedHost({ type: "RESIZE", compactMode });
  if (RECORDER_EMBEDDED) {
    return;
  }
  if (chrome.windows?.getCurrent && chrome.windows?.update) {
    chrome.windows.getCurrent((currentWindow) => {
      if (!currentWindow?.id) {
        return;
      }
      chrome.windows.update(currentWindow.id, compactMode ? COMPACT_WINDOW_SIZE : FULL_WINDOW_SIZE);
    });
  }
}

function hideRecorderWindow() {
  if (RECORDER_EMBEDDED) {
    notifyEmbeddedHost({ type: "HIDE" });
    return;
  }
  setCompactMode(true);
  setStatus("Вікно згорнуто. Запис і черга продовжують працювати.");
}

function handleEmbeddedHostMessage(event) {
  if (!RECORDER_EMBEDDED) {
    return;
  }
  const data = event.data || {};
  if (data.source !== "MEDTOOLS_LAKMUS_HOST") {
    return;
  }
  if (data.type === "SET_COMPACT") {
    setCompactMode(Boolean(data.compact));
  }
}

function notifyEmbeddedHost(payload) {
  if (!RECORDER_EMBEDDED || window.parent === window) {
    return;
  }
  window.parent.postMessage({
    source: "MEDTOOLS_RECORDER",
    ...payload
  }, "*");
}

function notifyHostStatus(partial = {}) {
  hostStatusState = { ...hostStatusState, ...partial };
  notifyEmbeddedHost({
    type: "STATUS",
    ...hostStatusState
  });
}

async function restoreRecorderSettings() {
  const values = await chrome.storage.local.get(Object.values(STORAGE_KEYS_RECORDER));
  const restoredChunkMs = normalizeRestoredChunkMs(values[STORAGE_KEYS_RECORDER.chunkMs]);
  elsRecorder.chunkMs.value = String(restoredChunkMs);
  if (restoredChunkMs !== values[STORAGE_KEYS_RECORDER.chunkMs]) {
    await chrome.storage.local.set({ [STORAGE_KEYS_RECORDER.chunkMs]: restoredChunkMs });
  }
  selectedMicrophoneDeviceId = String(values[STORAGE_KEYS_RECORDER.microphoneDeviceId] || "");
  await refreshMicrophoneDevices({ keepSelection: true }).catch(() => {});
  await chrome.storage.local.remove(LEGACY_STORAGE_KEYS_RECORDER);
}

async function saveRecorderSettings() {
  chunkMs = selectedChunkMs();
  await chrome.storage.local.set({
    [STORAGE_KEYS_RECORDER.chunkMs]: chunkMs,
    [STORAGE_KEYS_RECORDER.microphoneDeviceId]: selectedMicrophoneDeviceId
  });
}

async function refreshMicrophoneDevices(options = {}) {
  if (!navigator.mediaDevices?.enumerateDevices || !elsRecorder.microphoneSelect) {
    setActiveMicrophoneText("вибір мікрофона недоступний");
    return;
  }
  if (options.requestLabels && !recordingActive) {
    await requestTemporaryMicrophoneAccessForLabels().catch(() => {});
  }
  const devices = await navigator.mediaDevices.enumerateDevices();
  const microphones = devices.filter((device) => device.kind === "audioinput");
  const selectedValue = options.keepSelection ? selectedMicrophoneDeviceId : elsRecorder.microphoneSelect.value;
  elsRecorder.microphoneSelect.textContent = "";
  elsRecorder.microphoneSelect.append(new Option("Системний мікрофон", ""));
  microphones.forEach((device, index) => {
    elsRecorder.microphoneSelect.append(new Option(device.label || `Мікрофон ${index + 1}`, device.deviceId));
  });
  if (selectedValue && !microphones.some((device) => device.deviceId === selectedValue)) {
    elsRecorder.microphoneSelect.append(new Option("Раніше вибраний мікрофон недоступний", selectedValue));
  }
  selectedMicrophoneDeviceId = selectedValue || "";
  elsRecorder.microphoneSelect.value = selectedMicrophoneDeviceId;
  updateSelectedMicrophoneText(microphones);
}

async function requestTemporaryMicrophoneAccessForLabels() {
  const stream = await navigator.mediaDevices.getUserMedia({ audio: true, video: false });
  for (const track of stream.getTracks()) {
    track.stop();
  }
}

async function handleMicrophoneSelectionChange() {
  if (recordingActive) {
    elsRecorder.microphoneSelect.value = selectedMicrophoneDeviceId;
    setStatus("Мікрофон можна змінити перед новим записом.", true);
    return;
  }
  selectedMicrophoneDeviceId = elsRecorder.microphoneSelect.value || "";
  markReadiness("wait", "мікрофон змінено, повторіть перевірку готовності", { reset: true });
  await saveRecorderSettings();
  await refreshMicrophoneDevices({ keepSelection: true }).catch(() => {});
}

async function requestRecordingMicrophoneStream() {
  const audio = {
    channelCount: 1,
    echoCancellation: true,
    noiseSuppression: true,
    autoGainControl: true
  };
  if (selectedMicrophoneDeviceId) {
    audio.deviceId = { exact: selectedMicrophoneDeviceId };
  }
  try {
    return await navigator.mediaDevices.getUserMedia({ audio, video: false });
  } catch (error) {
    if (!selectedMicrophoneDeviceId) {
      throw error;
    }
    setStatus("Вибраний мікрофон недоступний. Пробую системний мікрофон.", true);
    selectedMicrophoneDeviceId = "";
    await saveRecorderSettings().catch(() => {});
    await refreshMicrophoneDevices({ keepSelection: true }).catch(() => {});
    return navigator.mediaDevices.getUserMedia({
      audio: {
        channelCount: 1,
        echoCancellation: true,
        noiseSuppression: true,
        autoGainControl: true
      },
      video: false
    });
  }
}

function updateSelectedMicrophoneText(microphones = []) {
  if (recordingActive) {
    updateActiveMicrophoneFromStream();
    return;
  }
  const selected = microphones.find((device) => device.deviceId === selectedMicrophoneDeviceId);
  const label = selected?.label || (selectedMicrophoneDeviceId ? "вибраний мікрофон" : "системний мікрофон");
  const labelsAvailable = microphones.some((device) => Boolean(device.label));
  if (!labelsAvailable && microphones.length) {
    setActiveMicrophoneText(`Вибрано: ${label} (${microphones.length} підкл., натисніть "Перевірити" для назви)`);
    return;
  }
  setActiveMicrophoneText(`Вибрано: ${label}`);
}

function updateActiveMicrophoneFromStream() {
  const track = mediaStream?.getAudioTracks?.()[0];
  const label = track?.label || selectedMicrophoneLabel() || "мікрофон активний";
  setActiveMicrophoneText(`Активний: ${label}`);
}

function selectedMicrophoneLabel() {
  const option = elsRecorder.microphoneSelect?.selectedOptions?.[0];
  return option?.textContent || "";
}

function setActiveMicrophoneText(value) {
  const text = String(value || "мікрофон не вибрано").trim();
  if (elsRecorder.activeMicrophoneName) {
    elsRecorder.activeMicrophoneName.textContent = text;
    elsRecorder.activeMicrophoneName.title = text;
  }
  if (elsRecorder.compactMicrophoneName) {
    elsRecorder.compactMicrophoneName.textContent = text;
    elsRecorder.compactMicrophoneName.title = text;
  }
  notifyHostStatus({ microphoneLabel: text });
}

function markSafetySaved(label, at = new Date().toISOString()) {
  latestSafetySave = {
    label,
    at
  };
  renderSafetyStatus();
}

function renderSafetyStatus() {
  if (!elsRecorder.safetyStatusText) {
    return;
  }
  if (!latestSafetySave) {
    elsRecorder.safetyStatusText.textContent = "очікує старту";
    return;
  }
  const time = new Date(latestSafetySave.at).toLocaleTimeString("uk-UA", {
    hour: "2-digit",
    minute: "2-digit",
    second: "2-digit"
  });
  elsRecorder.safetyStatusText.textContent = `${latestSafetySave.label} ${time}`;
}

async function checkServer() {
  setStatus("Перевіряю сервер транскрипції і мікрофон. Скажіть кілька слів для тесту звуку...");
  const [serverResult, microphoneResult] = await Promise.allSettled([
    checkCompanionReadiness(),
    checkMicrophoneReadiness()
  ]);
  const messages = [];
  let hasWarning = false;
  const serverData = serverResult.status === "fulfilled" ? serverResult.value : null;

  if (serverData?.ok) {
    if (serverData.whisper?.ready && serverData.draftMapper?.ready) {
      messages.push("Сервер готовий, Whisper і mapper знайдені.");
    } else if (!serverData.whisper?.ready) {
      messages.push("Сервер відповідає, але Whisper або модель не знайдені.");
    } else {
      messages.push("Сервер відповідає, але mapper для полів Лакмуса не пройшов self-test.");
    }
    hasWarning = hasWarning || !serverData.whisper?.ready;
    hasWarning = hasWarning || !serverData.draftMapper?.ready;
  } else {
    messages.push("Сервер транскрипції недоступний. Запис можна почати, чанки чекатимуть у черзі.");
    hasWarning = true;
  }

  const microphoneData = microphoneResult.status === "fulfilled" ? microphoneResult.value : { ok: false, message: "Мікрофон не перевірено." };
  if (microphoneData.ok) {
    messages.push(microphoneData.message || `Мікрофон готовий: ${microphoneData.label}.`);
    hasWarning = hasWarning || Boolean(microphoneData.warning);
  } else {
    messages.push(microphoneData.message || "Мікрофон не готовий.");
    hasWarning = true;
  }

  await saveRecorderSettings();
  if (sessionId && (livePendingCount() || finishRequested) && !queueBlockedReason) {
    scheduleFlush(0);
  }
  if (queueBlockedReason) {
    messages.push(`Попередня черга заблокована: ${queueBlockedReason}`);
    hasWarning = true;
  }
  setStatus(messages.join(" "), hasWarning);
}

async function runMicrophoneSignalTest() {
  if (recordingActive) {
    setStatus("Запис уже триває. Дивіться на рядок під смужкою мікрофона: там має бути видно, чи Chrome чує голос.");
    return;
  }
  if (finishRequested || livePendingCount() || encodingTasks.size) {
    setStatus("Тест мікрофона доступний тільки коли немає активної черги. Завершіть або експортуйте попередній запис.", true);
    return;
  }
  if (transcriptionTestActive) {
    return;
  }
  transcriptionTestActive = true;
  renderRecorderState("starting");
  try {
    setStatus("Тест мікрофона: скажіть 2-3 чіткі слова біля вибраного мікрофона...");
    const result = await checkMicrophoneReadiness();
    if (result.ok) {
      setStatus(result.message || "Мікрофон перевірено.", Boolean(result.warning));
    } else {
      setStatus(result.message || "Мікрофон не готовий.", true);
    }
  } finally {
    transcriptionTestActive = false;
    renderRecorderState("idle");
  }
}

async function checkCompanionReadiness() {
  const response = await fetchWithTimeout(`${companionBaseUrl()}/v1/health`, { cache: "no-store" }, HEALTH_TIMEOUT_MS);
  const data = await response.json();
  if (!response.ok || !data.ok) {
    throw new Error(data.message || "Сервер відповів з помилкою.");
  }
  return data;
}

async function checkMicrophoneReadiness() {
  if (recordingActive) {
    return { ok: true, label: selectedMicrophoneLabel() || "активний мікрофон" };
  }
  if (!navigator.mediaDevices?.getUserMedia) {
    setActiveMicrophoneText("мікрофон недоступний у цьому браузері");
    return { ok: false, message: "Мікрофон недоступний у цьому браузері." };
  }
  try {
    const stream = await requestRecordingMicrophoneStream();
    const track = stream.getAudioTracks()[0];
    const label = track?.label || selectedMicrophoneLabel() || "системний мікрофон";
    let signal = null;
    try {
      signal = await analyzeMicrophoneSignal(stream, MIC_PREFLIGHT_SAMPLE_MS);
    } finally {
      for (const item of stream.getTracks()) {
        item.stop();
      }
    }
    await refreshMicrophoneDevices({ keepSelection: true }).catch(() => {});
    const quality = microphoneSignalQuality(signal);
    showMicrophoneSignalResult(signal, quality, label);
    const statusText = quality.warning ? `Увага: ${label}` : `Готовий: ${label}`;
    setActiveMicrophoneText(statusText);
    return {
      ok: true,
      warning: quality.warning,
      label,
      signal,
      message: `Мікрофон готовий: ${label}. ${quality.message}`
    };
  } catch (error) {
    setActiveMicrophoneText("мікрофон не готовий");
    return { ok: false, message: microphoneUserMessage(error) };
  }
}

async function runTranscriptionTest() {
  if (recordingActive || finishRequested || livePendingCount() || encodingTasks.size) {
    setStatus("Перевірка готовності доступна тільки коли немає активного запису або черги.", true);
    return;
  }
  if (transcriptionTestActive) {
    return;
  }
  transcriptionTestActive = true;
  renderRecorderState("starting");
  let stream = null;
  try {
    setStatus("Перевірка готовності: говоріть 8 секунд чітко біля вибраного мікрофона...");
    stream = await requestRecordingMicrophoneStream();
    const track = stream.getAudioTracks()[0];
    const label = track?.label || selectedMicrophoneLabel() || "системний мікрофон";
    setActiveMicrophoneText(`Тест: ${label}`);
    const sample = await recordMicrophoneTestWav(stream, TRANSCRIPTION_TEST_SAMPLE_MS);
    const quality = microphoneSignalQuality(sample.signal);
    showMicrophoneSignalResult(sample.signal, quality, label);
    setStatus(`Тест записано. ${quality.message} Відправляю короткий фрагмент у Whisper...`, quality.warning);

    const session = await runSingleChunkTranscriptionTest({
      blob: sample.blob,
      source: "readiness_microphone_test",
      chunkMs: TRANSCRIPTION_TEST_SAMPLE_MS,
      statusPrefix: "Перевірка готовності"
    });
    showTranscriptionTestResult(session, quality);
  } catch (error) {
    markReadiness("bad", `перевірка не вдалась: ${companionUserMessage(error)}`);
    setStatus(`Перевірка готовності не вдалась: ${companionUserMessage(error)}`, true);
  } finally {
    if (stream) {
      for (const track of stream.getTracks()) {
        track.stop();
      }
    }
    transcriptionTestActive = false;
    await refreshMicrophoneDevices({ keepSelection: true }).catch(() => {});
    renderRecorderState("idle");
  }
}

function chooseTranscriptionTestFile() {
  if (recordingActive || finishRequested || livePendingCount() || encodingTasks.size) {
    setStatus("Тест без мікрофона доступний тільки коли немає активного запису або черги.", true);
    return;
  }
  if (transcriptionTestActive) {
    return;
  }
  elsRecorder.testFileInput.value = "";
  elsRecorder.testFileInput.click();
}

async function runFileTranscriptionTest(event) {
  const file = event.target.files?.[0] || null;
  event.target.value = "";
  if (!file) {
    return;
  }
  if (recordingActive || finishRequested || livePendingCount() || encodingTasks.size) {
    setStatus("Тест без мікрофона доступний тільки коли немає активного запису або черги.", true);
    return;
  }
  if (transcriptionTestActive) {
    return;
  }
  transcriptionTestActive = true;
  renderRecorderState("starting");
  try {
    validateTranscriptionTestFile(file);
    setStatus(`Тест без мікрофона: відправляю ${file.name} (${formatBytes(file.size)}) у Whisper...`);
    const session = await runSingleChunkTranscriptionTest({
      blob: file,
      source: "file_test",
      chunkMs: TRANSCRIPTION_TEST_FILE_CHUNK_MS,
      statusPrefix: "Тест без мікрофона"
    });
    showFileTranscriptionTestResult(session, file);
  } catch (error) {
    setStatus(`Тест без мікрофона не вдався: ${companionUserMessage(error)}`, true);
  } finally {
    transcriptionTestActive = false;
    renderRecorderState("idle");
  }
}

function validateTranscriptionTestFile(file) {
  if (!file || !file.size) {
    throw new Error("Файл порожній або не прочитався.");
  }
  if (file.size > TRANSCRIPTION_TEST_MAX_FILE_BYTES) {
    throw new Error(`Файл завеликий для швидкого тесту (${formatBytes(file.size)}). Виберіть короткий аудіо/відеофрагмент до ${formatBytes(TRANSCRIPTION_TEST_MAX_FILE_BYTES)}.`);
  }
  const type = String(file.type || "").toLowerCase();
  const name = String(file.name || "").toLowerCase();
  const looksSupported = (
    type.startsWith("audio/") ||
    type === "video/mp4" ||
    type === "video/webm" ||
    type === "video/quicktime" ||
    /\.(wav|webm|m4a|mp3|mp4|mov|ogg)$/i.test(name)
  );
  if (!looksSupported) {
    throw new Error("Підтримуються короткі audio/video файли: wav, webm, m4a, mp3, mp4, mov, ogg.");
  }
}

async function runSingleChunkTranscriptionTest({ blob, source, chunkMs: testChunkMs, statusPrefix }) {
  const auth = await getLicenseAuth();
  const testSessionId = makeClientSessionId();
  const clientStartedAt = new Date().toISOString();
  const fileTest = source === "file_test";
  await callCompanion("/v1/session/start", {
    clientSessionId: testSessionId,
    auth,
    language: "uk",
    source,
    chunkMs: normalizeChunkMs(testChunkMs || TRANSCRIPTION_TEST_SAMPLE_MS),
    chunkOverlapMs: 0,
    clientStartedAt,
    extensionVersion: chrome.runtime.getManifest().version,
    audioPipeline: fileTest ? "file_upload_test" : "webaudio_pcm_wav",
    liveChunkPolicy: fileTest ? "not_live_file_test" : "wav_only"
  });
  await callCompanion("/v1/session/chunk", {
    sessionId: testSessionId,
    chunkIndex: 0,
    mimeType: blob.type || mimeTypeFromFileName(blob.name) || "audio/webm",
    audioBase64: await blobToBase64(blob),
    offsetSeconds: 0,
    nominalStartSeconds: 0,
    overlapSeconds: 0,
    finalChunk: true,
    auth,
    capturedAt: new Date().toISOString()
  });
  await callCompanion("/v1/session/finish", {
    sessionId: testSessionId,
    auth,
    clientFinishedAt: new Date().toISOString()
  });
  return waitForTranscriptionTestSession(testSessionId, auth, statusPrefix);
}

async function recordMicrophoneTestWav(stream, durationMs) {
  const AudioContextClass = window.AudioContext || window.webkitAudioContext;
  if (!AudioContextClass) {
    throw new Error("AudioContext недоступний у цьому браузері.");
  }
  const context = new AudioContextClass();
  await context.resume();
  return new Promise((resolve, reject) => {
    let source = null;
    let processor = null;
    let silentGain = null;
    let finishTimer = null;
    let settled = false;
    const pcmChunks = [];
    let totalSamples = 0;
    let totalSquares = 0;
    let peak = 0;
    let frameCount = 0;
    let activeFrameCount = 0;
    const requestedDurationMs = Math.max(1500, Number(durationMs) || TRANSCRIPTION_TEST_SAMPLE_MS);

    const cleanup = () => {
      if (finishTimer) {
        clearTimeout(finishTimer);
        finishTimer = null;
      }
      if (processor) {
        processor.onaudioprocess = null;
        processor.disconnect();
        processor = null;
      }
      if (silentGain) {
        silentGain.disconnect();
        silentGain = null;
      }
      if (source) {
        source.disconnect();
        source = null;
      }
      context.close().catch(() => {});
    };

    const settleError = (error) => {
      if (settled) {
        return;
      }
      settled = true;
      cleanup();
      reject(error);
    };

    const finish = () => {
      if (settled) {
        return;
      }
      settled = true;
      const sourceSampleRate = context.sampleRate;
      cleanup();
      if (!totalSamples || !pcmChunks.length) {
        reject(new Error("Тестовий WebAudio-фрагмент порожній."));
        return;
      }
      const pcm = mergeFloat32Buffers(pcmChunks, totalSamples);
      const wavSamples = resampleLinear(pcm, sourceSampleRate, WAV_SAMPLE_RATE);
      const blob = encodeWavBlob(wavSamples, WAV_SAMPLE_RATE);
      if (blob.type !== "audio/wav") {
        reject(new Error("Тестовий WebAudio-фрагмент має некоректний WAV формат."));
        return;
      }
      const rms = Math.sqrt(totalSquares / Math.max(1, totalSamples));
      resolve({
        blob,
        signal: {
          durationMs: requestedDurationMs,
          samples: totalSamples,
          rmsDb: amplitudeToDbfs(rms),
          peakDb: amplitudeToDbfs(peak),
          activeFrameRatio: frameCount ? activeFrameCount / frameCount : 0
        }
      });
    };

    try {
      if (typeof context.createScriptProcessor !== "function") {
        throw new Error("WebAudio PCM-запис недоступний у цьому браузері.");
      }
      source = context.createMediaStreamSource(stream);
      processor = context.createScriptProcessor(4096, 1, 1);
      silentGain = context.createGain();
      silentGain.gain.value = 0;
      processor.onaudioprocess = (event) => {
        if (settled) {
          return;
        }
        const input = event.inputBuffer?.getChannelData?.(0);
        if (!input?.length) {
          return;
        }
        const copy = new Float32Array(input.length);
        copy.set(input);
        pcmChunks.push(copy);
        let frameSquares = 0;
        for (let index = 0; index < input.length; index += 1) {
          const value = Math.abs(input[index]);
          frameSquares += value * value;
          if (value > peak) {
            peak = value;
          }
        }
        totalSamples += input.length;
        totalSquares += frameSquares;
        frameCount += 1;
        const frameRms = Math.sqrt(frameSquares / Math.max(1, input.length));
        if (amplitudeToDbfs(frameRms) >= MIC_PREFLIGHT_ACTIVE_FRAME_DBFS) {
          activeFrameCount += 1;
        }
      };
      source.connect(processor);
      processor.connect(silentGain);
      silentGain.connect(context.destination);
      finishTimer = setTimeout(finish, requestedDurationMs);
    } catch (error) {
      cleanup();
      reject(error);
    }
  });
}

function mergeFloat32Buffers(buffers, totalSamples) {
  const output = new Float32Array(Math.max(0, Number(totalSamples) || 0));
  let offset = 0;
  for (const buffer of buffers) {
    output.set(buffer, offset);
    offset += buffer.length;
  }
  return output;
}

async function waitForTranscriptionTestSession(testSessionId, auth, statusPrefix = "Перевірка готовності") {
  const started = Date.now();
  let lastSession = null;
  while (Date.now() - started < TRANSCRIPTION_TEST_TIMEOUT_MS) {
    await sleep(TRANSCRIPTION_TEST_POLL_MS);
    const result = await callCompanion("/v1/session/status", {
      sessionId: testSessionId,
      auth
    });
    lastSession = result.session || null;
    const pending = Number(lastSession?.pendingChunkCount || 0);
    const done = Number(lastSession?.doneChunkCount || 0);
    const errors = Number(lastSession?.errorCount || 0);
    if (pending === 0 && done > 0) {
      return lastSession;
    }
    if (errors > 0 && pending === 0) {
      return lastSession;
    }
    setStatus(`${statusPrefix}: Whisper ще обробляє фрагмент (${Math.round((Date.now() - started) / 1000)}с)...`);
  }
  if (lastSession) {
    return lastSession;
  }
  throw new Error("Перевірка готовності не дочекалась відповіді Whisper.");
}

function showTranscriptionTestResult(session, quality) {
  const transcript = String(session?.transcript || "").trim();
  const metrics = session?.metrics || null;
  const verdict = readinessVerdict(session, quality);
  renderQualitySummary(metrics);
  if (elsRecorder.liveTranscriptPreview) {
    elsRecorder.liveTranscriptPreview.classList.toggle("waiting", !transcript);
    elsRecorder.liveTranscriptPreviewMeta.textContent = verdict.previewMeta;
    elsRecorder.liveTranscriptPreviewText.textContent = transcript || verdict.previewText;
  }
  markReadiness(verdict.readinessState, verdict.readinessText);
  setStatus(verdict.status, verdict.warning);
}

function readinessVerdict(session, quality) {
  const transcript = String(session?.transcript || "").trim();
  const compactTranscript = transcript.replace(/\s+/g, " ").slice(0, 180);
  const metrics = session?.metrics || null;
  const doneCount = Number(metrics?.doneChunkCount || 0);
  const errorCount = Number(metrics?.errorCount || 0) + Number(metrics?.errorChunkCount || 0);
  const draftEmpty = String(metrics?.draftEmptyReason || "");
  const rawWhisperCount = Number(metrics?.rawWhisperSegmentCount || 0);
  const discardedWhisperCount = Number(metrics?.discardedWhisperSegmentCount || 0);
  const inputRms = Number(metrics?.avgInputRmsDb);
  const activeRatio = Number(metrics?.avgActiveFrameRatio);
  const weakServerAudio = (
    Number.isFinite(inputRms) && inputRms <= MIC_PREFLIGHT_WARN_RMS_DBFS
  ) || (
    Number.isFinite(activeRatio) && activeRatio < MIC_PREFLIGHT_WARN_ACTIVE_RATIO
  );
  if (transcript) {
    return {
      warning: Boolean(quality?.warning),
      readinessState: quality?.warning ? "wait" : "ok",
      readinessText: quality?.warning ? "текст є, але звук слабкий; краще повторити ближче до мікрофона" : "готово до консультації",
      previewMeta: "готовність перевірена",
      previewText: transcript,
      status: `Готово до консультації: мікрофон, companion і Whisper працюють. ${quality.message} Розпізнано: ${compactTranscript}`
    };
  }
  if (errorCount > 0) {
    return {
      warning: true,
      readinessState: "bad",
      readinessText: "помилка companion/Whisper",
      previewMeta: "помилка обробки",
      previewText: "Whisper або companion повернув помилку. Текст консультації не втрачався б, але перед прийомом треба відкрити діагностику або перевірити companion.",
      status: "Перевірка готовності не пройшла: companion/Whisper повернув помилку. Натисніть Діагностика."
    };
  }
  if (doneCount > 0 && (draftEmpty === "discarded_noise" || (rawWhisperCount > 0 && discardedWhisperCount >= rawWhisperCount))) {
    const filteredText = filteredWhisperSummary(metrics);
    return {
      warning: true,
      readinessState: "bad",
      readinessText: "Whisper чує шум, але не корисну мову",
      previewMeta: "відфільтрований шум",
      previewText: `Whisper обробив аудіо, але корисного тексту не залишилось. ${filteredText} Повторіть перевірку з фразою: 'Скарги на головний біль, анамнез захворювання два дні'.`,
      status: `Перевірка готовності не пройшла: ${filteredText} Говоріть ближче до вибраного мікрофона або зменшіть шум у кабінеті.`
    };
  }
  if (doneCount > 0 && (quality?.warning || weakServerAudio)) {
    return {
      warning: true,
      readinessState: "bad",
      readinessText: "сервер працює, але мікрофонний тест без голосу",
      previewMeta: "мікрофон слабкий",
      previewText: "Whisper обробив аудіо, але не знайшов голосу. Це означає, що сервер працює, а проблема найімовірніше у вибраному мікрофоні, відстані або шумі.",
      status: `Перевірка готовності не дала тексту: сервер і Whisper відповіли, але мікрофонний сигнал слабкий або порожній. ${quality.message}`
    };
  }
  if (doneCount > 0) {
    return {
      warning: true,
      readinessState: "bad",
      readinessText: "Whisper обробив фрагмент, але текст порожній",
      previewMeta: "текст не знайдено",
      previewText: "Whisper обробив фрагмент, але текст порожній. Повторіть перевірку з фразою: 'Скарги на головний біль, анамнез захворювання два дні'.",
      status: `Перевірка готовності не дала тексту. Повторіть її з чіткою медичною фразою біля мікрофона. ${quality.message}`
    };
  }
  return {
    warning: true,
    readinessState: "bad",
    readinessText: "немає готової відповіді Whisper",
    previewMeta: "немає відповіді Whisper",
    previewText: "Фрагмент не дійшов до готового результату. Якщо тест без мікрофона працює, перевірте дозвіл мікрофона у Chrome і вибраний пристрій.",
    status: "Перевірка готовності не завершилась: немає готової відповіді Whisper."
  };
}

function markReadiness(state, message, options = {}) {
  const normalizedState = ["ok", "bad", "wait"].includes(state) ? state : "wait";
  if (options.reset) {
    lastReadinessCheck = null;
  } else {
    lastReadinessCheck = {
      state: normalizedState,
      message: String(message || ""),
      at: new Date().toISOString(),
      microphone: selectedMicrophoneLabel()
    };
  }
  renderReadinessStatus();
}

function renderReadinessStatus() {
  if (!elsRecorder.readinessStatus) {
    return;
  }
  const span = elsRecorder.readinessStatus.querySelector("span");
  const stale = lastReadinessCheck && Date.now() - new Date(lastReadinessCheck.at).getTime() > READINESS_FRESH_MS;
  const state = stale ? "wait" : (lastReadinessCheck?.state || "wait");
  elsRecorder.readinessStatus.dataset.state = state;
  if (!span) {
    return;
  }
  if (!lastReadinessCheck) {
    span.textContent = "натисніть \"Перевірка готовності\" перед консультацією";
    return;
  }
  const time = new Date(lastReadinessCheck.at).toLocaleTimeString("uk-UA", {
    hour: "2-digit",
    minute: "2-digit"
  });
  const prefix = stale ? "перевірка застаріла" : (state === "ok" ? "перевірено" : (state === "bad" ? "є проблема" : "потрібна увага"));
  span.textContent = `${prefix} ${time}: ${lastReadinessCheck.message}`;
}

function readinessStartWarning() {
  if (!lastReadinessCheck) {
    return "Перед консультацією ще не було успішної перевірки готовності. Запис стартує, але якщо текст не з'явиться після першого чанка, перевірте мікрофон.";
  }
  const ageMs = Date.now() - new Date(lastReadinessCheck.at).getTime();
  if (ageMs > READINESS_FRESH_MS) {
    return "Перевірка готовності застаріла. Запис стартує, але після старту перевірте смужку мікрофона.";
  }
  if (lastReadinessCheck.state !== "ok") {
    return `Остання перевірка готовності не була успішною: ${lastReadinessCheck.message}. Запис стартує, але контролюйте смужку мікрофона.`;
  }
  return "";
}

function showFileTranscriptionTestResult(session, file) {
  const transcript = String(session?.transcript || "").trim();
  const metrics = session?.metrics || null;
  const draftEmpty = String(metrics?.draftEmptyReason || "");
  renderQualitySummary(metrics);
  if (elsRecorder.liveTranscriptPreview) {
    elsRecorder.liveTranscriptPreview.classList.toggle("waiting", !transcript);
    elsRecorder.liveTranscriptPreviewMeta.textContent = transcript ? `файл готовий · ${formatBytes(file.size)}` : (draftEmpty === "discarded_noise" ? "файл дав лише шум" : "файл без тексту");
    elsRecorder.liveTranscriptPreviewText.textContent = transcript || (draftEmpty === "discarded_noise"
      ? `Whisper обробив файл, але корисного тексту не залишилось. ${filteredWhisperSummary(metrics)}`
      : "Whisper обробив файл, але не повернув текст. Перевірте, чи у файлі є голос, а не тільки шум або відео без аудіо.");
  }
  if (transcript) {
    setStatus(`Тест без мікрофона готовий. Розпізнано: ${transcript.replace(/\s+/g, " ").slice(0, 180)}`);
    return;
  }
  if (draftEmpty === "discarded_noise") {
    setStatus(`Тест без мікрофона не дав корисного тексту: ${filteredWhisperSummary(metrics)}`, true);
    return;
  }
  setStatus("Тест без мікрофона не дав тексту. Якщо у файлі точно є голос, треба дивитись якість аудіодоріжки або формат контейнера.", true);
}

function filteredWhisperSummary(metrics) {
  const rawCount = Number(metrics?.rawWhisperSegmentCount || 0);
  const discardedCount = Number(metrics?.discardedWhisperSegmentCount || 0);
  if (rawCount > 0 || discardedCount > 0) {
    return `Відфільтровано ${discardedCount || rawCount} з ${rawCount || discardedCount} фрагментів як шум/службові фрази.`;
  }
  return "Після фільтрації шуму текст порожній.";
}

function mimeTypeFromFileName(name = "") {
  const lower = String(name || "").toLowerCase();
  if (lower.endsWith(".wav")) return "audio/wav";
  if (lower.endsWith(".webm")) return "audio/webm";
  if (lower.endsWith(".m4a")) return "audio/mp4";
  if (lower.endsWith(".mp3")) return "audio/mpeg";
  if (lower.endsWith(".mp4")) return "video/mp4";
  if (lower.endsWith(".mov")) return "video/quicktime";
  if (lower.endsWith(".ogg")) return "audio/ogg";
  return "";
}

function formatBytes(value) {
  const bytes = Math.max(0, Number(value) || 0);
  if (bytes < 1024) {
    return `${Math.round(bytes)} B`;
  }
  if (bytes < 1024 * 1024) {
    return `${Math.round(bytes / 1024)} KB`;
  }
  return `${Math.round((bytes / (1024 * 1024)) * 10) / 10} MB`;
}

function sleep(ms) {
  return new Promise((resolve) => setTimeout(resolve, Math.max(0, Number(ms) || 0)));
}

async function analyzeMicrophoneSignal(stream, durationMs) {
  const AudioContextClass = window.AudioContext || window.webkitAudioContext;
  if (!AudioContextClass) {
    return null;
  }
  const context = new AudioContextClass();
  await context.resume();
  return new Promise((resolve, reject) => {
    let source = null;
    let analyser = null;
    let sampleTimer = null;
    let settled = false;
    let totalSamples = 0;
    let totalSquares = 0;
    let peak = 0;
    let frameCount = 0;
    let activeFrameCount = 0;

    const cleanup = () => {
      if (sampleTimer) {
        clearTimeout(sampleTimer);
        sampleTimer = null;
      }
      if (analyser) {
        analyser.disconnect();
      }
      if (source) {
        source.disconnect();
      }
      context.close().catch(() => {});
    };

    const finish = () => {
      if (settled) {
        return;
      }
      settled = true;
      cleanup();
      const rms = Math.sqrt(totalSquares / Math.max(1, totalSamples));
      resolve({
        durationMs,
        samples: totalSamples,
        rmsDb: amplitudeToDbfs(rms),
        peakDb: amplitudeToDbfs(peak),
        activeFrameRatio: frameCount ? activeFrameCount / frameCount : 0
      });
    };

    try {
      source = context.createMediaStreamSource(stream);
      analyser = context.createAnalyser();
      analyser.fftSize = 2048;
      source.connect(analyser);
      const input = new Float32Array(analyser.fftSize);
      const sample = () => {
        if (settled || !analyser) {
          return;
        }
        analyser.getFloatTimeDomainData(input);
        let frameSquares = 0;
        for (let index = 0; index < input.length; index += 1) {
          const value = Math.abs(input[index]);
          frameSquares += value * value;
          if (value > peak) {
            peak = value;
          }
        }
        totalSamples += input.length;
        totalSquares += frameSquares;
        frameCount += 1;
        const frameRms = Math.sqrt(frameSquares / Math.max(1, input.length));
        if (amplitudeToDbfs(frameRms) >= MIC_PREFLIGHT_ACTIVE_FRAME_DBFS) {
          activeFrameCount += 1;
        }
        sampleTimer = setTimeout(sample, AUDIO_METER_INTERVAL_MS);
      };
      sample();
      setTimeout(finish, Math.max(800, Number(durationMs) || MIC_PREFLIGHT_SAMPLE_MS));
    } catch (error) {
      cleanup();
      reject(error);
    }
  });
}

function microphoneSignalQuality(signal) {
  if (!signal || !Number.isFinite(Number(signal.rmsDb))) {
    return {
      warning: true,
      message: "Тест рівня звуку недоступний; перевірте, чи рухається смужка під час запису."
    };
  }
  const rmsDb = Number(signal.rmsDb);
  const peakDb = Number(signal.peakDb);
  const activeRatio = Number(signal.activeFrameRatio || 0);
  const summary = `Тест: ${formatDb(rmsDb)} RMS, peak ${formatDb(peakDb)}, активної мови ${Math.round(activeRatio * 100)}%.`;
  if (rmsDb <= MIC_PREFLIGHT_WARN_RMS_DBFS || activeRatio < MIC_PREFLIGHT_WARN_ACTIVE_RATIO) {
    return {
      warning: true,
      message: `${summary} Звук слабкий або ви мовчали під час тесту; для консультації говоріть ближче або виберіть інший мікрофон.`
    };
  }
  return {
    warning: false,
    message: `${summary} Рівень достатній для тестового запису.`
  };
}

function showMicrophoneSignalResult(signal, quality, label = "") {
  if (!signal || !Number.isFinite(Number(signal.rmsDb))) {
    if (elsRecorder.audioSignalVerdict) {
      elsRecorder.audioSignalVerdict.textContent = "Не вдалося виміряти сигнал. Під час запису дивіться, чи рухається смужка.";
      elsRecorder.audioSignalVerdict.dataset.state = "warn";
    }
    return;
  }
  const rmsDb = Number(signal.rmsDb);
  const peakDb = Number(signal.peakDb);
  const activeRatio = Math.max(0, Math.min(1, Number(signal.activeFrameRatio || 0)));
  const rmsAmplitude = dbfsToAmplitude(rmsDb);
  const peakAmplitude = dbfsToAmplitude(peakDb);
  const levelPercent = Math.round(Math.max(0, Math.min(1, rmsAmplitude * 7.5)) * 100);
  const peakPercent = Math.round(Math.max(0, Math.min(1, peakAmplitude * 2.8)) * 100);
  if (elsRecorder.audioLevelBar) {
    elsRecorder.audioLevelBar.style.setProperty("--audio-level", `${levelPercent}%`);
  }
  if (elsRecorder.audioPeakMark) {
    elsRecorder.audioPeakMark.style.setProperty("--audio-peak", `${peakPercent}%`);
    elsRecorder.audioPeakMark.style.setProperty("--audio-peak-opacity", peakPercent > 2 ? "1" : "0");
  }
  if (elsRecorder.compactAudioBar) {
    elsRecorder.compactAudioBar.style.setProperty("--compact-audio-level", `${Math.max(levelPercent, peakPercent > 2 ? 3 : 0)}%`);
  }
  if (elsRecorder.audioLevelText) {
    elsRecorder.audioLevelText.textContent = quality?.warning ? "слабко" : `${levelPercent}%`;
  }
  lastAudioSignalInfo = {
    levelPercent,
    peakPercent,
    audible: !quality?.warning,
    secondsSinceAudible: null,
    rmsDb,
    peakDb,
    activeFrameRatio: activeRatio,
    updatedAt: new Date().toISOString()
  };
  if (elsRecorder.audioSignalVerdict) {
    const activeText = `${Math.round(activeRatio * 100)}% активності`;
    const dbText = `${formatDb(rmsDb)} RMS, peak ${formatDb(peakDb)}`;
    if (quality?.warning) {
      elsRecorder.audioSignalVerdict.textContent = `Слабкий сигнал: ${dbText}, ${activeText}. Говоріть ближче або виберіть інший мікрофон.`;
      elsRecorder.audioSignalVerdict.dataset.state = "warn";
    } else {
      const name = label ? `: ${label}` : "";
      elsRecorder.audioSignalVerdict.textContent = `Мікрофон чує голос${name}. ${dbText}, ${activeText}.`;
      elsRecorder.audioSignalVerdict.dataset.state = "ok";
    }
  }
  notifyHostStatus({
    audioLevel: Math.max(levelPercent, peakPercent > 2 ? 3 : 0),
    microphoneLabel: label || selectedMicrophoneLabel() || "мікрофон"
  });
}

function dbfsToAmplitude(value) {
  if (!Number.isFinite(Number(value))) {
    return 0;
  }
  return Math.pow(10, Number(value) / 20);
}

function amplitudeToDbfs(amplitude) {
  const value = Math.max(Number(amplitude) || 0, 1e-7);
  return Math.round(20 * Math.log10(value) * 10) / 10;
}

function formatDb(value) {
  if (!Number.isFinite(Number(value))) {
    return "-";
  }
  return `${Math.round(Number(value))} dB`;
}

function microphoneUserMessage(error) {
  const name = String(error?.name || "");
  if (name === "NotAllowedError" || name === "PermissionDeniedError") {
    return "Доступ до мікрофона заборонено. Дозвольте мікрофон у Chrome для Parallax Health.";
  }
  if (name === "NotFoundError" || name === "DevicesNotFoundError") {
    return "Мікрофон не знайдено. Підключіть або виберіть інший пристрій.";
  }
  if (name === "NotReadableError" || name === "TrackStartError") {
    return "Мікрофон зайнятий іншою програмою або недоступний для Chrome.";
  }
  if (name === "OverconstrainedError" || name === "ConstraintNotSatisfiedError") {
    return "Вибраний мікрофон недоступний. Оновіть список або виберіть системний мікрофон.";
  }
  return "Не вдалося перевірити мікрофон.";
}

async function startRecording() {
  try {
    const canStart = await prepareNewRecordingIfNeeded();
    if (!canStart) {
      renderRecorderState("idle");
      return;
    }
    startInProgress = true;
    startCancelRequested = false;
    await saveRecorderSettings();
    renderRecorderState("starting");
    const startWarning = readinessStartWarning();
    setStatus(startWarning || "Запитую доступ до мікрофона... Якщо Chrome завис на дозволі, натисніть \"Скасувати\".", Boolean(startWarning));
    mediaStream = await requestRecordingMicrophoneStream();
    throwIfStartCancelled();
    updateActiveMicrophoneFromStream();
    await refreshMicrophoneDevices({ keepSelection: true }).catch(() => {});
    setStatus("Мікрофон активний. Починаю локальний запис...");
    sessionId = makeClientSessionId();
    startedAtMs = Date.now();
    lastKnownRecordingSeconds = 0;
    const clientStartedAt = new Date(startedAtMs).toISOString();
    chunkIndex = 0;
    emittedChunkCount = 0;
    latestTranscript = "";
    latestSegments = [];
    latestDraft = null;
    latestMetrics = null;
    renderQualitySummary(null);
    renderProcessingProgress();
    renderLiveTranscriptPreview([], "");
    pendingChunks = [];
    queueBlockedReason = "";
    encodingTasks = new Set();
    appliedLakmusValues = {};
    draftReviewValues = {};
    pcmBuffers = [];
    pcmTotalSamples = 0;
    pcmStartSample = 0;
    clearMediaRecorderBuffer();
    lastMediaRecorderRequestAt = 0;
    lastMediaRecorderFragmentAt = 0;
    lastMediaRecorderStallWarningAt = 0;
    pcmFallbackMode = false;
    lastPcmFallbackAt = 0;
    lastQueuedAudioInfo = null;
    resetAudioMeter();
    lastAudibleAudioAt = Date.now();
    lastQuietAudioWarningAt = 0;
    remoteSessionStarted = false;
    finishRequested = false;
    clientFinishedAt = "";
    consultationFinished = false;
    startPayload = {
      clientSessionId: sessionId,
      auth: null,
      language: "uk",
      source: "microphone",
      chunkMs,
      chunkOverlapMs: CHUNK_OVERLAP_MS,
      clientStartedAt,
      audioPipeline: "webaudio_pcm_wav",
      liveChunkPolicy: ALLOW_MEDIA_RECORDER_LIVE_CHUNKS ? "mediarecorder_allowed" : "wav_only",
      extensionVersion: chrome.runtime.getManifest().version
    };

    clearSafetySnapshot(sessionId).catch(() => {});
    if (elsRecorder.audioSignalVerdict) {
      elsRecorder.audioSignalVerdict.textContent = "Скажіть кілька слів: зараз перевіряю, чи Chrome бачить голос.";
      elsRecorder.audioSignalVerdict.dataset.state = "wait";
    }
    await startPcmRecorder();
    throwIfStartCancelled();
    startInProgress = false;
    startSafetySnapshotTimer();
    startDurationTimer();
    startPolling();
    startHealthMonitor();
    renderRecorderState("recording");
    elsRecorder.processingState.textContent = "локальний запис";
    setStatus(`Запис уже триває локально. Чанки по ${formatChunkDuration(chunkMs)} підуть на сервер під час консультації.`);
    scheduleConsultationAlarms("recording");
    scheduleLocalLongRecordingReminder(LOCAL_LONG_RECORDING_REMINDER_MS);
    persistSessionState().catch(() => {});
    markConsultationRuntimeState("recording").catch(() => {});

    try {
      await ensureStartPayloadAuth();
      await persistSessionState();
      await markConsultationRuntimeState("recording");
      await ensureRemoteSessionStarted();
    } catch (error) {
      if (isLocalLicenseAuthError(error) || isNonRetryableCompanionError(error)) {
        throw error;
      }
      if (String(error?.companionCode || "") === "too_many_active_sessions") {
        queueBlockedReason = companionUserMessage(error);
        await persistSessionState().catch(() => {});
        updateStaleQueueControls();
        setStatus(`Запис триває локально. ${queueBlockedReason} Після зупинки натисніть "Очистити старі сесії".`, true);
      } else {
        setStatus("Запис триває локально. Сервер зараз недоступний, чанки будуть у черзі й підуть автоматично.", true);
      }
    }

    scheduleFlush(0);
    if (remoteSessionStarted) {
      setStatus(startWarning
        ? `${startWarning} Чанки по ${formatChunkDuration(chunkMs)} відправляються на сервер під час консультації.`
        : `Запис триває. Чанки по ${formatChunkDuration(chunkMs)} відправляються на сервер під час консультації.`, Boolean(startWarning));
    }
  } catch (error) {
    const wasCancelled = Boolean(error?.recordingStartCancelled || startCancelRequested);
    startInProgress = false;
    startCancelRequested = false;
    if (recordingActive || mediaRecorder || audioContext) {
      await stopPcmRecorder().catch(() => {
        stopTracks();
      });
    } else {
      stopTracks();
    }
    stopPolling();
    stopHealthMonitor();
    clearLocalLongRecordingReminder();
    clearConsultationAlarms();
    await clearConsultationRuntimeState();
    if (String(error?.companionCode || "") === "too_many_active_sessions") {
      queueBlockedReason = companionUserMessage(error);
      await persistSessionState().catch(() => {});
      renderRecorderState("stopped");
      setStatus(`${queueBlockedReason} Натисніть "Очистити старі сесії" і запустіть запис ще раз.`, true);
      return;
    }
    if (sessionId && !recordingActive) {
      await clearPersistedSession().catch(() => {});
      resetSessionMemory();
    }
    renderRecorderState("idle");
    setStatus(wasCancelled ? "Запуск запису скасовано." : (error.message || "Не вдалося почати запис."), !wasCancelled);
  }
}

function throwIfStartCancelled() {
  if (!startCancelRequested) {
    return;
  }
  const error = new Error("Запуск запису скасовано.");
  error.recordingStartCancelled = true;
  throw error;
}

function showTranscriptAndFields() {
  if (compactMode) {
    setCompactMode(false);
  }
  if (elsRecorder.transcriptDetails) {
    elsRecorder.transcriptDetails.open = true;
    elsRecorder.transcriptDetails.scrollIntoView({ behavior: "smooth", block: "start" });
  }
  if (!latestTranscript && !hasLakmusDraftValues(lakmusDraftFromReviewForm(latestDraft))) {
    setStatus("Текст зʼявиться в блоці транскрипції після першого обробленого чанка.");
  }
}

async function stopRecording(options = {}) {
  if (!recordingActive) {
    if (startInProgress) {
      startCancelRequested = true;
      setStatus("Скасовую запуск запису. Якщо Chrome ще питає дозвіл мікрофона, закрийте цей системний запит.");
      renderRecorderState("starting");
    }
    return;
  }
  const flushNow = options.flush !== false;
  setStatus(flushNow
    ? "Зупиняю запис і відправляю останній фрагмент..."
    : "Зупиняю запис і зберігаю останній фрагмент...");
  await stopPcmRecorder();
  const encoded = await waitForEncodingQueue();
  if (!encoded) {
    setStatus("Останній аудіофрагмент ще готується. Він залишиться у локальній черзі й досилається автоматично.", true);
  }
  if (flushNow) {
    await flushPendingQueue();
  } else {
    scheduleFlush(0);
  }
  await refreshSessionStatus().catch(() => {});
  await markConsultationRuntimeState("stopped");
  scheduleConsultationAlarms("stopped");
  if (queueBlockedReason) {
    setStatus(`Запис зупинено. Черга заблокована: ${queueBlockedReason}`, true);
  } else if (livePendingCount()) {
    setStatus(`Запис зупинено. У черзі ще ${livePendingCount()} чанків, досилаю автоматично.`, true);
  } else {
    setStatus("Запис зупинено. Можна завершити консультацію або експортувати TXT.");
  }
  await persistSessionState();
}

async function finishConsultation() {
  try {
    if (startInProgress) {
      startCancelRequested = true;
      setStatus("Скасовую запуск запису. Якщо Chrome ще питає дозвіл мікрофона, закрийте цей системний запит.");
      renderRecorderState("starting");
      return;
    }
    if (recordingActive) {
      await stopRecording({ flush: false });
    }
    await waitForEncodingQueue();
    renderRecorderState("processing");
    finishRequested = true;
    clientFinishedAt = clientFinishedAt || new Date().toISOString();
    await markConsultationRuntimeState("finish_pending");
    scheduleConsultationAlarms("finish_pending");
    await persistSessionState();
    const flushed = await flushPendingQueue(true);
    if (flushed && remoteSessionStarted && !finishRequested) {
      setStatus(`Консультацію завершено. ${recordingOperatorActionText(latestMetrics)}`);
    } else if (queueBlockedReason) {
      setStatus(`Завершення зупинено: ${queueBlockedReason} Очистіть стару чергу або почніть новий запис.`, true);
    } else {
      setStatus(`Сервер недоступний або є черга (${livePendingCount()}). Завершення буде дослане автоматично.`, true);
    }
  } catch (error) {
    setStatus(error.message || "Не вдалося завершити консультацію.", true);
  } finally {
    if (!recordingActive) {
      renderRecorderState(consultationFinished ? "finished" : (finishRequested ? "finish_pending" : "stopped"));
    }
  }
}

function queueChunk(blob, finalChunk, offsetMs, overlapMs, nominalStartMs = null, durationMs = null) {
  const index = chunkIndex;
  const mimeType = blob.type || "audio/wav";
  if (!isAllowedLiveChunkMimeType(mimeType)) {
    setStatus(`Живий запис має йти у WAV, але Chrome підготував ${audioFormatLabel(mimeType)}. Не відправляю цей фрагмент у Whisper, щоб не зламати транскрипцію.`, true);
    lastQueuedAudioInfo = {
      index,
      bytes: blob.size || 0,
      durationMs: Number(durationMs || 0),
      mimeType,
      finalChunk: Boolean(finalChunk),
      blocked: true,
      queuedAt: new Date().toISOString()
    };
    updateAudioCaptureStatus();
    return false;
  }
  chunkIndex += 1;
  elsRecorder.chunkCount.textContent = String(chunkIndex);
  const safeDurationMs = Number.isFinite(Number(durationMs))
    ? Math.max(0, Number(durationMs))
    : Math.max(0, chunkMs + Number(overlapMs || 0));
  const queuedChunk = {
    index,
    offsetMs,
    overlapMs,
    nominalStartMs,
    audioBlob: blob,
    finalChunk,
    payload: null,
    status: "encoding"
  };
  pendingChunks.push(queuedChunk);
  lastQueuedAudioInfo = {
    index,
    bytes: blob.size || 0,
    durationMs: safeDurationMs,
    mimeType,
    finalChunk: Boolean(finalChunk),
    queuedAt: new Date().toISOString()
  };
  updateAudioCaptureStatus();
  updatePendingCount();
  const encodeTask = persistAndEncodeQueuedChunk(queuedChunk, blob, finalChunk).catch((error) => {
    queuedChunk.status = "error";
    queuedChunk.error = error.message || String(error);
    setStatus(`Не вдалося підготувати аудіофрагмент: ${error.message || String(error)}`, true);
  }).finally(() => {
    encodingTasks.delete(encodeTask);
  });
  encodingTasks.add(encodeTask);
  return true;
}

async function persistAndEncodeQueuedChunk(queuedChunk, blob, finalChunk) {
  queuedChunk.audioBlob = blob;
  queuedChunk.finalChunk = finalChunk;
  queuedChunk.status = "encoding";
  await persistQueuedChunk(queuedChunk);
  await encodeQueuedChunk(queuedChunk, blob, finalChunk);
}

async function encodeQueuedChunk(queuedChunk, blob, finalChunk) {
  const audioBase64 = await blobToBase64(blob);
  const nominalStartMs = Number.isFinite(Number(queuedChunk.nominalStartMs))
    ? Number(queuedChunk.nominalStartMs)
    : queuedChunk.index * chunkMs;
  const payload = {
    sessionId,
    chunkIndex: queuedChunk.index,
    mimeType: blob.type || "audio/wav",
    audioBase64,
    offsetSeconds: Math.max(0, roundSeconds(queuedChunk.offsetMs / 1000)),
    nominalStartSeconds: Math.max(0, roundSeconds(nominalStartMs / 1000)),
    overlapSeconds: Math.max(0, roundSeconds((queuedChunk.overlapMs || 0) / 1000)),
    finalChunk,
    source: "microphone",
    audioPipeline: "webaudio_pcm_wav",
    liveChunkPolicy: ALLOW_MEDIA_RECORDER_LIVE_CHUNKS ? "mediarecorder_allowed" : "wav_only",
    extensionVersion: chrome.runtime.getManifest().version,
    auth: await getLicenseAuth(),
    capturedAt: new Date().toISOString()
  };
  queuedChunk.payload = payload;
  queuedChunk.audioBlob = null;
  queuedChunk.status = "ready";
  await persistQueuedChunk(queuedChunk);
  scheduleFlush(0);
}

async function startPcmRecorder() {
  audioContext = new AudioContext();
  await audioContext.resume();
  audioSource = audioContext.createMediaStreamSource(mediaStream);
  audioAnalyser = audioContext.createAnalyser();
  audioAnalyser.fftSize = 2048;
  audioSource.connect(audioAnalyser);
  startPcmFallbackCapture();
  if (pcmProcessor) {
    pcmFallbackMode = true;
    lastPcmFallbackAt = Date.now();
  }
  recordingActive = true;
  startAudioMeterLoop();
  if (!pcmFallbackMode) {
    startMediaRecorder();
  }
}

async function stopPcmRecorder() {
  if (pcmFallbackMode) {
    emitDuePcmChunks(true);
  }
  await persistSafetySnapshot({ force: true }).catch(() => {});
  lastKnownRecordingSeconds = currentRecordingSeconds();
  recordingActive = false;
  await stopMediaRecorder();
  stopSafetySnapshotTimer();
  stopAudioMeterLoop();
  if (pcmProcessor) {
    pcmProcessor.onaudioprocess = null;
    pcmProcessor.disconnect();
    pcmProcessor = null;
  }
  if (pcmSilentGain) {
    pcmSilentGain.disconnect();
    pcmSilentGain = null;
  }
  if (audioAnalyser) {
    audioAnalyser.disconnect();
    audioAnalyser = null;
  }
  if (audioSource) {
    audioSource.disconnect();
    audioSource = null;
  }
  stopTracks();
  stopHealthMonitor();
  clearLocalLongRecordingReminder();
  resetAudioMeter();
  await refreshMicrophoneDevices({ keepSelection: true }).catch(() => {});
  if (audioContext) {
    await audioContext.close().catch(() => {});
    audioContext = null;
  }
  renderRecorderState("stopped");
}

function startMediaRecorder() {
  if (typeof MediaRecorder === "undefined") {
    throw new Error("Chrome не підтримує MediaRecorder для цього профілю.");
  }
  const mimeType = selectMediaRecorderMimeType();
  const options = mimeType ? { mimeType } : undefined;
  mediaRecorder = new MediaRecorder(mediaStream, options);
  mediaRecorderStopPromise = null;
  mediaRecorderStartedAtMs = Date.now();
  lastMediaRecorderRequestAt = 0;
  lastMediaRecorderFragmentAt = mediaRecorderStartedAtMs;
  lastMediaRecorderStallWarningAt = 0;
  lastMediaRecorderChunkEndMs = 0;
  clearMediaRecorderBuffer();

  mediaRecorder.addEventListener("dataavailable", (event) => {
    const finalChunk = !recordingActive || mediaRecorder?.state === "inactive";
    handleMediaRecorderFragment(event.data, finalChunk);
  });
  mediaRecorder.addEventListener("error", (event) => {
    const message = event.error?.message || "невідома помилка MediaRecorder";
    setStatus(`Помилка запису мікрофона: ${message}. Запис не зупинено, але перевірте мікрофон.`, true);
  });
  mediaRecorder.start();
}

async function stopMediaRecorder() {
  const recorder = mediaRecorder;
  if (!recorder) {
    return;
  }
  if (recorder.state === "inactive") {
    mediaRecorder = null;
    return;
  }
  mediaRecorderStopPromise = new Promise((resolve) => {
    let settled = false;
    const finish = () => {
      if (settled) {
        return;
      }
      settled = true;
      clearTimeout(timer);
      resolve();
    };
    const timer = setTimeout(finish, MEDIA_RECORDER_STOP_TIMEOUT_MS);
    recorder.addEventListener("stop", finish, { once: true });
  });
  try {
    recorder.requestData();
  } catch {
    // Some Chrome versions throw if data is already being emitted.
  }
  recorder.stop();
  await mediaRecorderStopPromise;
  if (mediaRecorder === recorder) {
    mediaRecorder = null;
  }
  mediaRecorderStopPromise = null;
}

function handleMediaRecorderFragment(blob, finalChunk) {
  if (!blob || !blob.size) {
    return;
  }
  lastMediaRecorderFragmentAt = Date.now();
  if (pcmFallbackMode) {
    return;
  }
  const elapsedMs = mediaRecorderStartedAtMs
    ? Math.max(lastMediaRecorderChunkEndMs, Date.now() - mediaRecorderStartedAtMs)
    : Math.max(lastMediaRecorderChunkEndMs, (emittedChunkCount + 1) * chunkMs);
  const fragmentStartMs = Math.max(0, lastMediaRecorderChunkEndMs);
  const durationMs = elapsedMs - fragmentStartMs;
  if (!finalChunk && durationMs < 250) {
    return;
  }
  lastMediaRecorderChunkEndMs = elapsedMs;
  appendMediaRecorderFragment(blob, fragmentStartMs, elapsedMs);
  persistMediaRecorderBufferSnapshot().catch(() => {});
  const bufferedDurationMs = mediaRecorderBufferEndMs - mediaRecorderBufferStartMs;
  if (!finalChunk && bufferedDurationMs < chunkMs) {
    lastQueuedAudioInfo = {
      index: emittedChunkCount,
      bytes: mediaRecorderBufferBytes,
      durationMs: bufferedDurationMs,
      mimeType: mediaRecorder?.mimeType || mediaRecorderFragments[0]?.blob?.type || "audio/webm",
      finalChunk: false,
      buffered: true,
      queuedAt: new Date().toISOString()
    };
    updateAudioCaptureStatus();
    renderLiveTranscriptPreview(latestSegments, latestTranscript);
    return;
  }
  flushMediaRecorderBuffer(finalChunk);
}

function appendMediaRecorderFragment(blob, startMs, endMs) {
  if (!mediaRecorderFragments.length) {
    mediaRecorderBufferStartMs = Math.max(0, startMs);
  }
  mediaRecorderFragments.push({ blob, startMs, endMs });
  mediaRecorderBufferEndMs = Math.max(mediaRecorderBufferEndMs, endMs);
  mediaRecorderBufferBytes += blob.size || 0;
}

function flushMediaRecorderBuffer(finalChunk) {
  if (!mediaRecorderFragments.length) {
    return;
  }
  const offsetMs = mediaRecorderBufferStartMs;
  const endMs = mediaRecorderBufferEndMs;
  const durationMs = endMs - offsetMs;
  if (durationMs < 250) {
    clearMediaRecorderBuffer();
    return;
  }
  const index = emittedChunkCount;
  const mimeType = mediaRecorder?.mimeType || mediaRecorderFragments[0]?.blob?.type || "audio/webm";
  if (!ALLOW_MEDIA_RECORDER_LIVE_CHUNKS && !isAllowedLiveChunkMimeType(mimeType)) {
    const bufferedDurationMs = durationMs;
    const bufferedBytes = mediaRecorderBufferBytes;
    clearMediaRecorderBuffer();
    if (audioContext && pcmTotalSamples > 0) {
      pcmFallbackMode = true;
      lastPcmFallbackAt = Date.now();
      emitDuePcmChunks(Boolean(finalChunk));
      setStatus(`Chrome підготував ${audioFormatLabel(mimeType)}, тому переключив запис на WAV через WebAudio. Це захищає транскрипцію від WebM/EBML помилки.`, true);
      return;
    }
    lastQueuedAudioInfo = {
      index,
      bytes: bufferedBytes,
      durationMs: bufferedDurationMs,
      mimeType,
      finalChunk: Boolean(finalChunk),
      blocked: true,
      queuedAt: new Date().toISOString()
    };
    updateAudioCaptureStatus();
    setStatus(`Chrome не дав WAV-аудіо для живої транскрипції (${audioFormatLabel(mimeType)}). Перевірте мікрофон або перезапустіть recorder; цей фрагмент не відправлено в Whisper.`, true);
    return;
  }
  const blob = new Blob(mediaRecorderFragments.map((fragment) => fragment.blob), { type: mimeType });
  emittedChunkCount += 1;
  lastQueuedAudioInfo = {
    index,
    bytes: blob.size,
    durationMs,
    finalChunk: Boolean(finalChunk),
    queuedAt: new Date().toISOString()
  };
  updateAudioCaptureStatus();
  if (!queueChunk(blob, finalChunk, offsetMs, 0, offsetMs, durationMs)) {
    clearMediaRecorderBuffer();
    return;
  }
  trimPcmBuffers();
  clearMediaRecorderBuffer();
  renderLiveTranscriptPreview(latestSegments, latestTranscript);
  if (recordingActive) {
    setStatus(`Запис триває. Аудіофрагмент ${index + 1} (${formatAudioFragmentInfo(lastQueuedAudioInfo)}) збережено і відправляється на транскрипцію.`);
  }
}

function isAllowedLiveChunkMimeType(mimeType) {
  return String(mimeType || "").toLowerCase().includes(LIVE_RECORDER_MIME_TYPE);
}

function clearMediaRecorderBuffer() {
  mediaRecorderFragments = [];
  mediaRecorderBufferStartMs = 0;
  mediaRecorderBufferEndMs = 0;
  mediaRecorderBufferBytes = 0;
}

function updateAudioCaptureStatus() {
  if (!elsRecorder.processingState || !lastQueuedAudioInfo) {
    renderChunkPipelineStatus();
    return;
  }
  const label = lastQueuedAudioInfo.buffered
    ? "буфер запису"
    : (recordingActive ? "локальний запис" : "аудіо збережено");
  elsRecorder.processingState.textContent = `${label}: ${formatAudioFragmentInfo(lastQueuedAudioInfo)}`;
  renderChunkPipelineStatus();
}

function formatAudioFragmentInfo(info) {
  if (!info) {
    return "фрагментів ще немає";
  }
  const seconds = Math.max(0, Math.round(Number(info.durationMs || 0) / 1000));
  const prefix = info.buffered ? "~" : "";
  const state = info.blocked ? "заблоковано, " : "";
  return `${state}${prefix}${seconds}с, ${audioFormatLabel(info.mimeType)}, ${formatBytes(info.bytes || 0)}`;
}

function audioFormatLabel(mimeType) {
  const value = String(mimeType || "").toLowerCase();
  if (value.includes("wav")) {
    return "WAV";
  }
  if (value.includes("webm")) {
    return "WEBM";
  }
  if (value.includes("mp4") || value.includes("m4a")) {
    return "MP4";
  }
  return value ? value.toUpperCase() : "AUDIO";
}

function selectMediaRecorderMimeType() {
  const candidates = [
    "audio/webm;codecs=opus",
    "audio/webm",
    "audio/mp4"
  ];
  return candidates.find((type) => MediaRecorder.isTypeSupported?.(type)) || "";
}

function startAudioMeterLoop() {
  stopAudioMeterLoop();
  const samples = new Float32Array(audioAnalyser?.fftSize || 2048);
  const tick = () => {
    if (!recordingActive || !audioAnalyser) {
      return;
    }
    audioAnalyser.getFloatTimeDomainData(samples);
    updateAudioMeter(samples);
    audioMeterTimer = setTimeout(tick, AUDIO_METER_INTERVAL_MS);
  };
  tick();
}

function startPcmFallbackCapture() {
  if (!audioContext || !audioSource || typeof audioContext.createScriptProcessor !== "function") {
    return;
  }
  pcmProcessor = audioContext.createScriptProcessor(4096, 1, 1);
  pcmSilentGain = audioContext.createGain();
  pcmSilentGain.gain.value = 0;
  pcmProcessor.onaudioprocess = (event) => {
    if (!recordingActive) {
      return;
    }
    const input = event.inputBuffer?.getChannelData?.(0);
    if (!input?.length) {
      return;
    }
    appendPcmSamples(input);
  };
  audioSource.connect(pcmProcessor);
  pcmProcessor.connect(pcmSilentGain);
  pcmSilentGain.connect(audioContext.destination);
}

function stopAudioMeterLoop() {
  if (audioMeterTimer) {
    clearTimeout(audioMeterTimer);
    audioMeterTimer = null;
  }
}

function appendPcmSamples(samples) {
  const copy = new Float32Array(samples.length);
  copy.set(samples);
  pcmBuffers.push(copy);
  pcmTotalSamples += copy.length;
  if (pcmBuffers.length > 32) {
    trimPcmBuffers();
  }
}

function updateAudioMeter(samples) {
  const now = performance.now();
  if (now - lastAudioMeterAt < AUDIO_METER_INTERVAL_MS) {
    return;
  }
  lastAudioMeterAt = now;

  let sumSquares = 0;
  let peak = 0;
  for (let index = 0; index < samples.length; index += 1) {
    const value = Math.abs(samples[index]);
    sumSquares += value * value;
    if (value > peak) {
      peak = value;
    }
  }

  const rms = Math.sqrt(sumSquares / Math.max(1, samples.length));
  const level = Math.min(1, rms * 7.5);
  const peakLevel = Math.min(1, peak * 2.8);
  smoothedAudioLevel = smoothedAudioLevel * 0.72 + level * 0.28;
  renderAudioMeter(smoothedAudioLevel, peakLevel);
}

function renderAudioMeter(level, peak) {
  if (!elsRecorder.audioLevelBar || !elsRecorder.audioPeakMark || !elsRecorder.audioLevelText) {
    return;
  }
  const levelPercent = Math.round(Math.max(0, Math.min(1, level)) * 100);
  const peakPercent = Math.round(Math.max(0, Math.min(1, peak)) * 100);
  elsRecorder.audioLevelBar.style.setProperty("--audio-level", `${levelPercent}%`);
  elsRecorder.audioPeakMark.style.setProperty("--audio-peak", `${peakPercent}%`);
  elsRecorder.audioPeakMark.style.setProperty("--audio-peak-opacity", peakPercent > 2 ? "1" : "0");
  if (elsRecorder.compactAudioBar) {
    elsRecorder.compactAudioBar.style.setProperty("--compact-audio-level", `${Math.max(levelPercent, peakPercent > 2 ? 3 : 0)}%`);
  }
  if (!recordingActive) {
    elsRecorder.audioLevelText.textContent = "очікує";
  } else if (levelPercent < 3 && peakPercent < 5) {
    elsRecorder.audioLevelText.textContent = "тихо";
  } else {
    elsRecorder.audioLevelText.textContent = `${levelPercent}%`;
  }
  updateAudioSignalVerdict(levelPercent, peakPercent);
  monitorQuietAudio(levelPercent, peakPercent);
  notifyHostStatus({
    audioLevel: Math.max(levelPercent, peakPercent > 2 ? 3 : 0)
  });
}

function updateAudioSignalVerdict(levelPercent, peakPercent) {
  if (!elsRecorder.audioSignalVerdict) {
    return;
  }
  const now = Date.now();
  const audible = recordingActive && (levelPercent >= AUDIBLE_LEVEL_PERCENT || peakPercent >= AUDIBLE_PEAK_PERCENT);
  const secondsSinceAudible = lastAudibleAudioAt ? Math.max(0, Math.round((now - lastAudibleAudioAt) / 1000)) : null;
  lastAudioSignalInfo = {
    levelPercent,
    peakPercent,
    audible,
    secondsSinceAudible,
    updatedAt: new Date(now).toISOString()
  };
  if (!recordingActive) {
    elsRecorder.audioSignalVerdict.textContent = "Очікує старту запису.";
    elsRecorder.audioSignalVerdict.dataset.state = "idle";
    return;
  }
  if (audible) {
    elsRecorder.audioSignalVerdict.textContent = "Звук є. Chrome бачить мікрофон.";
    elsRecorder.audioSignalVerdict.dataset.state = "ok";
    return;
  }
  if (secondsSinceAudible != null && secondsSinceAudible < 8) {
    elsRecorder.audioSignalVerdict.textContent = `Звук був ${secondsSinceAudible}с тому, зараз тихо.`;
    elsRecorder.audioSignalVerdict.dataset.state = "wait";
    return;
  }
  elsRecorder.audioSignalVerdict.textContent = "Тихо. Якщо говорите зараз, перевірте вибраний мікрофон.";
  elsRecorder.audioSignalVerdict.dataset.state = "warn";
}

function monitorQuietAudio(levelPercent, peakPercent) {
  if (!recordingActive || !startedAtMs) {
    return;
  }
  const now = Date.now();
  const audible = levelPercent >= AUDIBLE_LEVEL_PERCENT || peakPercent >= AUDIBLE_PEAK_PERCENT;
  if (audible) {
    lastAudibleAudioAt = now;
    return;
  }
  const recordingAgeMs = now - startedAtMs;
  const quietForMs = now - (lastAudibleAudioAt || startedAtMs);
  const warningDelayMs = lastQuietAudioWarningAt ? QUIET_AUDIO_WARNING_MS : FIRST_QUIET_AUDIO_WARNING_MS;
  if (recordingAgeMs < warningDelayMs || quietForMs < warningDelayMs) {
    return;
  }
  if (lastQuietAudioWarningAt && now - lastQuietAudioWarningAt < QUIET_AUDIO_REPEAT_MS) {
    return;
  }
  lastQuietAudioWarningAt = now;
  setStatus("Мікрофон майже не ловить звук. Перевірте вибраний пристрій або розміщення мікрофона; для тесту скажіть ближче кілька чітких медичних фраз.", true);
}

function resetAudioMeter() {
  smoothedAudioLevel = 0;
  lastAudioMeterAt = 0;
  lastAudibleAudioAt = 0;
  lastQuietAudioWarningAt = 0;
  lastAudioSignalInfo = null;
  renderAudioMeter(0, 0);
  notifyHostStatus({ audioLevel: 0 });
}

function emitDuePcmChunks(final) {
  if (!audioContext) {
    return;
  }
  const sampleRate = audioContext.sampleRate;
  const recordedMs = (pcmTotalSamples / sampleRate) * 1000;
  while (recordedMs >= (emittedChunkCount + 1) * chunkMs) {
    emitPcmChunk(emittedChunkCount, (emittedChunkCount + 1) * chunkMs, false);
    emittedChunkCount += 1;
  }

  if (final && recordedMs > emittedChunkCount * chunkMs + 250) {
    emitPcmChunk(emittedChunkCount, recordedMs, true);
    emittedChunkCount += 1;
  }
  trimPcmBuffers();
}

function emitPcmChunk(index, endMs, finalChunk) {
  const overlapMs = index === 0 ? 0 : CHUNK_OVERLAP_MS;
  const offsetMs = Math.max(0, index * chunkMs - overlapMs);
  const samples = collectPcmRange(offsetMs, endMs);
  if (!samples.length) {
    return;
  }
  const sourceRate = audioContext.sampleRate;
  const wavSamples = resampleLinear(samples, sourceRate, WAV_SAMPLE_RATE);
  const blob = encodeWavBlob(wavSamples, WAV_SAMPLE_RATE);
  queueChunk(blob, finalChunk, offsetMs, overlapMs, offsetMs, Math.max(0, endMs - offsetMs));
}

function collectPcmRange(startMs, endMs) {
  const sampleRate = audioContext.sampleRate;
  const startSample = Math.max(pcmStartSample, Math.floor((startMs / 1000) * sampleRate));
  const endSample = Math.min(pcmTotalSamples, Math.ceil((endMs / 1000) * sampleRate));
  if (endSample <= startSample) {
    return new Float32Array();
  }

  const output = new Float32Array(endSample - startSample);
  let writeOffset = 0;
  let cursor = pcmStartSample;
  for (const buffer of pcmBuffers) {
    const bufferStart = cursor;
    const bufferEnd = cursor + buffer.length;
    if (bufferEnd > startSample && bufferStart < endSample) {
      const from = Math.max(0, startSample - bufferStart);
      const to = Math.min(buffer.length, endSample - bufferStart);
      output.set(buffer.subarray(from, to), writeOffset);
      writeOffset += to - from;
    }
    cursor = bufferEnd;
    if (cursor >= endSample) {
      break;
    }
  }
  return output.subarray(0, writeOffset);
}

function trimPcmBuffers() {
  if (!audioContext) {
    return;
  }
  const nextNeededMs = Math.max(0, emittedChunkCount * chunkMs - CHUNK_OVERLAP_MS - 1000);
  const keepFromSample = Math.floor((nextNeededMs / 1000) * audioContext.sampleRate);
  while (pcmBuffers.length && pcmStartSample + pcmBuffers[0].length <= keepFromSample) {
    pcmStartSample += pcmBuffers[0].length;
    pcmBuffers.shift();
  }
  if (pcmBuffers.length && keepFromSample > pcmStartSample) {
    const drop = keepFromSample - pcmStartSample;
    pcmBuffers[0] = pcmBuffers[0].slice(drop);
    pcmStartSample = keepFromSample;
  }
}

function resampleLinear(samples, sourceRate, targetRate) {
  if (sourceRate === targetRate) {
    return samples;
  }
  const ratio = sourceRate / targetRate;
  const outputLength = Math.max(1, Math.round(samples.length / ratio));
  const output = new Float32Array(outputLength);
  for (let index = 0; index < outputLength; index += 1) {
    const position = index * ratio;
    const left = Math.floor(position);
    const right = Math.min(samples.length - 1, left + 1);
    const weight = position - left;
    output[index] = samples[left] * (1 - weight) + samples[right] * weight;
  }
  return output;
}

function encodeWavBlob(samples, sampleRate) {
  const bytesPerSample = 2;
  const buffer = new ArrayBuffer(44 + samples.length * bytesPerSample);
  const view = new DataView(buffer);
  writeAscii(view, 0, "RIFF");
  view.setUint32(4, 36 + samples.length * bytesPerSample, true);
  writeAscii(view, 8, "WAVE");
  writeAscii(view, 12, "fmt ");
  view.setUint32(16, 16, true);
  view.setUint16(20, 1, true);
  view.setUint16(22, 1, true);
  view.setUint32(24, sampleRate, true);
  view.setUint32(28, sampleRate * bytesPerSample, true);
  view.setUint16(32, bytesPerSample, true);
  view.setUint16(34, 16, true);
  writeAscii(view, 36, "data");
  view.setUint32(40, samples.length * bytesPerSample, true);
  let offset = 44;
  for (const sample of samples) {
    const clamped = Math.max(-1, Math.min(1, sample));
    view.setInt16(offset, clamped < 0 ? clamped * 0x8000 : clamped * 0x7fff, true);
    offset += bytesPerSample;
  }
  return new Blob([buffer], { type: "audio/wav" });
}

function writeAscii(view, offset, text) {
  for (let index = 0; index < text.length; index += 1) {
    view.setUint8(offset + index, text.charCodeAt(index));
  }
}

async function ensureRemoteSessionStarted() {
  if (remoteSessionStarted) {
    return;
  }
  await ensureStartPayloadAuth();
  const response = await callCompanion("/v1/session/start", startPayload);
  const previousSessionId = sessionId;
  sessionId = response.sessionId || sessionId;
  if (previousSessionId !== sessionId) {
    for (const queuedChunk of pendingChunks) {
      if (queuedChunk.payload) {
        queuedChunk.payload.sessionId = sessionId;
      }
    }
  }
  remoteSessionStarted = true;
  if (response.session) {
    await refreshSessionStatus(response.session);
  }
}

async function ensureStartPayloadAuth() {
  if (!startPayload) {
    throw new Error("Сесію запису ще не підготовлено.");
  }
  if (!startPayload.auth) {
    startPayload.auth = await getLicenseAuth();
    startPayload.extensionVersion = chrome.runtime.getManifest().version;
  }
  return startPayload.auth;
}

async function flushPendingQueue(sendFinish = finishRequested) {
  recoverRetryableBlockedQueue();
  if (queueBlockedReason || pendingChunks.some((chunk) => chunk.status === "blocked")) {
    queueBlockedReason = queueBlockedReason || "Попередня черга вже зупинена. Очистіть її перед новим записом.";
    markPendingChunksBlocked();
    await persistSessionState().catch(() => {});
    setStatus(`Черга заблокована: ${queueBlockedReason}`, true);
    updatePendingCount();
    return false;
  }
  if (flushInProgress) {
    scheduleFlush(FLUSH_RETRY_MS);
    return false;
  }
  flushInProgress = true;
  try {
    try {
      await ensureRemoteSessionStarted();
    } catch (error) {
      if (isNonRetryableCompanionError(error)) {
        await blockPendingQueue(error);
        return false;
      }
      throw error;
    }
    const ordered = [...pendingChunks].sort((a, b) => a.index - b.index);
    for (const queuedChunk of ordered) {
      if (queuedChunk.status === "encoding") {
        scheduleFlush(500);
        return false;
      }
      if (queuedChunk.status === "error" || queuedChunk.status === "blocked") {
        continue;
      }
      if (!queuedChunk.payload) {
        queuedChunk.status = "error";
        queuedChunk.error = "Старий зашифрований чанк неможливо дослати цією версією.";
        updatePendingCount();
        continue;
      }
      queuedChunk.status = "sending";
      updatePendingCount();
      try {
        const result = await callCompanion("/v1/session/chunk", queuedChunk.payload);
        pendingChunks = pendingChunks.filter((item) => item !== queuedChunk);
        await deletePersistedQueuedChunk(queuedChunk);
        if (queuedChunk.payload?.finalChunk) {
          await clearSafetySnapshot(sessionId).catch(() => {});
        }
        updatePendingCount();
        await refreshSessionStatus(result.session || null);
      } catch (error) {
        if (isNonRetryableCompanionError(error)) {
          await blockPendingQueue(error);
          return false;
        }
        queuedChunk.status = "ready";
        setStatus(`${companionUserMessage(error)} У черзі ${livePendingCount()} чанків, повторю автоматично. Запис не втрачено.`, true);
        scheduleFlush(FLUSH_RETRY_MS);
        return false;
      }
    }
    if (sendFinish && remoteSessionStarted && !livePendingCount()) {
      const result = await callCompanion("/v1/session/finish", {
        sessionId,
        auth: await getLicenseAuth(),
        clientFinishedAt: clientFinishedAt || new Date().toISOString()
      });
      await refreshSessionStatus(result.session || null);
      if (isServerSessionFinal(result.session || null)) {
        finishRequested = false;
        consultationFinished = true;
        await clearPersistedSession();
        await clearConsultationRuntimeState();
        clearConsultationAlarms();
        clearLocalLongRecordingReminder();
      } else {
        finishRequested = true;
        consultationFinished = false;
        await persistSessionState().catch(() => {});
        scheduleFlush(FLUSH_RETRY_MS);
        return false;
      }
    }
    return remoteSessionStarted && livePendingCount() === 0;
  } catch (error) {
    setStatus(`${companionUserMessage(error)} У черзі ${livePendingCount()} чанків, повторю автоматично. Запис не втрачено.`, true);
    scheduleFlush(FLUSH_RETRY_MS);
    return false;
  } finally {
    flushInProgress = false;
    updatePendingCount();
  }
}

async function blockPendingQueue(error) {
  queueBlockedReason = companionUserMessage(error);
  markPendingChunksBlocked();
  if (flushTimer) {
    clearTimeout(flushTimer);
    flushTimer = null;
  }
  await persistSessionState().catch(() => {});
  updatePendingCount();
  setStatus(`Черга зупинена: ${queueBlockedReason}`, true);
}

function markPendingChunksBlocked() {
  for (const chunk of pendingChunks) {
    if (chunk.status !== "error") {
      chunk.status = "blocked";
    }
  }
}

function scheduleFlush(delayMs) {
  if (flushTimer) {
    clearTimeout(flushTimer);
  }
  flushTimer = setTimeout(() => {
    flushTimer = null;
    flushPendingQueue(finishRequested).catch(() => {});
  }, delayMs);
}

function updatePendingCount() {
  const livePending = livePendingCount();
  elsRecorder.pendingCount.textContent = String(livePending);
  updateStaleQueueControls(livePending);
  updateExportButtonState();
  renderChunkPipelineStatus();
}

function updateStaleQueueControls(livePending = livePendingCount()) {
  if (!elsRecorder.clearStaleQueueButton) {
    return;
  }
  const canCleanupRemote = isRetryableBlockedQueueReason(queueBlockedReason);
  const canClear = !recordingActive && (Boolean(sessionId) && livePending > 0 || canCleanupRemote);
  elsRecorder.clearStaleQueueButton.hidden = !canClear;
  elsRecorder.clearStaleQueueButton.disabled = !canClear || flushInProgress;
  elsRecorder.clearStaleQueueButton.textContent = livePending > 0 ? "Очистити чергу" : "Очистити старі сесії";
}

function livePendingChunks() {
  return pendingChunks.filter((chunk) => chunk.status !== "error");
}

function livePendingCount() {
  return livePendingChunks().length;
}

function chunkPipelineSummary() {
  const chunks = Array.isArray(pendingChunks) ? pendingChunks : [];
  const total = Math.max(chunkIndex, chunks.length);
  if (!total && !lastQueuedAudioInfo) {
    return { state: "idle", label: "ще немає аудіофрагментів" };
  }
  const counts = chunks.reduce((acc, chunk) => {
    const key = chunk.status || "unknown";
    acc[key] = (acc[key] || 0) + 1;
    return acc;
  }, {});
  const livePending = livePendingCount();
  if (queueBlockedReason) {
    return { state: "blocked", label: `черга заблокована: ${queueBlockedReason}` };
  }
  if (counts.sending) {
    return { state: "sending", label: `відправляю ${counts.sending}; у черзі ${livePending}` };
  }
  if (counts.encoding) {
    return { state: "encoding", label: `готую WAV ${counts.encoding}; у черзі ${livePending}` };
  }
  if (counts.ready) {
    return { state: "queued", label: `очікує відправки ${counts.ready}; у черзі ${livePending}` };
  }
  if (counts.blocked) {
    return { state: "blocked", label: `заблоковано ${counts.blocked}; потрібна дія` };
  }
  if (counts.error) {
    return { state: "error", label: `помилка підготовки ${counts.error}; зробіть експорт або діагностику` };
  }
  if (lastQueuedAudioInfo?.buffered) {
    return { state: "recording", label: `записую буфер ${formatAudioFragmentInfo(lastQueuedAudioInfo)}` };
  }
  const processed = Number(latestMetrics?.doneChunkCount || 0);
  const done = processed || Math.max(0, total - livePending);
  const label = total ? `готово ${done}/${total}` : "очікую перший аудіофрагмент";
  return { state: recordingActive ? "recording" : "done", label };
}

function renderChunkPipelineStatus() {
  if (!elsRecorder.chunkPipelineStatus) {
    return;
  }
  const summary = chunkPipelineSummary();
  elsRecorder.chunkPipelineStatus.dataset.state = summary.state;
  elsRecorder.chunkPipelineStatus.textContent = `Останній чанк: ${summary.label}.`;
}

function recoverRetryableBlockedQueue() {
  if (!queueBlockedReason || !isRetryableBlockedQueueReason(queueBlockedReason)) {
    return;
  }
  queueBlockedReason = "";
  for (const chunk of pendingChunks) {
    if (chunk.status === "blocked" && chunk.payload) {
      chunk.status = "ready";
    }
  }
}

function isRetryableBlockedQueueReason(reason) {
  return /too many active sessions|забагато незавершених сесій/i.test(String(reason || ""));
}

async function waitForEncodingQueue(timeoutMs = ENCODING_WAIT_MS) {
  const deadline = Date.now() + timeoutMs;
  while (encodingTasks.size || pendingChunks.some((chunk) => chunk.status === "encoding")) {
    const remaining = deadline - Date.now();
    if (remaining <= 0) {
      return false;
    }
    const tasks = Array.from(encodingTasks);
    if (tasks.length) {
      await Promise.race([
        Promise.allSettled(tasks),
        sleep(Math.min(500, remaining))
      ]);
    } else {
      await sleep(Math.min(100, remaining));
    }
  }
  return true;
}

function sleep(ms) {
  return new Promise((resolve) => setTimeout(resolve, ms));
}

function hasUnsafeRecorderState() {
  return Boolean(
    recordingActive ||
    livePendingCount() > 0 ||
    finishRequested ||
    encodingTasks.size > 0 ||
    flushInProgress ||
    queueBlockedReason ||
    (sessionId && !consultationFinished)
  );
}

function handleRecorderPageHide() {
  if (!sessionId) {
    return;
  }
  persistSessionState().catch(() => {});
  requestMediaRecorderData("закриття сторінки").catch(() => {});
  if (pcmTotalSamples) {
    persistSafetySnapshot({ force: true }).catch(() => {});
  }
}

function handleRecorderVisibilityChange() {
  if (document.visibilityState === "hidden") {
    handleRecorderPageHide();
    return;
  }
  if (recordingActive && audioContext?.state === "suspended") {
    audioContext.resume().catch(() => {});
    setStatus("Повернувся до recorder. Якщо Mac засинав, перевірте, чи рухається смужка мікрофона.", true);
  }
  refreshMicrophoneDevices({ keepSelection: true }).catch(() => {});
  if (sessionId && (livePendingCount() || finishRequested) && !queueBlockedReason) {
    scheduleFlush(1000);
  }
}

async function restorePendingSession() {
  const state = await loadPersistedSession();
  if (!state?.sessionId) {
    return "idle";
  }
  sessionId = state.sessionId;
  startPayload = state.startPayload || null;
  chunkMs = normalizeChunkMs(state.chunkMs || DEFAULT_CHUNK_MS);
  finishRequested = Boolean(state.finishRequested);
  clientFinishedAt = state.clientFinishedAt || "";
  queueBlockedReason = state.queueBlockedReason || "";
  appliedLakmusValues = state.appliedLakmusValues || {};
  draftReviewValues = sanitizeDraftReviewValues(state.draftReviewValues || {});
  latestSafetySave = state.latestSafetySave || null;
  lastQueuedAudioInfo = state.lastQueuedAudioInfo || null;
  renderSafetyStatus();
  startedAtMs = Number(state.startedAtMs || Date.parse(startPayload?.clientStartedAt || "")) || 0;
  elsRecorder.chunkMs.value = String(chunkMs);
  renderLakmusDraftForm(null);
  updateDraftActionButtons();
  pendingChunks = await loadPersistedQueuedChunks(sessionId);
  resumePersistedEncodingChunks();
  const recoveredSafetySnapshot = await recoverSafetySnapshot().catch(() => false);
  if (queueBlockedReason) {
    markPendingChunksBlocked();
  }
  chunkIndex = pendingChunks.reduce((max, chunk) => Math.max(max, chunk.index + 1), 0);
  elsRecorder.chunkCount.textContent = String(chunkIndex);
  updatePendingCount();
  if (!pendingChunks.length && finishRequested && chunkIndex === 0) {
    await clearPersistedSession();
    sessionId = "";
    startPayload = null;
    finishRequested = false;
    clientFinishedAt = "";
    setStatus("Порожній попередній запис очищено. Можна починати нову консультацію.");
    return "idle";
  }
  if (pendingChunks.length) {
    if (!latestSafetySave) {
      markSafetySaved("чергу відновлено");
    }
    await markConsultationRuntimeState("stopped");
    scheduleConsultationAlarms("stopped");
    if (queueBlockedReason) {
      setStatus(`Попередня черга заблокована: ${queueBlockedReason} Якщо це старий запис, скопіюйте потрібний текст або натисніть "Очистити чергу".`, true);
      return "stopped";
    }
    const recoveredPrefix = recoveredSafetySnapshot ? "Відновлено аварійний фрагмент після переривання сторінки. " : "";
    setStatus(`${recoveredPrefix}Є незавершена черга з попереднього запису: ${livePendingCount()} чанків. Натисніть "Перевірити" або "Завершити".`, true);
    scheduleFlush(1000);
    return "stopped";
  } else if (state.recordingActive) {
    await markConsultationRuntimeState("stopped");
    scheduleConsultationAlarms("stopped");
    setStatus("Попередній запис було перервано або вікно закрили. Натисніть \"Завершити\" для закриття сесії або \"Новий запис\" для початку іншої консультації.", true);
    return "stopped";
  } else if (finishRequested) {
    await markConsultationRuntimeState("finish_pending");
    scheduleConsultationAlarms("finish_pending");
    setStatus("Є незавершене завершення консультації. Натисніть \"Перевірити\".", true);
    scheduleFlush(1000);
    return "finish_pending";
  }
  return "stopped";
}

async function persistSessionState() {
  if (!sessionId) {
    return;
  }
  const db = await openRecorderDb();
  const savedAt = new Date().toISOString();
  const nextSafetySave = {
    label: "стан збережено",
    at: savedAt
  };
  await putStoreValue(db, STATE_STORE, {
    id: "current",
    sessionId,
    startPayload,
    chunkMs,
    finishRequested,
    clientFinishedAt,
    queueBlockedReason,
    appliedLakmusValues,
    draftReviewValues,
    latestSafetySave: nextSafetySave,
    lastQueuedAudioInfo,
    recordingActive,
    startedAtMs,
    updatedAt: savedAt
  });
  latestSafetySave = nextSafetySave;
  renderSafetyStatus();
}

async function loadPersistedSession() {
  const db = await openRecorderDb();
  return getStoreValue(db, STATE_STORE, "current");
}

async function persistQueuedChunk(queuedChunk) {
  const db = await openRecorderDb();
  await putStoreValue(db, QUEUE_STORE, {
    id: `${sessionId}:${queuedChunk.index}`,
    sessionId,
    index: queuedChunk.index,
    payload: queuedChunk.payload,
    status: queuedChunk.status,
    audioBlob: queuedChunk.payload ? null : (queuedChunk.audioBlob || null),
    finalChunk: Boolean(queuedChunk.finalChunk),
    mimeType: queuedChunk.audioBlob?.type || queuedChunk.payload?.mimeType || "audio/wav",
    offsetMs: queuedChunk.offsetMs,
    overlapMs: queuedChunk.overlapMs,
    nominalStartMs: queuedChunk.nominalStartMs,
    createdAt: new Date().toISOString()
  });
  markSafetySaved(queuedChunk.payload ? "чергу збережено" : "аудіо збережено");
}

function startSafetySnapshotTimer() {
  stopSafetySnapshotTimer();
  safetySnapshotTimer = setInterval(() => {
    persistSafetySnapshot().catch(() => {});
    monitorPcmChunkEmission();
    monitorMediaRecorderStall();
  }, SAFETY_SNAPSHOT_INTERVAL_MS);
  persistSafetySnapshot().catch(() => {});
}

function monitorPcmChunkEmission() {
  if (!recordingActive || !pcmFallbackMode) {
    return;
  }
  emitDuePcmChunks(false);
  persistSessionState().catch(() => {});
}

function stopSafetySnapshotTimer() {
  if (safetySnapshotTimer) {
    clearInterval(safetySnapshotTimer);
    safetySnapshotTimer = null;
  }
}

async function persistSafetySnapshot(options = {}) {
  const requestedMediaRecorderData = await requestMediaRecorderData(options.force ? "аварійний фрагмент запитано" : "поточний фрагмент запитано");
  if (await persistMediaRecorderBufferSnapshot()) {
    return;
  }
  const recorderFresh = requestedMediaRecorderData && lastMediaRecorderFragmentAt && (Date.now() - lastMediaRecorderFragmentAt < MEDIA_RECORDER_STALL_WARNING_MS);
  if (requestedMediaRecorderData && recorderFresh && !options.force && !pcmFallbackMode) {
    return;
  }
  if (!sessionId || !audioContext || !pcmTotalSamples) {
    return;
  }
  const sampleRate = audioContext.sampleRate;
  const recordedMs = (pcmTotalSamples / sampleRate) * 1000;
  const overlapMs = emittedChunkCount === 0 ? 0 : CHUNK_OVERLAP_MS;
  const offsetMs = Math.max(0, emittedChunkCount * chunkMs - overlapMs);
  const durationMs = recordedMs - offsetMs;
  if (durationMs < MIN_SAFETY_SNAPSHOT_MS && !options.force) {
    return;
  }
  const samples = collectPcmRange(offsetMs, recordedMs);
  if (!samples.length) {
    return;
  }
  const wavSamples = resampleLinear(samples, sampleRate, WAV_SAMPLE_RATE);
  const blob = encodeWavBlob(wavSamples, WAV_SAMPLE_RATE);
  const audioBase64 = await blobToBase64(blob);
  const db = await openRecorderDb();
  await putStoreValue(db, SAFETY_STORE, {
    id: sessionId,
    sessionId,
    chunkIndex: emittedChunkCount,
    offsetMs,
    overlapMs,
    endMs: recordedMs,
    durationMs,
    mimeType: blob.type || "audio/wav",
    audioBase64,
    capturedAt: new Date().toISOString(),
    updatedAt: new Date().toISOString()
  });
  markSafetySaved("аварійний фрагмент збережено");
}

async function persistMediaRecorderBufferSnapshot() {
  if (!sessionId || !mediaRecorderFragments.length) {
    return false;
  }
  const fragments = [...mediaRecorderFragments];
  const offsetMs = mediaRecorderBufferStartMs;
  const endMs = mediaRecorderBufferEndMs;
  const durationMs = endMs - offsetMs;
  if (durationMs < MIN_SAFETY_SNAPSHOT_MS) {
    return false;
  }
  const mimeType = mediaRecorder?.mimeType || fragments[0]?.blob?.type || "audio/webm";
  const blob = new Blob(fragments.map((fragment) => fragment.blob), { type: mimeType });
  const audioBase64 = await blobToBase64(blob);
  const db = await openRecorderDb();
  await putStoreValue(db, SAFETY_STORE, {
    id: sessionId,
    sessionId,
    chunkIndex: emittedChunkCount,
    offsetMs,
    nominalStartMs: offsetMs,
    overlapMs: 0,
    endMs,
    durationMs,
    mimeType,
    audioBase64,
    capturedAt: new Date().toISOString(),
    updatedAt: new Date().toISOString()
  });
  markSafetySaved("аварійний фрагмент збережено");
  return true;
}

async function requestMediaRecorderData(label) {
  if (pcmFallbackMode || !sessionId || !recordingActive || !mediaRecorder || mediaRecorder.state !== "recording") {
    return false;
  }
  try {
    lastMediaRecorderRequestAt = Date.now();
    mediaRecorder.requestData();
    markSafetySaved(label || "поточний фрагмент запитано");
    return true;
  } catch {
    return false;
  }
}

function monitorMediaRecorderStall() {
  if (!recordingActive || !mediaRecorder || mediaRecorder.state !== "recording") {
    return;
  }
  const now = Date.now();
  const lastFragmentAt = lastMediaRecorderFragmentAt || mediaRecorderStartedAtMs || startedAtMs || now;
  if (now - lastFragmentAt < MEDIA_RECORDER_STALL_WARNING_MS) {
    return;
  }
  if (!pcmFallbackMode && pcmTotalSamples > 0) {
    pcmFallbackMode = true;
    lastPcmFallbackAt = now;
    clearMediaRecorderBuffer();
    setStatus("Chrome давно не віддає аудіофрагмент. Перемикаюся на аварійний WAV-запис через WebAudio; запис і локальне збереження тривають.", true);
  }
  if (pcmFallbackMode) {
    emitDuePcmChunks(false);
    persistSessionState().catch(() => {});
    return;
  }
  if (lastMediaRecorderStallWarningAt && now - lastMediaRecorderStallWarningAt < MEDIA_RECORDER_STALL_REPEAT_MS) {
    return;
  }
  lastMediaRecorderStallWarningAt = now;
  setStatus("Chrome давно не віддав аудіофрагмент для транскрипції. Запис не зупинено, але якщо це повторюється - натисніть Стоп, збережіть TXT і почніть новий запис.", true);
}

async function recoverSafetySnapshot() {
  if (!sessionId) {
    return false;
  }
  const db = await openRecorderDb();
  const snapshot = await getStoreValue(db, SAFETY_STORE, sessionId);
  if (!snapshot?.audioBase64 || snapshot.sessionId !== sessionId) {
    return false;
  }
  const snapshotIndex = Number(snapshot.chunkIndex || 0);
  if (pendingChunks.some((chunk) => chunk.index === snapshotIndex)) {
    await clearSafetySnapshot(sessionId).catch(() => {});
    return false;
  }
  if (Number(snapshot.durationMs || 0) < MIN_SAFETY_SNAPSHOT_MS) {
    await clearSafetySnapshot(sessionId).catch(() => {});
    return false;
  }
  const queuedChunk = {
    index: snapshotIndex,
    offsetMs: Number(snapshot.offsetMs || 0),
    overlapMs: Number(snapshot.overlapMs || 0),
    payload: {
      sessionId,
      chunkIndex: snapshotIndex,
      mimeType: snapshot.mimeType || "audio/wav",
      audioBase64: snapshot.audioBase64,
      offsetSeconds: Math.max(0, roundSeconds(Number(snapshot.offsetMs || 0) / 1000)),
      nominalStartSeconds: Math.max(0, roundSeconds(Number(snapshot.nominalStartMs ?? snapshot.offsetMs ?? (snapshotIndex * chunkMs)) / 1000)),
      overlapSeconds: Math.max(0, roundSeconds(Number(snapshot.overlapMs || 0) / 1000)),
      finalChunk: true,
      recoveredAfterCrash: true,
      source: "microphone",
      audioPipeline: "webaudio_pcm_wav",
      liveChunkPolicy: ALLOW_MEDIA_RECORDER_LIVE_CHUNKS ? "mediarecorder_allowed" : "wav_only",
      extensionVersion: chrome.runtime.getManifest().version,
      auth: await getLicenseAuth(),
      capturedAt: snapshot.capturedAt || new Date().toISOString()
    },
    status: "ready",
    recovered: true
  };
  pendingChunks.push(queuedChunk);
  await persistQueuedChunk(queuedChunk);
  await clearSafetySnapshot(sessionId).catch(() => {});
  markSafetySaved("фрагмент відновлено");
  return true;
}

async function clearSafetySnapshot(targetSessionId = sessionId) {
  if (!targetSessionId) {
    return;
  }
  const db = await openRecorderDb();
  await deleteStoreValue(db, SAFETY_STORE, targetSessionId);
}

async function loadPersistedQueuedChunks(targetSessionId) {
  const db = await openRecorderDb();
  const rows = await getAllStoreValues(db, QUEUE_STORE);
  return rows
    .filter((row) => row.sessionId === targetSessionId)
    .sort((a, b) => a.index - b.index)
    .map((row) => ({
      index: row.index,
      offsetMs: row.offsetMs || 0,
      overlapMs: row.overlapMs || 0,
      nominalStartMs: Number.isFinite(Number(row.nominalStartMs)) ? Number(row.nominalStartMs) : null,
      payload: row.payload || null,
      audioBlob: row.audioBlob || null,
      finalChunk: Boolean(row.finalChunk || row.payload?.finalChunk),
      status: row.payload ? "ready" : (row.audioBlob ? "encoding" : "error"),
      error: row.payload || row.audioBlob ? "" : "Збережений аудіофрагмент не містить аудіо."
    }));
}

function resumePersistedEncodingChunks() {
  for (const queuedChunk of pendingChunks) {
    if (queuedChunk.status !== "encoding" || !queuedChunk.audioBlob) {
      continue;
    }
    const encodeTask = encodeQueuedChunk(queuedChunk, queuedChunk.audioBlob, queuedChunk.finalChunk).catch((error) => {
      queuedChunk.status = "error";
      queuedChunk.error = error.message || String(error);
      setStatus(`Не вдалося відновити аудіофрагмент: ${error.message || String(error)}`, true);
      persistQueuedChunk(queuedChunk).catch(() => {});
    }).finally(() => {
      encodingTasks.delete(encodeTask);
    });
    encodingTasks.add(encodeTask);
  }
}

async function deletePersistedQueuedChunk(queuedChunk) {
  const db = await openRecorderDb();
  await deleteStoreValue(db, QUEUE_STORE, `${sessionId}:${queuedChunk.index}`);
}

async function archivePersistedSessionBeforeClear(reason) {
  if (!sessionId) {
    return null;
  }
  const db = await openRecorderDb();
  const targetSessionId = sessionId;
  const [state, chunks, safetySnapshot] = await Promise.all([
    getStoreValue(db, STATE_STORE, "current"),
    getAllStoreValues(db, QUEUE_STORE),
    getStoreValue(db, SAFETY_STORE, targetSessionId)
  ]);
  const targetChunks = chunks
    .filter((chunk) => chunk.sessionId === targetSessionId)
    .sort((a, b) => Number(a.index || 0) - Number(b.index || 0));
  const stateMatches = state?.sessionId === targetSessionId;
  if (!stateMatches && !targetChunks.length && !safetySnapshot) {
    return null;
  }
  const createdAt = new Date().toISOString();
  const archive = {
    id: `${createdAt.replace(/[-:.TZ]/g, "").slice(0, 14)}-${targetSessionId}`,
    sessionId: targetSessionId,
    reason: String(reason || "manual_clear"),
    createdAt,
    chunkCount: targetChunks.length,
    hasSafetySnapshot: Boolean(safetySnapshot),
    state: stateMatches ? sanitizeArchivedSessionState(state) : null,
    chunks: targetChunks.map(sanitizeArchivedQueuedChunk),
    safetySnapshot: safetySnapshot ? sanitizeArchivedSafetySnapshot(safetySnapshot) : null
  };
  await putStoreValue(db, ARCHIVE_STORE, archive);
  await pruneRecoveryArchives(db);
  markSafetySaved("резервну копію черги збережено", createdAt);
  return archive;
}

function sanitizeArchivedSessionState(state) {
  const clone = structuredCloneSafe(state);
  if (clone.startPayload?.auth) {
    delete clone.startPayload.auth;
    clone.startPayload.authRemoved = true;
  }
  return clone;
}

function sanitizeArchivedQueuedChunk(chunk) {
  const clone = structuredCloneSafe(chunk);
  if (clone.payload?.auth) {
    delete clone.payload.auth;
    clone.payload.authRemoved = true;
  }
  return clone;
}

function sanitizeArchivedSafetySnapshot(snapshot) {
  return structuredCloneSafe(snapshot);
}

function structuredCloneSafe(value) {
  if (typeof structuredClone === "function") {
    return structuredClone(value);
  }
  return JSON.parse(JSON.stringify(value));
}

async function pruneRecoveryArchives(db) {
  const archives = await getAllStoreValues(db, ARCHIVE_STORE);
  const staleArchives = archives
    .sort((a, b) => String(b.createdAt || "").localeCompare(String(a.createdAt || "")))
    .slice(MAX_RECOVERY_ARCHIVES);
  await Promise.all(staleArchives.map((archive) => deleteStoreValue(db, ARCHIVE_STORE, archive.id)));
}

async function loadRecoveryArchiveSummary() {
  const db = await openRecorderDb();
  const archives = await getAllStoreValues(db, ARCHIVE_STORE);
  const sorted = archives.sort((a, b) => String(b.createdAt || "").localeCompare(String(a.createdAt || "")));
  const latest = sorted[0] || null;
  return {
    count: sorted.length,
    latestCreatedAt: latest?.createdAt || "",
    latestSessionId: latest?.sessionId || "",
    latestChunkCount: Number(latest?.chunkCount || 0)
  };
}

async function clearPersistedSession() {
  const db = await openRecorderDb();
  const chunks = await getAllStoreValues(db, QUEUE_STORE);
  const targetSessionId = sessionId;
  await Promise.all(chunks
    .filter((chunk) => chunk.sessionId === targetSessionId)
    .map((chunk) => deleteStoreValue(db, QUEUE_STORE, chunk.id)));
  await clearSafetySnapshot(targetSessionId).catch(() => {});
  await deleteStoreValue(db, STATE_STORE, "current");
}

async function clearStaleQueueWithConfirmation() {
  if (recordingActive) {
    setStatus("Під час активного запису чергу очищати не можна.", true);
    return;
  }
  if (flushInProgress || encodingTasks.size) {
    setStatus("Черга зараз обробляється. Зачекайте кілька секунд і повторіть.", true);
    return;
  }
  const livePending = livePendingCount();
  if ((!sessionId || !livePending) && isRetryableBlockedQueueReason(queueBlockedReason)) {
    const confirmed = confirmRecorderDestructiveAction({
      phrase: "СЕСІЇ",
      title: "Закрити старі незавершені сесії на сервері?",
      body: "Це закриє попередні незавершені сесії цієї ліцензії на цьому пристрої, щоб новий запис міг стартувати. Уже збережені транскрипції та файли консультацій не видаляються."
    });
    if (!confirmed) {
      setStatus("Старі сесії сервера не змінено.");
      return;
    }
    await cleanupRemoteStaleSessions();
    queueBlockedReason = "";
    await archivePersistedSessionBeforeClear("manual_remote_stale_sessions_cleanup").catch(() => {});
    await clearPersistedSession().catch(() => {});
    await clearConsultationRuntimeState();
    resetSessionMemory();
    resetRecorderOutputs();
    renderRecorderState("idle");
    return;
  }
  if (!sessionId || !livePending) {
    setStatus("Немає недосланої черги для очищення.");
    updateStaleQueueControls();
    return;
  }
  const ok = confirmRecorderDestructiveAction({
    phrase: "ОЧИСТИТИ",
    title: `Очистити локальну чергу з ${livePending} чанків?`,
    body: "Це видалить недослані аудіофрагменти попереднього запису на цьому комп'ютері. Вже збережена транскрипція або текст для полів, якщо вони є на екрані, не зміняться."
  });
  if (!ok) {
    setStatus("Чергу не очищено.");
    return;
  }
  if (isRetryableBlockedQueueReason(queueBlockedReason)) {
    await cleanupRemoteStaleSessions().catch((error) => {
      setStatus(`Не вдалося закрити старі сесії сервера: ${companionUserMessage(error)}`, true);
    });
  }
  if (flushTimer) {
    clearTimeout(flushTimer);
    flushTimer = null;
  }
  flushInProgress = false;
  const archive = await archivePersistedSessionBeforeClear("manual_clear_local_queue").catch(() => null);
  await clearPersistedSession().catch(() => {});
  await clearConsultationRuntimeState();
  clearConsultationAlarms();
  clearLocalLongRecordingReminder();
  resetSessionMemory();
  resetRecorderOutputs();
  renderRecorderState("idle");
  setStatus(archive
    ? "Стару локальну чергу очищено. Аварійну копію збережено локально; можна починати новий запис."
    : "Стару локальну чергу очищено. Можна починати новий запис.");
}

async function cleanupRemoteStaleSessions() {
  setStatus("Закриваю старі незавершені сесії сервера...");
  const result = await callCompanion("/v1/sessions/cleanup", {
    auth: await getLicenseAuth(),
    excludeSessionId: sessionId || ""
  });
  const closedCount = Number(result.closedCount || 0);
  setStatus(closedCount
    ? `Старі сесії сервера закрито: ${closedCount}. Можна починати новий запис.`
    : "На сервері не було старих сесій для цієї ліцензії.");
  return result;
}

async function prepareNewRecordingIfNeeded() {
  if (!sessionId && !pendingChunks.length && !finishRequested) {
    return true;
  }
  if (recordingActive) {
    setStatus("Запис уже триває.", true);
    return false;
  }

  const livePending = livePendingCount();
  const hasUsefulOutput = Boolean(String(latestTranscript || "").trim()) || hasLakmusDraftValues(latestDraft);
  if (!livePending && !finishRequested && !hasUsefulOutput) {
    await clearPersistedSession().catch(() => {});
    await clearConsultationRuntimeState();
    clearConsultationAlarms();
    clearLocalLongRecordingReminder();
    resetSessionMemory();
    resetRecorderOutputs();
    setStatus("Порожній попередній запис очищено. Починаю новий запис...");
    return true;
  }
  const warning = queueBlockedReason
    ? `Є заблокована черга попереднього запису: ${queueBlockedReason}`
    : livePending
      ? `Є попередній запис із ${livePending} недосланими чанками.`
    : finishRequested
      ? "Є попередня консультація, завершення якої ще не підтверджене."
      : "Є попередня консультація у recorder.";
  const ok = window.confirm(`${warning}\n\nOK: почати новий запис і очистити локальний стан цього recorder-вікна.\nCancel: повернутися і спочатку натиснути "Завершити" або "Експорт TXT".`);
  if (!ok) {
    setStatus("Новий запис не почато. Завершіть або експортуйте попередню консультацію.");
    return false;
  }
  const confirmed = confirmRecorderDestructiveAction({
    phrase: "НОВИЙ ЗАПИС",
    title: "Почати новий запис і очистити попередній локальний стан?",
    body: "Якщо у попередній консультації ще є недослані чанки, вони будуть видалені з цього комп'ютера. Спочатку експортуйте або скопіюйте важливий текст, якщо він потрібен."
  });
  if (!confirmed) {
    setStatus("Новий запис не почато. Завершіть або експортуйте попередню консультацію.");
    return false;
  }

  const archive = await archivePersistedSessionBeforeClear("new_recording_replaces_previous_state").catch(() => null);
  await clearPersistedSession().catch(() => {});
  await clearConsultationRuntimeState();
  clearConsultationAlarms();
  clearLocalLongRecordingReminder();
  resetSessionMemory();
  resetRecorderOutputs();
  setStatus(archive
    ? "Попередній локальний стан очищено, аварійну копію збережено. Починаю новий запис..."
    : "Попередній локальний стан очищено. Починаю новий запис...");
  return true;
}

function confirmRecorderDestructiveAction({ phrase, title, body }) {
  const value = window.prompt(`${title}\n\n${body}\n\nЩоб підтвердити, введіть: ${phrase}`);
  return String(value || "").trim().toUpperCase() === phrase;
}

function resetSessionMemory() {
  stopSafetySnapshotTimer();
  stopAudioMeterLoop();
  if (draftReviewSaveTimer) {
    clearTimeout(draftReviewSaveTimer);
    draftReviewSaveTimer = null;
  }
  mediaRecorder = null;
  mediaRecorderStopPromise = null;
  audioAnalyser = null;
  startInProgress = false;
  startCancelRequested = false;
  sessionId = "";
  chunkIndex = 0;
  emittedChunkCount = 0;
  latestTranscript = "";
  latestSegments = [];
  latestDraft = null;
  latestMetrics = null;
  latestSafetySave = null;
  firstTranscriptRevealed = false;
  pendingChunks = [];
  queueBlockedReason = "";
  encodingTasks = new Set();
  appliedLakmusValues = {};
  draftReviewValues = {};
  pcmBuffers = [];
  pcmTotalSamples = 0;
  pcmStartSample = 0;
  clearMediaRecorderBuffer();
  lastMediaRecorderRequestAt = 0;
  lastMediaRecorderFragmentAt = 0;
  lastMediaRecorderStallWarningAt = 0;
  lastQueuedAudioInfo = null;
  lastAudibleAudioAt = 0;
  lastQuietAudioWarningAt = 0;
  remoteSessionStarted = false;
  finishRequested = false;
  clientFinishedAt = "";
  consultationFinished = false;
  startPayload = null;
  startedAtMs = 0;
  lastKnownRecordingSeconds = 0;
  lastTranscriptLength = 0;
}

function resetRecorderOutputs() {
  elsRecorder.chunkCount.textContent = "0";
  elsRecorder.pendingCount.textContent = "0";
  elsRecorder.segmentCount.textContent = "0";
  elsRecorder.durationValue.textContent = "00:00";
  elsRecorder.compactDuration.textContent = "00:00";
  elsRecorder.processingState.textContent = "не розпочато";
  renderChunkPipelineStatus();
  elsRecorder.transcriptOutput.textContent = "";
  renderLiveTranscriptPreview([], "");
  renderLakmusDraftForm(null);
  renderQualitySummary(null);
  renderProcessingProgress();
  renderSafetyStatus();
  elsRecorder.exportButton.disabled = true;
  elsRecorder.fillLakmusButton.disabled = true;
  elsRecorder.copyDraftButton.disabled = true;
  elsRecorder.copyTranscriptButton.disabled = true;
  updateStaleQueueControls(0);
  notifyHostStatus({
    duration: "00:00",
    audioLevel: 0,
    recordedPercent: 0,
    processedPercent: 0,
    processedLabel: "00:00 / 00:00",
    recordedLabel: "00:00",
    transcribedLabel: "00:00"
  });
}

async function markConsultationRuntimeState(status) {
  if (!sessionId) {
    return;
  }
  const startedAt = startPayload?.clientStartedAt
    || (startedAtMs ? new Date(startedAtMs).toISOString() : new Date().toISOString());
  await chrome.storage.local.set({
    [RUNTIME_STATE_KEY]: {
      status,
      sessionId,
      startedAt,
      updatedAt: new Date().toISOString(),
      chunkCount: chunkIndex,
      pendingCount: livePendingCount(),
      queueBlocked: Boolean(queueBlockedReason)
    }
  });
}

async function clearConsultationRuntimeState() {
  await chrome.storage.local.remove(RUNTIME_STATE_KEY).catch(() => {});
}

function scheduleConsultationAlarms(status) {
  if (!chrome.alarms?.create) {
    return;
  }
  if (status === "recording") {
    chrome.alarms.clear(STOPPED_FINISH_ALARM);
    chrome.alarms.create(LONG_RECORDING_ALARM, {
      delayInMinutes: LONG_RECORDING_REMINDER_MINUTES,
      periodInMinutes: LONG_RECORDING_REPEAT_MINUTES
    });
    return;
  }
  if (status === "stopped" || status === "finish_pending") {
    chrome.alarms.clear(LONG_RECORDING_ALARM);
    chrome.alarms.create(STOPPED_FINISH_ALARM, {
      delayInMinutes: STOPPED_FINISH_REMINDER_MINUTES,
      periodInMinutes: STOPPED_FINISH_REPEAT_MINUTES
    });
    return;
  }
  clearConsultationAlarms();
}

function clearConsultationAlarms() {
  if (!chrome.alarms?.clear) {
    return;
  }
  chrome.alarms.clear(LONG_RECORDING_ALARM);
  chrome.alarms.clear(STOPPED_FINISH_ALARM);
}

function scheduleLocalLongRecordingReminder(delayMs) {
  clearLocalLongRecordingReminder();
  longRecordingReminderTimer = setTimeout(() => {
    longRecordingReminderTimer = null;
    showLocalLongRecordingReminder();
  }, delayMs);
}

function clearLocalLongRecordingReminder() {
  if (longRecordingReminderTimer) {
    clearTimeout(longRecordingReminderTimer);
    longRecordingReminderTimer = null;
  }
}

function showLocalLongRecordingReminder() {
  if (!recordingActive) {
    return;
  }
  const elapsed = Math.max(1, Math.round((Date.now() - startedAtMs) / 60000));
  setStatus(`Запис триває ${elapsed} хв. Якщо консультація завершена, натисніть "Завершити".`);
  scheduleLocalLongRecordingReminder(LOCAL_LONG_RECORDING_REPEAT_MS);
}

function showBrowserNotification(title, message) {
  if (!chrome.notifications?.create) {
    return;
  }
  chrome.notifications.create(`medtools-recorder-${Date.now()}`, {
    type: "basic",
    iconUrl: "assets/icons/icon-128.png",
    title,
    message,
    priority: 1
  });
}

function openRecorderDb() {
  return new Promise((resolve, reject) => {
    const request = indexedDB.open(RECORDER_DB_NAME, RECORDER_DB_VERSION);
    request.onupgradeneeded = () => {
      const db = request.result;
      if (!db.objectStoreNames.contains(QUEUE_STORE)) {
        db.createObjectStore(QUEUE_STORE, { keyPath: "id" });
      }
      if (!db.objectStoreNames.contains(STATE_STORE)) {
        db.createObjectStore(STATE_STORE, { keyPath: "id" });
      }
      if (!db.objectStoreNames.contains(SAFETY_STORE)) {
        db.createObjectStore(SAFETY_STORE, { keyPath: "id" });
      }
      if (!db.objectStoreNames.contains(ARCHIVE_STORE)) {
        db.createObjectStore(ARCHIVE_STORE, { keyPath: "id" });
      }
    };
    request.onsuccess = () => resolve(request.result);
    request.onerror = () => reject(request.error);
  });
}

function putStoreValue(db, storeName, value) {
  return new Promise((resolve, reject) => {
    const transaction = db.transaction(storeName, "readwrite");
    transaction.objectStore(storeName).put(value);
    transaction.oncomplete = resolve;
    transaction.onerror = () => reject(transaction.error);
  });
}

function getStoreValue(db, storeName, key) {
  return new Promise((resolve, reject) => {
    const request = db.transaction(storeName, "readonly").objectStore(storeName).get(key);
    request.onsuccess = () => resolve(request.result || null);
    request.onerror = () => reject(request.error);
  });
}

function getAllStoreValues(db, storeName) {
  return new Promise((resolve, reject) => {
    const request = db.transaction(storeName, "readonly").objectStore(storeName).getAll();
    request.onsuccess = () => resolve(request.result || []);
    request.onerror = () => reject(request.error);
  });
}

function deleteStoreValue(db, storeName, key) {
  return new Promise((resolve, reject) => {
    const transaction = db.transaction(storeName, "readwrite");
    transaction.objectStore(storeName).delete(key);
    transaction.oncomplete = resolve;
    transaction.onerror = () => reject(transaction.error);
  });
}

function selectedChunkMs() {
  return normalizeChunkMs(elsRecorder.chunkMs.value);
}

function normalizeChunkMs(value) {
  const parsed = Number(value);
  if (parsed === 15000 || parsed === 30000 || parsed === 60000) {
    return parsed;
  }
  return 15000;
}

function normalizeRestoredChunkMs(value) {
  const parsed = Number(value);
  if (!Number.isFinite(parsed) || parsed === LEGACY_SLOW_CHUNK_MS) {
    return DEFAULT_CHUNK_MS;
  }
  return normalizeChunkMs(parsed);
}

function formatChunkDuration(value) {
  if (value === 15000) {
    return "15 секунд";
  }
  return value === 30000 ? "30 секунд" : "1 хвилині";
}

function roundSeconds(value) {
  const number = Number(value);
  if (!Number.isFinite(number)) {
    return 0;
  }
  return Math.round(number * 1000) / 1000;
}

function makeClientSessionId() {
  const stamp = new Date().toISOString().replace(/[-:.TZ]/g, "").slice(0, 14);
  const bytes = new Uint8Array(4);
  crypto.getRandomValues(bytes);
  const suffix = Array.from(bytes).map((byte) => byte.toString(16).padStart(2, "0")).join("");
  return `${stamp}-${suffix}`;
}

async function refreshSessionStatus(existingSession = null) {
  if ((!sessionId || !remoteSessionStarted) && !existingSession) {
    return;
  }
  const hadTranscript = Boolean(String(latestTranscript || "").trim() || (Array.isArray(latestSegments) && latestSegments.length));
  const payload = existingSession || await callCompanion("/v1/session/status", {
    sessionId,
    auth: await getLicenseAuth()
  });
  const session = payload.session || payload;
  latestTranscript = session.transcript || "";
  latestSegments = Array.isArray(session.segments) ? session.segments : [];
  latestDraft = session.draft || null;
  latestMetrics = session.metrics || null;
  renderProcessingProgress(session);
  renderTranscriptReview(latestSegments, latestTranscript);
  updateTranscriptCopyButton();
  renderLiveTranscriptPreview(latestSegments, latestTranscript);
  revealFirstTranscriptIfNeeded(hadTranscript);
  renderLakmusDraftForm(latestDraft);
  renderQualitySummary(latestMetrics);
  elsRecorder.segmentCount.textContent = String(session.segmentCount || 0);
  elsRecorder.processingState.textContent = session.processingState || session.status || "очікує";
  updateExportButtonState();
  updateDraftActionButtons();
  if (session.chunkCount != null) {
    elsRecorder.chunkCount.textContent = String(Math.max(Number(session.chunkCount || 0), chunkIndex));
  }
  await maybeAutoFillVisibleLakmusFields();
  lastTranscriptLength = latestTranscript.length;
  if (finishRequested && !recordingActive && !livePendingCount() && isServerSessionFinal(session)) {
    finishRequested = false;
    consultationFinished = true;
    await clearPersistedSession();
    await clearConsultationRuntimeState();
    clearConsultationAlarms();
    clearLocalLongRecordingReminder();
    renderRecorderState("finished");
  }
}

function isServerSessionFinal(session) {
  if (!session) {
    return false;
  }
  const status = String(session.status || "");
  const processingState = String(session.processingState || "");
  const pendingChunkCount = Number(session.pendingChunkCount || 0);
  const hasPendingState = /queued|processing|finishing|обробка/i.test(processingState);
  return status === "finished" && pendingChunkCount === 0 && !hasPendingState;
}

function revealFirstTranscriptIfNeeded(hadTranscript) {
  const hasTranscript = Boolean(String(latestTranscript || "").trim() || (Array.isArray(latestSegments) && latestSegments.length));
  if (!hasTranscript || hadTranscript || firstTranscriptRevealed) {
    return;
  }
  firstTranscriptRevealed = true;
  if (compactMode) {
    setCompactMode(false);
  }
  if (elsRecorder.transcriptDetails) {
    elsRecorder.transcriptDetails.open = true;
    elsRecorder.transcriptDetails.scrollIntoView({ behavior: "smooth", block: "start" });
  }
  highlightNewTranscript();
  setStatus("Перший текст готовий. Відкрив блок транскрипції та полів для перевірки.");
}

function highlightNewTranscript() {
  for (const element of [elsRecorder.liveTranscriptPreview, elsRecorder.transcriptDetails]) {
    if (!element) {
      continue;
    }
    element.classList.remove("new-transcript-pulse");
    void element.offsetWidth;
    element.classList.add("new-transcript-pulse");
    setTimeout(() => element.classList.remove("new-transcript-pulse"), 2200);
  }
}

function renderTranscriptReview(segments, fallbackText) {
  const container = elsRecorder.transcriptOutput;
  container.textContent = "";
  if (Array.isArray(segments) && segments.length) {
    const fragment = document.createDocumentFragment();
    for (const segment of segments) {
      fragment.append(renderTranscriptSegment(segment));
    }
    container.append(fragment);
    updateTranscriptCopyButton();
    return;
  }
  const empty = document.createElement("p");
  empty.className = "transcript-empty";
  empty.textContent = fallbackText || "Транскрипція з'явиться після першого обробленого чанка.";
  container.append(empty);
  updateTranscriptCopyButton();
}

function renderLiveTranscriptPreview(segments = latestSegments, fallbackText = latestTranscript) {
  if (!elsRecorder.liveTranscriptPreview || !elsRecorder.liveTranscriptPreviewText || !elsRecorder.liveTranscriptPreviewMeta) {
    return;
  }
  const latestSegment = Array.isArray(segments)
    ? [...segments].reverse().find((segment) => String(segment.text || "").trim())
    : null;
  if (latestSegment) {
    elsRecorder.liveTranscriptPreview.classList.remove("waiting");
    elsRecorder.liveTranscriptPreviewMeta.textContent = `${formatTranscriptClock(latestSegment.start)}-${formatTranscriptClock(latestSegment.end)} · ${latestSegment.label || "фрагмент"}`;
    elsRecorder.liveTranscriptPreviewText.textContent = latestSegment.text || "";
    return;
  }
  const fallback = latestTranscriptPreviewText(fallbackText);
  if (fallback) {
    elsRecorder.liveTranscriptPreview.classList.remove("waiting");
    elsRecorder.liveTranscriptPreviewMeta.textContent = "текст без сегментів";
    elsRecorder.liveTranscriptPreviewText.textContent = fallback;
    return;
  }
  const noSpeechReason = noSpeechDetectedMessage(latestMetrics);
  if (noSpeechReason) {
    elsRecorder.liveTranscriptPreview.classList.remove("waiting");
    elsRecorder.liveTranscriptPreviewMeta.textContent = noSpeechReason.meta;
    elsRecorder.liveTranscriptPreviewText.textContent = noSpeechReason.text;
    return;
  }
  elsRecorder.liveTranscriptPreview.classList.add("waiting");
  elsRecorder.liveTranscriptPreviewMeta.textContent = recordingActive || sessionId
    ? liveTranscriptWaitLabel()
    : "ще не почато";
  elsRecorder.liveTranscriptPreviewText.textContent = recordingActive
    ? `${liveTranscriptWaitText()} Після обробки Whisper тут зʼявиться остання репліка.`
    : "Тут буде видно перший транскрибований фрагмент без прокрутки вниз.";
}

function noSpeechDetectedMessage(metrics = latestMetrics) {
  if (!metrics) {
    return null;
  }
  const doneCount = Number(metrics.doneChunkCount || 0);
  const pendingCount = Number(metrics.pendingChunkCount || 0);
  const transcriptChars = Number(metrics.transcriptChars || 0);
  const skippedCount = Number(metrics.skippedWhisperChunkCount || 0);
  const discardedCount = Number(metrics.discardedWhisperSegmentCount || 0);
  if (!doneCount || pendingCount || transcriptChars) {
    return null;
  }
  if (discardedCount > 0) {
    return {
      meta: "поки немає корисної мови",
      text: "Запис оброблено. Система відфільтрувала тишу або випадковий шум, тому в поля нічого не додано."
    };
  }
  if (skippedCount >= doneCount) {
    return {
      meta: "мову не знайдено",
      text: "Оброблені чанки схожі на тишу або дуже слабкий звук. Перевірте вибраний мікрофон і попросіть говорити ближче."
    };
  }
  return {
    meta: "текст порожній",
    text: "Чанк оброблено, але Whisper не повернув корисного тексту. Для тесту скажіть кілька чітких медичних фраз ближче до мікрофона."
  };
}

function liveTranscriptWaitLabel() {
  if (!recordingActive || !startedAtMs) {
    return sessionId ? "очікую обробку" : "ще не почато";
  }
  const etaSeconds = secondsUntilNextChunk();
  return etaSeconds > 0
    ? `до чанка ${formatShortSeconds(etaSeconds)}`
    : "чанк готується";
}

function liveTranscriptWaitText() {
  if (!recordingActive || !startedAtMs) {
    return "Текст зʼявиться після першого обробленого чанка.";
  }
  if (lastQueuedAudioInfo) {
    const pending = livePendingCount();
    if (lastQueuedAudioInfo.buffered) {
      return `Аудіо вже пишеться: у локальному буфері ${formatAudioFragmentInfo(lastQueuedAudioInfo)}. На транскрипцію піде після ${formatChunkDuration(chunkMs)} або після стопу.`;
    }
    return `Аудіо вже пишеться: останній фрагмент ${formatAudioFragmentInfo(lastQueuedAudioInfo)}${pending ? `, у черзі ${pending}` : ""}.`;
  }
  const etaSeconds = secondsUntilNextChunk();
  if (etaSeconds > 0) {
    return `Перший текст очікується після наступного чанка: приблизно через ${formatShortSeconds(etaSeconds)}.`;
  }
  const pending = livePendingCount();
  return pending
    ? `Чанк уже в черзі (${pending}); чекаю обробку Whisper.`
    : "Чанк готується локально; чекаю обробку Whisper.";
}

function secondsUntilNextChunk() {
  if (!startedAtMs) {
    return 0;
  }
  const elapsedMs = Math.max(0, Date.now() - startedAtMs);
  const nextBoundaryMs = Math.max(chunkMs, (emittedChunkCount + 1) * chunkMs);
  return Math.max(0, Math.ceil((nextBoundaryMs - elapsedMs) / 1000));
}

function formatShortSeconds(seconds) {
  const safeSeconds = Math.max(0, Math.ceil(Number(seconds || 0)));
  if (safeSeconds < 60) {
    return `${safeSeconds}с`;
  }
  return `${Math.floor(safeSeconds / 60)}хв ${String(safeSeconds % 60).padStart(2, "0")}с`;
}

function latestTranscriptPreviewText(value) {
  const lines = String(value || "")
    .split(/\n+/)
    .map((line) => line.trim())
    .filter(Boolean);
  const lastLine = lines.at(-1) || "";
  return lastLine
    .replace(/^\[[^\]]+\]\s*/, "")
    .replace(/^(Лікар|Пацієнт\/представник|Співрозмовник)(\s+\(ймовірно\))?:\s*/i, "")
    .trim()
    .slice(0, 320);
}

function renderTranscriptSegment(segment) {
  const row = document.createElement("article");
  row.className = "transcript-segment";
  if (segment.quality?.suspicious) {
    row.classList.add("needs-review");
  }

  const meta = document.createElement("div");
  meta.className = "transcript-meta";

  const time = document.createElement("span");
  time.className = "transcript-time";
  time.textContent = `[${formatTranscriptClock(segment.start)}-${formatTranscriptClock(segment.end)}]`;
  meta.append(time);

  const speaker = document.createElement("span");
  speaker.className = `transcript-speaker ${segment.speaker || "unknown"}`;
  speaker.textContent = segment.label || transcriptSpeakerLabel(segment);
  meta.append(speaker);

  const terms = Array.isArray(segment.medicalTerms) ? segment.medicalTerms : [];
  if (terms.length) {
    const termBadge = document.createElement("span");
    termBadge.className = "transcript-badge term";
    termBadge.textContent = `терміни ${terms.length}`;
    meta.append(termBadge);
  }

  if (segment.quality?.suspicious) {
    const reviewBadge = document.createElement("span");
    reviewBadge.className = "transcript-badge review";
    reviewBadge.textContent = "перевірити";
    meta.append(reviewBadge);
  }

  if (segment.audio?.replayAvailable && segment.id) {
    const replayButton = document.createElement("button");
    replayButton.type = "button";
    replayButton.className = "transcript-replay";
    replayButton.textContent = "▶";
    replayButton.title = "Переслухати цю репліку";
    replayButton.addEventListener("click", () => {
      playTranscriptSegment(segment, replayButton).catch((error) => {
        setStatus(error.message || "Не вдалося відтворити репліку.", true);
      });
    });
    meta.append(replayButton);
  }

  const text = document.createElement("p");
  text.className = "transcript-text";
  appendHighlightedTranscriptText(text, segment.text || "", terms);

  row.append(meta, text);
  return row;
}

async function playTranscriptSegment(segment, button) {
  if (!sessionId || !segment?.id) {
    throw new Error("Немає активної сесії для переслуховування.");
  }
  if (button) {
    button.disabled = true;
    button.textContent = "…";
  }
  try {
    const result = await callCompanion("/v1/session/replay", {
      sessionId,
      segmentId: segment.id,
      auth: await getLicenseAuth()
    });
    if (!result?.audioBase64) {
      throw new Error("Аудіо для цієї репліки недоступне.");
    }
    if (replayAudio) {
      replayAudio.pause();
      replayAudio = null;
    }
    const audioBytes = base64ToUint8Array(result.audioBase64);
    const blob = new Blob([audioBytes], { type: result.mimeType || "audio/wav" });
    const url = URL.createObjectURL(blob);
    replayAudio = new Audio(url);
    replayAudio.addEventListener("ended", () => URL.revokeObjectURL(url), { once: true });
    replayAudio.addEventListener("error", () => URL.revokeObjectURL(url), { once: true });
    await replayAudio.play();
    setStatus("Відтворюю вибрану репліку.");
  } finally {
    if (button) {
      button.disabled = false;
      button.textContent = "▶";
    }
  }
}

function base64ToUint8Array(value) {
  const binary = atob(String(value || ""));
  const bytes = new Uint8Array(binary.length);
  for (let index = 0; index < binary.length; index += 1) {
    bytes[index] = binary.charCodeAt(index);
  }
  return bytes;
}

function appendHighlightedTranscriptText(target, text, terms) {
  const source = String(text || "");
  const patterns = transcriptTermPatterns(terms);
  if (!source || !patterns.length) {
    target.textContent = source;
    return;
  }
  const matches = [];
  for (const item of patterns) {
    for (const match of source.matchAll(item.pattern)) {
      matches.push({
        start: match.index,
        end: match.index + match[0].length,
        text: match[0],
        label: item.label
      });
    }
  }
  matches.sort((a, b) => a.start - b.start || b.end - a.end);
  let cursor = 0;
  for (const match of matches) {
    if (match.start < cursor) {
      continue;
    }
    if (match.start > cursor) {
      target.append(document.createTextNode(source.slice(cursor, match.start)));
    }
    const mark = document.createElement("mark");
    mark.className = "medical-term";
    mark.title = match.label;
    mark.textContent = match.text;
    target.append(mark);
    cursor = match.end;
  }
  if (cursor < source.length) {
    target.append(document.createTextNode(source.slice(cursor)));
  }
}

function transcriptTermPatterns(terms) {
  const keys = new Set((terms || []).map((term) => term.key).filter(Boolean));
  const patterns = [];
  const add = (key, label, pattern) => {
    if (keys.has(key)) {
      patterns.push({ label, pattern });
    }
  };
  add("eeg", "ЕЕГ", /(?<![\p{L}\p{N}_])еег(?![\p{L}\p{N}_])|електроенцефалограф\w*/giu);
  add("mri", "МРТ", /(?<![\p{L}\p{N}_])мрт(?![\p{L}\p{N}_])|магнітн\w*\s+резонансн\w*\s+томограф\w*/giu);
  add("ct", "КТ", /(?<![\p{L}\p{N}_])кт(?![\p{L}\p{N}_])|комп['ʼ]?ютерн\w*\s+томограф\w*/giu);
  add("usd", "УЗД", /(?<![\p{L}\p{N}_])узд(?![\p{L}\p{N}_])|ультразвуков\w*/giu);
  add("apgar", "Апгар", /апгар\w*/giu);
  add("gestation", "Гестаційний вік", /гестаційн\w*|термін\w*\s+вагітн\w*|тижн\w*/giu);
  add("seizures", "Судоми", /судом\w*|епілеп\w*/giu);
  add("tremor", "Тремор", /тремор\w*/giu);
  add("tone", "М'язовий тонус", /гіпотон\w*|гіпертонус\w*|м['ʼ]?язов\w*\s+тонус\w*/giu);
  add("reflex", "Рефлекси", /рефлекс\w*/giu);
  add("development", "Психомоторний розвиток", /психомотор\w*|мовленн\w*|розвит\w*|регрес\w*/giu);
  add("asd", "РАС", /(?<![\p{L}\p{N}_])рас(?![\p{L}\p{N}_])|аутизм\w*|аутистичн\w*/giu);
  add("adhd", "СДУГ", /(?<![\p{L}\p{N}_])сдуг(?![\p{L}\p{N}_])|гіперактив\w*|дефіцит\w*\s+уваг\w*/giu);
  add("cp", "ДЦП", /(?<![\p{L}\p{N}_])дцп(?![\p{L}\p{N}_])|церебральн\w*\s+параліч\w*/giu);
  add("head_size", "Окружність голови", /(?<![\p{L}\p{N}_])ог(?![\p{L}\p{N}_])|окружн\w*\s+голов\w*|мікроцефал\w*|макроцефал\w*/giu);
  add("birth", "Пологи", /полог\w*|народ\w*|закрич\w*|жовтяниц\w*/giu);
  add("feeding", "Вигодовування", /грудн\w*\s+вигодов\w*|суміш\w*|прикорм\w*|ковтан\w*|жуван\w*/giu);
  add("screening", "Скринінг", /скринінг\w*/giu);
  return patterns;
}

function transcriptSpeakerLabel(segment) {
  if (segment.speaker === "doctor") {
    return segment.speakerConfidence === "continuation" ? "Лікар (ймовірно)" : "Лікар";
  }
  if (segment.speaker === "representative") {
    return segment.speakerConfidence === "continuation" ? "Пацієнт/представник (ймовірно)" : "Пацієнт/представник";
  }
  return "Співрозмовник";
}

function formatTranscriptClock(totalSeconds) {
  const safeSeconds = Math.max(0, Math.floor(Number(totalSeconds || 0)));
  const minutes = Math.floor(safeSeconds / 60);
  const seconds = safeSeconds % 60;
  return `${String(minutes).padStart(2, "0")}:${String(seconds).padStart(2, "0")}`;
}

function startPolling() {
  stopPolling();
  pollTimer = setInterval(() => {
    refreshSessionStatus().catch(() => {});
  }, 5000);
}

function stopPolling() {
  if (pollTimer) {
    clearInterval(pollTimer);
    pollTimer = null;
  }
}

function startHealthMonitor() {
  stopHealthMonitor();
  updateServerState(remoteSessionStarted ? "online" : "unknown");
  healthTimer = setInterval(() => {
    checkServerSilently().catch(() => {});
  }, 10000);
  checkServerSilently().catch(() => {});
}

function stopHealthMonitor() {
  if (healthTimer) {
    clearInterval(healthTimer);
    healthTimer = null;
  }
}

async function checkServerSilently() {
  try {
    const response = await fetchWithTimeout(`${companionBaseUrl()}/v1/health`, { cache: "no-store" }, HEALTH_TIMEOUT_MS);
    const data = await response.json();
    if (!response.ok || !data.ok) {
      throw new Error(data.message || "health failed");
    }
    lastServerOkAt = Date.now();
    updateServerState(data.whisper?.ready
      ? (data.draftMapper?.ready ? "online" : "no-mapper")
      : "no-whisper");
    if (livePendingCount() && !queueBlockedReason) {
      scheduleFlush(0);
    }
  } catch (error) {
    updateServerState("offline");
    if (recordingActive && livePendingCount() >= 2) {
      const recentlyOnline = lastServerOkAt && Date.now() - lastServerOkAt < 90_000;
      const statusText = recentlyOnline
        ? `Сервер зайнятий обробкою. Запис триває, у черзі ${livePendingCount()} чанків, вони досилаються автоматично.`
        : `Сервер тимчасово не відповідає. Запис триває локально, у черзі ${livePendingCount()} чанків. Якщо це довго не минає, заповнюйте ключові поля руками.`;
      setStatus(statusText, true);
      if (Date.now() - lastOfflineNotificationAt > OFFLINE_NOTIFICATION_COOLDOWN_MS) {
        lastOfflineNotificationAt = Date.now();
        showBrowserNotification(
          "Parallax Health",
          "Запис не зупинено. Чанки збережені локально і чекають автоматичної досилки."
        );
      }
    }
  }
}

function updateServerState(state) {
  const labels = {
    online: "online",
    offline: "offline",
    "no-whisper": "без Whisper",
    "no-mapper": "без mapper",
    unknown: "перевірка"
  };
  elsRecorder.serverState.textContent = labels[state] || state;
  elsRecorder.serverState.parentElement.classList.toggle("metric-warn", state === "offline" || state === "no-whisper" || state === "no-mapper");
}

function startDurationTimer() {
  stopDurationTimer();
  updateDuration();
  durationTimer = setInterval(updateDuration, 1000);
}

function stopDurationTimer() {
  if (durationTimer) {
    clearInterval(durationTimer);
    durationTimer = null;
  }
}

function updateDuration() {
  if (!startedAtMs) {
    elsRecorder.durationValue.textContent = "00:00";
    elsRecorder.compactDuration.textContent = "00:00";
    lastKnownRecordingSeconds = 0;
    renderProcessingProgress();
    notifyHostStatus({ duration: "00:00" });
    return;
  }
  const seconds = Math.max(0, Math.floor((Date.now() - startedAtMs) / 1000));
  lastKnownRecordingSeconds = Math.max(lastKnownRecordingSeconds, seconds);
  const minutes = Math.floor(seconds / 60);
  const rest = seconds % 60;
  const label = `${String(minutes).padStart(2, "0")}:${String(rest).padStart(2, "0")}`;
  elsRecorder.durationValue.textContent = label;
  elsRecorder.compactDuration.textContent = label;
  if (recordingActive && !latestTranscript && !(Array.isArray(latestSegments) && latestSegments.length)) {
    renderLiveTranscriptPreview(latestSegments, latestTranscript);
  }
  renderProcessingProgress();
  notifyHostStatus({ duration: label });
}

function renderProcessingProgress(session = null) {
  const recordedSeconds = currentRecordingSeconds(session);
  const processedSeconds = Math.min(recordedSeconds, currentProcessedSeconds(session));
  const processedPercent = recordedSeconds > 0
    ? Math.max(0, Math.min(100, Math.round((processedSeconds / recordedSeconds) * 100)))
    : 0;
  const recordedPercent = recordedSeconds > 0 ? 100 : 0;
  const recordedLabel = formatTranscriptClock(recordedSeconds);
  const transcribedLabel = formatTranscriptClock(processedSeconds);
  const label = `${transcribedLabel} / ${recordedLabel}`;

  if (elsRecorder.processingProgressText) {
    elsRecorder.processingProgressText.textContent = label;
  }
  if (elsRecorder.recordingProgressText) {
    elsRecorder.recordingProgressText.textContent = recordedLabel;
  }
  if (elsRecorder.transcribedProgressText) {
    elsRecorder.transcribedProgressText.textContent = transcribedLabel;
  }
  for (const bar of [elsRecorder.recordingProgressBar, elsRecorder.compactRecordingBar]) {
    if (bar) {
      bar.style.width = `${recordedPercent}%`;
      bar.classList.toggle("live", Boolean(recordingActive && recordedSeconds > 0));
    }
  }
  for (const bar of [elsRecorder.transcribedProgressBar, elsRecorder.compactTranscribedBar]) {
    if (bar) {
      bar.style.width = `${processedPercent}%`;
    }
  }
  notifyHostStatus({
    recordedPercent,
    processedPercent,
    processedLabel: label,
    recordedLabel,
    transcribedLabel
  });
}

function currentRecordingSeconds(session = null) {
  if (startedAtMs && recordingActive) {
    return Math.max(0, Math.floor((Date.now() - startedAtMs) / 1000));
  }
  const metricDuration = Number(session?.metrics?.durationSeconds ?? latestMetrics?.durationSeconds);
  if (Number.isFinite(metricDuration) && metricDuration > 0) {
    lastKnownRecordingSeconds = Math.max(lastKnownRecordingSeconds, Math.round(metricDuration));
  }
  return Math.max(0, Math.round(lastKnownRecordingSeconds));
}

function currentProcessedSeconds(session = null) {
  const segments = Array.isArray(session?.segments) ? session.segments : latestSegments;
  const segmentEnd = Array.isArray(segments)
    ? segments.reduce((max, segment) => {
      const end = Number(segment?.end);
      return Number.isFinite(end) && end > max ? end : max;
    }, 0)
    : 0;
  const metrics = session?.metrics || latestMetrics || {};
  const doneChunkCount = Number(session?.doneChunkCount ?? metrics.doneChunkCount ?? 0);
  const sessionChunkMs = Number(startPayload?.chunkMs || chunkMs || selectedChunkMs());
  const approxChunkSeconds = doneChunkCount > 0
    ? doneChunkCount * Math.max(1, Math.round(sessionChunkMs / 1000))
    : 0;
  return Math.max(0, Math.round(Math.max(segmentEnd, approxChunkSeconds)));
}

function stopTracks() {
  if (mediaStream) {
    for (const track of mediaStream.getTracks()) {
      track.stop();
    }
    mediaStream = null;
  }
}

function exportTranscript() {
  if (!hasRecoveryExportContent()) {
    setStatus("Поки немає даних для TXT експорту.", true);
    return;
  }
  const text = buildRecoveryExportText();
  const blob = new Blob([text], { type: "text/plain;charset=utf-8" });
  const url = URL.createObjectURL(blob);
  const link = document.createElement("a");
  link.href = url;
  const namePart = sessionId || new Date().toISOString().slice(0, 19).replace(/[:T]/g, "-");
  link.download = `parallax-health-consultation-${namePart}.txt`;
  link.click();
  URL.revokeObjectURL(url);
  setStatus("TXT експорт збережено.");
}

function buildRecoveryExportText() {
  const draftText = formatLakmusDraftForCopy();
  const recentEvents = Array.from(elsRecorder.eventLog?.children || [])
    .slice(0, 20)
    .map((row) => row.innerText || row.textContent || "")
    .filter(Boolean);
  return [
    "PARALLAX HEALTH CONSULTATION EXPORT",
    "Файл містить текстові дані поточного запису. Аудіо не додається.",
    "",
    "СТАН",
    `Створено: ${new Date().toISOString()}`,
    `Сесія: ${sessionId || ""}`,
    `Статус: ${recordingActive ? "recording" : (consultationFinished ? "finished" : (finishRequested ? "finish_pending" : "stopped_or_idle"))}`,
    `Обробка: ${textOf(elsRecorder.processingState)}`,
    `Тривалість: ${textOf(elsRecorder.durationValue)}`,
    `Чанків: ${textOf(elsRecorder.chunkCount)}`,
    `У черзі: ${livePendingCount()}`,
    `Останній чанк: ${chunkPipelineSummary().label}`,
    `Фрагментів: ${textOf(elsRecorder.segmentCount)}`,
    `Сервер: ${textOf(elsRecorder.serverState)}`,
    `Захист запису: ${textOf(elsRecorder.safetyStatusText)}`,
    `Якість: ${qualityVerdictText(latestMetrics) || "ще немає метрик"}`,
    `Висновок: ${recordingOutcomeSummary(latestMetrics)}`,
    `Pipeline: ${recordingPipelineLabel(latestMetrics)}`,
    `Дія: ${recordingOperatorActionText(latestMetrics)}`,
    "",
    "ТЕКСТ ДЛЯ ПОЛІВ",
    draftText || "Поки порожньо.",
    "",
    "ПОВНА ТРАНСКРИПЦІЯ",
    latestTranscript || "Транскрипція ще не готова або порожня.",
    "",
    "ОСТАННІ ПОДІЇ",
    ...(recentEvents.length ? recentEvents : ["Подій поки немає."])
  ].join("\n");
}

function hasRecoveryExportContent() {
  return Boolean(
    sessionId ||
    latestTranscript ||
    hasLakmusDraftValues(lakmusDraftFromReviewForm(latestDraft)) ||
    livePendingCount()
  );
}

function updateExportButtonState() {
  elsRecorder.exportButton.disabled = !hasRecoveryExportContent();
}

async function fillVisibleLakmusFields(options = {}) {
  const currentDraft = lakmusDraftFromReviewForm(latestDraft);
  const fields = options.delta ? lakmusDraftDeltaValues(currentDraft) : lakmusDraftValues(currentDraft);
  if (!Object.keys(fields).length) {
    if (!options.quiet) {
      setStatus("Немає підготовлених значень для полів Лакмуса.", true);
    }
    return;
  }

  try {
    const targetTab = await findLakmusTargetTab();

    const response = await chrome.tabs.sendMessage(targetTab.id, {
      type: "MEDTOOLS_FILL_VISIBLE_LAKMUS_FIELDS",
      fields
    });
    markAppliedLakmusFields(response, currentDraft);
    await persistSessionState();
    const filledCount = Array.isArray(response?.filled) ? response.filled.length : 0;
    const skippedCount = Array.isArray(response?.skipped) ? response.skipped.length : 0;
    const filled = response?.filled?.length ? response.filled.join(", ") : "жодне поле";
    const skipped = skippedCount ? ` Пропущено ${skippedCount}; деталі є в технічному журналі.` : "";
    setStatus(`${options.quiet ? "Автооновлено" : "Дописано в Лакмус"}: ${filled}.${skipped}`, !filledCount);
  } catch (error) {
    if (!options.quiet) {
      setStatus(error.message || "Не вдалося заповнити Лакмус. Перезавантажте вкладку Лакмуса і спробуйте ще раз.", true);
    }
  }
}

async function maybeAutoFillVisibleLakmusFields(options = {}) {
  if (!autoFillEnabled) {
    lastTranscriptLength = latestTranscript.length;
    return;
  }
  const currentDraft = lakmusDraftFromReviewForm(latestDraft);
  if (!hasLakmusDraftValues(currentDraft)) {
    lastTranscriptLength = latestTranscript.length;
    return;
  }
  const signature = lakmusDraftSignature(currentDraft);
  const transcriptGrew = latestTranscript.length > lastTranscriptLength;
  const draftChanged = signature && signature !== lastAutoFillDraftSignature;
  if (!options.force && !transcriptGrew && !draftChanged) {
    lastTranscriptLength = latestTranscript.length;
    return;
  }
  lastAutoFillDraftSignature = signature;
  lastTranscriptLength = latestTranscript.length;
  await fillVisibleLakmusFields({ quiet: true, delta: true });
}

async function checkVisibleLakmusFields() {
  try {
    const targetTab = await findLakmusTargetTab();
    const response = await chrome.tabs.sendMessage(targetTab.id, {
      type: "MEDTOOLS_CHECK_VISIBLE_LAKMUS_FIELDS"
    });
    const visible = Array.isArray(response?.visible) ? response.visible.map((item) => item.label).filter(Boolean) : [];
    const missing = Array.isArray(response?.missing) ? response.missing.map((item) => item.label).filter(Boolean) : [];
    if (response?.ready) {
      setStatus(`Лакмус готовий: знайдено ${visible.length}/3 потрібних полів.`);
      return;
    }
    setStatus(`У Лакмусі видно ${visible.length}/3 потрібних полів. Відкрийте ще: ${missing.join(", ") || "немає"}.`, true);
  } catch (error) {
    setStatus(error.message || "Не вдалося перевірити поля Лакмуса.", true);
  }
}

async function findLakmusTargetTab() {
  const tabs = await chrome.tabs.query({ url: "https://baddi.lakmus.org/*" });
  if (!tabs.length) {
    throw new Error("Відкрийте вкладку Лакмуса з консультацією.");
  }
  const targetTab = tabs.find((tab) => tab.active) || (tabs.length === 1 ? tabs[0] : null);
  if (!targetTab?.id) {
    throw new Error("Залиште відкритою одну потрібну вкладку Лакмуса або зробіть її активною.");
  }
  return targetTab;
}

async function copyLakmusDraft() {
  const text = formatLakmusDraftForCopy();
  if (!text.trim()) {
    setStatus("Текст для полів ще порожній.", true);
    return;
  }
  try {
    await copyTextToClipboard(text);
    setStatus("Текст скопійовано.");
  } catch (error) {
    downloadTextFile(text, `parallax-health-lakmus-fields-${sessionId || timestampForFileName()}.txt`);
    setStatus("Буфер обміну недоступний. Текст для полів збережено TXT-файлом.", true);
  }
}

async function copyFullTranscript() {
  const text = formatFullTranscriptForCopy();
  if (!text.trim()) {
    setStatus("Повна транскрипція ще порожня.", true);
    return;
  }
  try {
    await copyTextToClipboard(text);
    setStatus("Повну транскрипцію скопійовано.");
  } catch (error) {
    downloadTextFile(text, `parallax-health-transcript-${sessionId || timestampForFileName()}.txt`);
    setStatus("Буфер обміну недоступний. Повну транскрипцію збережено TXT-файлом.", true);
  }
}

function formatFullTranscriptForCopy() {
  const transcript = String(latestTranscript || "").trim();
  if (transcript) {
    return transcript;
  }
  if (!Array.isArray(latestSegments) || !latestSegments.length) {
    return "";
  }
  return latestSegments
    .filter((segment) => String(segment.text || "").trim())
    .map((segment) => `[${formatTranscriptClock(segment.start)}-${formatTranscriptClock(segment.end)}] ${segment.label || transcriptSpeakerLabel(segment)}: ${String(segment.text || "").trim()}`)
    .join("\n");
}

function updateTranscriptCopyButton() {
  if (elsRecorder.copyTranscriptButton) {
    elsRecorder.copyTranscriptButton.disabled = !formatFullTranscriptForCopy().trim();
  }
}

async function copyRecorderDiagnostics() {
  let text = "";
  try {
    text = await buildRecorderDiagnostics();
    await copyTextToClipboard(text);
    setStatus("Діагностику запису скопійовано.");
  } catch (error) {
    if (text) {
      downloadTextFile(text, `parallax-health-recorder-diagnostics-${timestampForFileName()}.txt`);
      setStatus("Буфер обміну недоступний. Діагностику збережено TXT-файлом.", true);
      return;
    }
    setStatus("Не вдалося підготувати діагностику запису.", true);
  }
}

async function copyTextToClipboard(text) {
  const value = String(text || "");
  if (navigator.clipboard?.writeText) {
    try {
      await navigator.clipboard.writeText(value);
      return;
    } catch (error) {
      // Fallback below keeps support diagnostics available in extension pages.
    }
  }
  const textarea = document.createElement("textarea");
  textarea.value = value;
  textarea.setAttribute("readonly", "");
  Object.assign(textarea.style, {
    position: "fixed",
    top: "-1000px",
    left: "-1000px",
    width: "1px",
    height: "1px",
    opacity: "0"
  });
  document.body.append(textarea);
  textarea.focus();
  textarea.select();
  const ok = document.execCommand("copy");
  textarea.remove();
  if (!ok) {
    throw new Error("Clipboard copy failed");
  }
}

function downloadTextFile(text, filename) {
  const blob = new Blob([String(text || "")], { type: "text/plain;charset=utf-8" });
  const url = URL.createObjectURL(blob);
  const link = document.createElement("a");
  link.href = url;
  link.download = filename || `parallax-health-${timestampForFileName()}.txt`;
  link.click();
  URL.revokeObjectURL(url);
}

function timestampForFileName(date = new Date()) {
  return date.toISOString().slice(0, 19).replace(/[:T]/g, "-");
}

async function buildRecorderDiagnostics() {
  const values = await chrome.storage.local.get([
    LICENSE_STORAGE_KEYS.deviceId,
    LICENSE_STORAGE_KEYS.license,
    RUNTIME_STATE_KEY
  ]);
  const license = values[LICENSE_STORAGE_KEYS.license] || {};
  const runtimeState = values[RUNTIME_STATE_KEY] || {};
  const pendingStatusCounts = pendingChunks.reduce((accumulator, chunk) => {
    const status = String(chunk.status || "unknown");
    accumulator[status] = (accumulator[status] || 0) + 1;
    return accumulator;
  }, {});
  const draftLengths = lakmusDraftValues(lakmusDraftFromReviewForm(latestDraft));
  const recentEvents = Array.from(elsRecorder.eventLog?.children || [])
    .slice(0, 8)
    .map((row) => sanitizeDiagnosticLine(row.innerText || row.textContent || ""))
    .filter(Boolean);
  const lastServerOk = lastServerOkAt ? new Date(lastServerOkAt).toISOString() : "";
  const companionDiagnostics = await fetchCompanionDiagnostics().catch((error) => ({
    ok: false,
    message: sanitizeDiagnosticLine(error.message || String(error))
  }));
  const recoveryArchiveSummary = await loadRecoveryArchiveSummary().catch(() => ({
    count: 0,
    latestCreatedAt: ""
  }));
  const recordedSeconds = currentRecordingSeconds();
  const processedSeconds = Math.min(recordedSeconds, currentProcessedSeconds());
  const processedPercent = recordedSeconds > 0 ? Math.round((processedSeconds / recordedSeconds) * 100) : 0;

  return [
    "Parallax Health recorder diagnostics",
    "no audio, no transcript text, no field text, no license key",
    `time: ${new Date().toISOString()}`,
    `version: ${chrome.runtime.getManifest().version}`,
    `extensionId: ${chrome.runtime.id}`,
    `embedded: ${RECORDER_EMBEDDED ? "yes" : "no"}`,
    `online: ${navigator.onLine ? "yes" : "no"}`,
    `licenseEmail: ${license.email || ""}`,
    `licenseStatus: ${license.status || (license.active ? "active" : "not_active")}`,
    `expiresAt: ${license.expiresAt || ""}`,
    `devicePrefix: ${String(values[LICENSE_STORAGE_KEYS.deviceId] || "").slice(0, 8)}`,
    `companionUrl: ${companionBaseUrl()}`,
    `sessionId: ${sessionId || ""}`,
    `runtimeState: ${runtimeState.status || ""}`,
    `runtimePending: ${runtimeState.pendingCount ?? ""}`,
    `runtimeChunks: ${runtimeState.chunkCount ?? ""}`,
    `recordingActive: ${recordingActive ? "yes" : "no"}`,
    `remoteSessionStarted: ${remoteSessionStarted ? "yes" : "no"}`,
    `finishRequested: ${finishRequested ? "yes" : "no"}`,
    `consultationFinished: ${consultationFinished ? "yes" : "no"}`,
    `queueBlocked: ${queueBlockedReason ? "yes" : "no"}`,
    `queueBlockedReason: ${sanitizeDiagnosticLine(queueBlockedReason)}`,
    `chunkMs: ${chunkMs}`,
    `selectedMicrophone: ${sanitizeDiagnosticLine(selectedMicrophoneLabel())}`,
    `microphoneStatus: ${sanitizeDiagnosticLine(textOf(elsRecorder.activeMicrophoneName))}`,
    `audioVerdict: ${sanitizeDiagnosticLine(textOf(elsRecorder.audioSignalVerdict))}`,
    `readiness: ${sanitizeDiagnosticLine(textOf(elsRecorder.readinessStatus))}`,
    `readinessState: ${lastReadinessCheck?.state || ""}`,
    `readinessAt: ${lastReadinessCheck?.at || ""}`,
    `audioLevel: ${lastAudioSignalInfo ? `${lastAudioSignalInfo.levelPercent}% level, ${lastAudioSignalInfo.peakPercent}% peak, audible=${lastAudioSignalInfo.audible ? "yes" : "no"}, sinceAudible=${lastAudioSignalInfo.secondsSinceAudible ?? ""}s` : ""}`,
    `liveChunkPolicy: ${ALLOW_MEDIA_RECORDER_LIVE_CHUNKS ? "mediarecorder_allowed" : "wav_only"}`,
    `audioCaptureMode: ${pcmFallbackMode ? "webaudio_wav" : (mediaRecorder ? "mediarecorder_fallback" : "idle")}`,
    `mediaRecorderState: ${mediaRecorder?.state || ""}`,
    `pcmFallbackMode: ${pcmFallbackMode ? "yes" : "no"}`,
    `pcmBufferedMs: ${audioContext ? Math.round((pcmTotalSamples / audioContext.sampleRate) * 1000) : 0}`,
    `lastPcmFallbackAt: ${lastPcmFallbackAt ? new Date(lastPcmFallbackAt).toISOString() : ""}`,
    `lastMediaRecorderRequestAt: ${lastMediaRecorderRequestAt ? new Date(lastMediaRecorderRequestAt).toISOString() : ""}`,
    `lastMediaRecorderFragmentAt: ${lastMediaRecorderFragmentAt ? new Date(lastMediaRecorderFragmentAt).toISOString() : ""}`,
    `mediaRecorderBufferedMs: ${Math.max(0, Math.round(mediaRecorderBufferEndMs - mediaRecorderBufferStartMs))}`,
    `chunkIndex: ${chunkIndex}`,
    `lastAudioChunkMimeType: ${lastQueuedAudioInfo?.mimeType || ""}`,
    `lastAudioChunkFormat: ${lastQueuedAudioInfo ? audioFormatLabel(lastQueuedAudioInfo.mimeType) : ""}`,
    `lastAudioChunk: ${lastQueuedAudioInfo ? `${lastQueuedAudioInfo.index + 1}, ${formatAudioFragmentInfo(lastQueuedAudioInfo)}, buffered=${lastQueuedAudioInfo.buffered ? "yes" : "no"}, final=${lastQueuedAudioInfo.finalChunk ? "yes" : "no"}, at=${lastQueuedAudioInfo.queuedAt || ""}` : ""}`,
    `pendingCount: ${livePendingCount()}`,
    `pendingStatuses: ${JSON.stringify(pendingStatusCounts)}`,
    `encodingTasks: ${encodingTasks.size}`,
    `flushInProgress: ${flushInProgress ? "yes" : "no"}`,
    `safetySaveLabel: ${sanitizeDiagnosticLine(latestSafetySave?.label || "")}`,
    `safetySaveAt: ${latestSafetySave?.at || ""}`,
    `recoveryArchiveCount: ${recoveryArchiveSummary.count}`,
    `latestRecoveryArchiveAt: ${recoveryArchiveSummary.latestCreatedAt || ""}`,
    `latestRecoveryArchiveChunks: ${recoveryArchiveSummary.latestChunkCount || 0}`,
    `lastAudibleAudioAt: ${lastAudibleAudioAt ? new Date(lastAudibleAudioAt).toISOString() : ""}`,
    `quietAudioWarningAt: ${lastQuietAudioWarningAt ? new Date(lastQuietAudioWarningAt).toISOString() : ""}`,
    `serverState: ${textOf(elsRecorder.serverState)}`,
    `processingState: ${textOf(elsRecorder.processingState)}`,
    `duration: ${textOf(elsRecorder.durationValue)}`,
    `recordedSeconds: ${recordedSeconds}`,
    `processedSeconds: ${processedSeconds}`,
    `processedPercent: ${processedPercent}`,
    `segmentCount: ${textOf(elsRecorder.segmentCount)}`,
    `transcriptChars: ${latestTranscript.length}`,
    `draftChars: complaints=${(draftLengths.complaints || "").length}, disease=${(draftLengths.disease_history || "").length}, life=${(draftLengths.life_history || "").length}`,
    `qualityVerdict: ${sanitizeDiagnosticLine(qualityVerdictText(latestMetrics))}`,
    `outcomeSummary: ${sanitizeDiagnosticLine(recordingOutcomeSummary(latestMetrics))}`,
    `recordingPipeline: ${sanitizeDiagnosticLine(recordingPipelineLabel(latestMetrics))}`,
    `operatorAction: ${sanitizeDiagnosticLine(recordingOperatorActionText(latestMetrics))}`,
    `metrics: ${JSON.stringify(sanitizeMetricsForDiagnostics(latestMetrics))}`,
    `lastServerOkAt: ${lastServerOk}`,
    `statusLine: ${sanitizeDiagnosticLine(textOf(elsRecorder.statusLine))}`,
    "companionDiagnostics:",
    JSON.stringify(companionDiagnostics, null, 2),
    "recentEvents:",
    ...recentEvents.map((event) => `- ${event}`)
  ].join("\n");
}

function qualityVerdictText(metrics) {
  if (!metrics) {
    return "";
  }
  const verdict = sessionQualityVerdict(metrics);
  return [verdict.label, verdict.hint].filter(Boolean).join(" - ");
}

function recordingOperatorActionText(metrics) {
  if (!metrics) {
    return "Дочекайтесь перших метрик або перевірте сервер перед консультацією.";
  }
  const emptyReason = String(metrics.draftEmptyReason || "");
  const source = String(metrics.sessionSource || "").toLowerCase();
  if (emptyReason === "educational_content" || source.startsWith("file_test")) {
    return "Це тестовий або навчальний аудіоматеріал: перевіряйте транскрипцію, але не дописуйте його в Лакмус.";
  }
  const verdict = sessionQualityVerdict(metrics);
  if (verdict.level === "bad") {
    return "Не покладатися на автозаповнення; експортуйте/скопіюйте наявний текст і заповніть ключові поля вручну.";
  }
  if (verdict.level === "waiting") {
    return "Дочекатися обробки або перевірити, чи рухається смужка мікрофона.";
  }
  if (/Клінічний текст не знайдено|Whisper не знайшов|Старий запис|Нетипов/i.test(verdict.label)) {
    return "Не дописувати в Лакмус автоматично; спочатку перевірити повну транскрипцію і мікрофон.";
  }
  if (verdict.level === "warn") {
    return "Перевірити транскрипцію й текст для полів перед дописуванням у Лакмус.";
  }
  return "Перевірити текст для полів; після перевірки можна дописувати у видимі поля Лакмуса.";
}

async function fetchCompanionDiagnostics() {
  return callCompanion("/v1/diagnostics", {
    sessionId: sessionId || "",
    auth: await getLicenseAuth()
  });
}

function textOf(element) {
  return String(element?.innerText || element?.textContent || "").trim();
}

function sanitizeDiagnosticLine(value) {
  return String(value || "").replace(/\s+/g, " ").trim().slice(0, 240);
}

function sanitizeMetricsForDiagnostics(metrics) {
  if (!metrics) {
    return null;
  }
  const allowed = [
    "status",
    "processingState",
    "durationSeconds",
    "firstTranscriptLatencySeconds",
    "chunkCount",
    "mimeTypes",
    "doneChunkCount",
    "pendingChunkCount",
    "errorChunkCount",
    "skippedWhisperChunkCount",
    "rawWhisperSegmentCount",
    "usefulWhisperSegmentCount",
    "discardedWhisperSegmentCount",
    "rawWhisperTextChars",
    "discardedWhisperTextChars",
    "segmentCount",
    "suspiciousSegmentCount",
    "clinicalSignalSegmentCount",
    "draftEligibleSegmentCount",
    "draftExcludedSegmentCount",
    "medicalTermCount",
    "replayAvailableSegmentCount",
    "transcriptChars",
    "draftEmptyReason",
    "draftFieldsWithText",
    "draftChars",
    "estimatedManualTypingMinutes",
    "estimatedReviewMinutes",
    "estimatedNetSavedMinutes",
    "automationCoveragePercent",
    "errorCount",
    "avgChunkLatencySeconds",
    "maxChunkLatencySeconds",
    "avgProcessingSeconds",
    "avgInputRmsDb",
    "avgInputPeakDb",
    "avgActiveFrameRatio",
    "clientExtensionVersion",
    "clientAudioPipeline",
    "clientLiveChunkPolicy"
  ];
  return Object.fromEntries(allowed.map((key) => [key, metrics[key] ?? null]));
}

function renderLakmusDraftForm(draft) {
  const values = {
    ...lakmusDraftValues(draft),
    ...draftReviewValues
  };
  for (const input of elsRecorder.draftInputs || []) {
    const key = input.dataset.draftKey;
    if (document.activeElement === input) {
      continue;
    }
    input.value = values[key] || "";
  }
  renderDraftAdvice(draft);
  renderDraftEvidence(draft);
  updateDraftActionButtons();
}

function renderDraftAdvice(draft) {
  const target = elsRecorder.draftAdvice;
  if (!target) {
    return;
  }
  const values = lakmusDraftValues(lakmusDraftFromReviewForm(draft));
  const fieldCount = Object.keys(values).length;
  const metrics = latestMetrics || null;
  const transcriptChars = Number(metrics?.transcriptChars || latestTranscript.length || draft?.transcriptChars || 0);
  const pending = Number(metrics?.pendingChunkCount || 0);
  const errors = Number(metrics?.errorCount || 0) + Number(metrics?.errorChunkCount || 0);
  const eligible = Number(metrics?.draftEligibleSegmentCount || 0);
  const emptyReason = String(metrics?.draftEmptyReason || "").trim();
  target.className = "draft-advice waiting";

  if (fieldCount) {
    target.className = "draft-advice ok";
    target.textContent = `Підготовлено ${fieldCount}/3 полів. Перевірте текст, за потреби відредагуйте тут, тоді дописуйте у видимі поля Лакмуса.`;
    return;
  }

  const reasonAdvice = draftEmptyReasonAdvice(emptyReason);
  if (reasonAdvice) {
    target.className = `draft-advice ${reasonAdvice.level}`;
    target.textContent = reasonAdvice.text;
    return;
  }

  if (errors) {
    target.className = "draft-advice bad";
    target.textContent = "Поля не сформовані через помилки обробки. Експортуйте наявний текст, перевірте діагностику і заповнюйте ключові дані вручну.";
    return;
  }

  if (pending) {
    target.textContent = `Ще обробляється ${pending} чанків. Поля оновляться автоматично, якщо в транскрипції зʼявляться клінічні факти.`;
    return;
  }

  if (!transcriptChars) {
    target.textContent = "Поля порожні, бо ще немає розпізнаного тексту. Перевірте мікрофон і дочекайтесь першого чанка.";
    return;
  }

  if (!eligible) {
    target.className = "draft-advice warn";
    target.textContent = "Транскрипція є, але без безпечних клінічних фактів для трьох полів. Автодопис у Лакмус вимкнений для такого тексту.";
    return;
  }

  const verdict = sessionQualityVerdict(metrics);
  target.className = `draft-advice ${verdict.level === "bad" ? "bad" : "warn"}`;
  target.textContent = `${verdict.hint || "Mapper не сформував текст для полів."} ${recordingOperatorActionText(metrics)}`;
}

function draftEmptyReasonAdvice(reason) {
  const messages = {
    processing_pending: {
      level: "waiting",
      text: "Частина аудіо ще обробляється. Поля оновляться автоматично, якщо у транскрипції зʼявляться клінічні факти."
    },
    no_processed_audio: {
      level: "waiting",
      text: "Поля порожні, бо сервер ще не обробив жодного аудіофрагмента. Перевірте, що запис триває і смужка мікрофона рухається."
    },
    near_silence: {
      level: "warn",
      text: "Поля порожні, бо аудіо схоже на тишу або дуже слабкий сигнал. Перевірте вибраний мікрофон і говоріть ближче до нього."
    },
    no_transcript: {
      level: "warn",
      text: "Whisper обробив аудіо, але не знайшов придатного тексту. Перевірте мікрофон, шум і зробіть короткий тест перед консультацією."
    },
    discarded_noise: {
      level: "warn",
      text: "Whisper чув лише короткі шумові або службові фрази, які безпечно відфільтровані. Для тесту скажіть чітку медичну фразу біля вибраного мікрофона."
    },
    educational_content: {
      level: "warn",
      text: "Транскрипція є, але це схоже на навчальний або тестовий матеріал, а не факти конкретної консультації. Повний текст збережено, автодопис у Лакмус вимкнений."
    },
    no_clinical_signal: {
      level: "warn",
      text: "Транскрипція є, але без безпечних клінічних фактів для трьох полів. Автодопис у Лакмус вимкнений для такого тексту."
    },
    clinical_segments_excluded: {
      level: "warn",
      text: "Клінічні слова знайдені, але сегменти позначені як ненадійні для автозаповнення. Перевірте повну транскрипцію і за потреби скопіюйте текст вручну."
    },
    mapper_error: {
      level: "bad",
      text: "Транскрипція є, але mapper полів не спрацював. Скопіюйте повну транскрипцію або експорт TXT і перевірте діагностику."
    },
    mapper_empty: {
      level: "warn",
      text: "Mapper не сформував текст для полів. Перевірте повну транскрипцію і вручну перенесіть тільки підтверджені факти."
    }
  };
  return messages[reason] || null;
}

function renderDraftEvidence(draft) {
  const container = elsRecorder.draftEvidence;
  if (!container) {
    return;
  }
  const fields = draftEvidenceFields(draft);
  container.textContent = "";
  if (!fields.length) {
    const empty = document.createElement("p");
    empty.className = "draft-evidence-empty";
    empty.textContent = "Джерела зʼявляться після структурованої транскрипції.";
    container.append(empty);
    return;
  }
  const title = document.createElement("div");
  title.className = "draft-evidence-title";
  title.textContent = "Звідки взято";
  container.append(title);
  for (const field of fields) {
    const group = document.createElement("section");
    group.className = "draft-evidence-group";
    const heading = document.createElement("h3");
    heading.textContent = field.label;
    group.append(heading);
    for (const item of field.evidence) {
      group.append(renderDraftEvidenceItem(item));
    }
    container.append(group);
  }
}

function draftEvidenceFields(draft) {
  const fields = Array.isArray(draft?.fields)
    ? draft.fields
    : Object.entries(draft?.fields || {}).map(([key, value]) => ({ key, ...(value || {}) }));
  return fields
    .map((field) => ({
      key: field.key,
      label: field.label || draftFieldLabel(field.key),
      evidence: draftEvidenceItems(field.evidence)
    }))
    .filter((field) => ["complaints", "disease_history", "life_history"].includes(field.key) && field.evidence.length);
}

function draftEvidenceItems(evidence) {
  const items = Array.isArray(evidence) ? evidence : [];
  return items
    .map((item) => {
      if (typeof item === "string") {
        return { text: item, replayAvailable: false };
      }
      return {
        text: String(item?.text || ""),
        segmentId: String(item?.segmentId || ""),
        start: item?.start ?? null,
        end: item?.end ?? null,
        label: String(item?.label || ""),
        replayAvailable: Boolean(item?.replayAvailable)
      };
    })
    .filter((item) => item.text.trim())
    .slice(0, 5);
}

function renderDraftEvidenceItem(item) {
  const row = document.createElement("div");
  row.className = "draft-evidence-item";
  if (item.replayAvailable && item.segmentId) {
    const replayButton = document.createElement("button");
    replayButton.type = "button";
    replayButton.className = "draft-evidence-replay";
    replayButton.textContent = "▶";
    replayButton.title = "Переслухати джерело для цього поля";
    replayButton.addEventListener("click", () => {
      playTranscriptSegment({
        id: item.segmentId,
        audio: { replayAvailable: true }
      }, replayButton).catch((error) => {
        setStatus(error.message || "Не вдалося відтворити джерело.", true);
      });
    });
    row.append(replayButton);
  }
  const body = document.createElement("div");
  const meta = document.createElement("span");
  meta.className = "draft-evidence-meta";
  const hasTime = Number.isFinite(Number(item.start)) && Number.isFinite(Number(item.end));
  meta.textContent = [
    hasTime ? `[${formatTranscriptClock(item.start)}-${formatTranscriptClock(item.end)}]` : "",
    item.label || ""
  ].filter(Boolean).join(" ");
  const text = document.createElement("p");
  text.textContent = item.text;
  body.append(meta, text);
  row.append(body);
  return row;
}

function draftFieldLabel(key) {
  return {
    complaints: "Скарги",
    disease_history: "Анамнез захворювання",
    life_history: "Анамнез життя"
  }[key] || "Поле";
}

function handleDraftReviewInput(event) {
  const input = event.currentTarget;
  const key = input?.dataset?.draftKey;
  if (!["complaints", "disease_history", "life_history"].includes(key)) {
    return;
  }
  draftReviewValues[key] = String(input.value || "");
  updateDraftActionButtons();
  schedulePersistSessionState();
}

function sanitizeDraftReviewValues(values) {
  const result = {};
  for (const key of ["complaints", "disease_history", "life_history"]) {
    if (Object.prototype.hasOwnProperty.call(values || {}, key)) {
      result[key] = String(values[key] || "");
    }
  }
  return result;
}

function schedulePersistSessionState() {
  if (!sessionId) {
    return;
  }
  if (draftReviewSaveTimer) {
    clearTimeout(draftReviewSaveTimer);
  }
  draftReviewSaveTimer = setTimeout(() => {
    draftReviewSaveTimer = null;
    persistSessionState().catch(() => {});
  }, DRAFT_REVIEW_SAVE_DEBOUNCE_MS);
}

function updateDraftActionButtons() {
  const hasValues = hasLakmusDraftValues(lakmusDraftFromReviewForm(latestDraft));
  elsRecorder.fillLakmusButton.disabled = !hasValues;
  elsRecorder.copyDraftButton.disabled = !hasValues;
  if (elsRecorder.saveCorrectionsButton) {
    elsRecorder.saveCorrectionsButton.disabled = !draftHasUserCorrections();
  }
}

function draftHasUserCorrections() {
  const original = lakmusDraftValues(latestDraft);
  for (const key of ["complaints", "disease_history", "life_history"]) {
    if (!Object.prototype.hasOwnProperty.call(draftReviewValues || {}, key)) {
      continue;
    }
    if (normalizeCorrectionCompare(draftReviewValues[key]) !== normalizeCorrectionCompare(original[key] || "")) {
      return true;
    }
  }
  return false;
}

async function saveDraftCorrections() {
  if (!sessionId) {
    setStatus("Немає активної сесії для збереження виправлень.", true);
    return;
  }
  const original = lakmusDraftValues(latestDraft);
  const changed = [];
  for (const key of ["complaints", "disease_history", "life_history"]) {
    if (!Object.prototype.hasOwnProperty.call(draftReviewValues || {}, key)) {
      continue;
    }
    const before = String(original[key] || "");
    const after = String(draftReviewValues[key] || "");
    if (normalizeCorrectionCompare(before) === normalizeCorrectionCompare(after)) {
      continue;
    }
    changed.push({ key, before, after });
  }
  if (!changed.length) {
    updateDraftActionButtons();
    setStatus("Немає нових виправлень для навчання.");
    return;
  }
  const button = elsRecorder.saveCorrectionsButton;
  if (button) {
    button.disabled = true;
  }
  try {
    for (const item of changed) {
      await callCompanion("/v1/session/correction", {
        sessionId,
        auth: await getLicenseAuth(),
        fieldKey: item.key,
        originalFieldValue: item.before,
        correctedFieldValue: item.after,
        source: "draft_field_review"
      });
    }
    latestDraft = lakmusDraftFromReviewForm(latestDraft);
    draftReviewValues = {};
    schedulePersistSessionState();
    updateDraftActionButtons();
    setStatus(`Виправлення збережено для навчання: ${changed.length}.`);
    logEvent(`Виправлення для навчання збережено: ${changed.length}.`);
  } catch (error) {
    setStatus(error.message || "Не вдалося зберегти виправлення.", true);
    updateDraftActionButtons();
  }
}

function normalizeCorrectionCompare(value) {
  return String(value || "").replace(/\s+/g, " ").trim();
}

function renderQualitySummary(metrics) {
  if (!elsRecorder.qualitySummary) {
    return;
  }
  if (!metrics) {
    elsRecorder.qualitySummary.className = "quality-summary waiting";
    elsRecorder.qualitySummary.textContent = "Метрики з'являться після перших оброблених чанків.";
    return;
  }
  const noSpeechReason = noSpeechDetectedMessage(metrics);
  const verdict = sessionQualityVerdict(metrics, noSpeechReason);
  elsRecorder.qualitySummary.className = `quality-summary ${verdict.level}`;
  const done = Number(metrics.doneChunkCount || 0);
  const total = Number(metrics.chunkCount || 0);
  const pending = Number(metrics.pendingChunkCount || 0);
  const transcriptChars = Number(metrics.transcriptChars || 0);
  const fields = Number(metrics.draftFieldsWithText || 0);
  const parts = [
    `<strong>${escapeHtml(verdict.label)}</strong>`,
    escapeHtml(recordingOutcomeSummary(metrics)),
    escapeHtml(`оброблено ${done}/${total}`)
  ];
  if (pending) {
    parts.push(escapeHtml(`ще в черзі ${pending}`));
  }
  if (transcriptChars) {
    parts.push(escapeHtml("транскрипт готовий"));
  }
  parts.push(escapeHtml(`поля ${fields}/3`));
  const impact = workflowImpactText(metrics);
  if (impact) {
    parts.push(escapeHtml(impact));
  }
  elsRecorder.qualitySummary.innerHTML = parts
    .map((part) => `<span>${part}</span>`)
    .join(" · ");
}

function workflowImpactText(metrics) {
  const draftFields = Number(metrics?.draftFieldsWithText || 0);
  const saved = Number(metrics?.estimatedNetSavedMinutes || 0);
  const review = Number(metrics?.estimatedReviewMinutes || 0);
  const coverage = Number(metrics?.automationCoveragePercent || 0);
  if (!draftFields) {
    return "";
  }
  const savedText = saved > 0 ? `економія ~${formatMetricMinutes(saved)}` : "економія ще не підтверджена";
  const reviewText = review > 0 ? `перевірка ~${formatMetricMinutes(review)}` : "";
  const coverageText = Number.isFinite(coverage) ? `покриття ${Math.round(coverage)}%` : "";
  return [savedText, reviewText, coverageText].filter(Boolean).join(", ");
}

function recordingOutcomeSummary(metrics) {
  if (!metrics) {
    return "чекаю перший оброблений чанк";
  }
  const doneCount = Number(metrics.doneChunkCount || 0);
  const pendingCount = Number(metrics.pendingChunkCount || 0);
  const errorCount = Number(metrics.errorCount || 0) + Number(metrics.errorChunkCount || 0);
  const transcriptChars = Number(metrics.transcriptChars || 0);
  const draftFields = Number(metrics.draftFieldsWithText || 0);
  const emptyReason = String(metrics.draftEmptyReason || "");
  const source = String(metrics.sessionSource || "").toLowerCase();
  const inputRms = Number(metrics.avgInputRmsDb);
  const activeRatio = Number(metrics.avgActiveFrameRatio);
  const weakAudio = (
    Number.isFinite(inputRms) && inputRms < -34
  ) || (
    Number.isFinite(activeRatio) && activeRatio < 0.18
  );

  if (errorCount > 0) {
    return "є технічна помилка обробки; запис не вважати готовим, зробіть діагностику або TXT-експорт";
  }
  if (!doneCount && pendingCount > 0) {
    return "аудіо вже в черзі; текст ще не мав зʼявитися, дочекайтесь Whisper";
  }
  if (!doneCount) {
    return "ще немає обробленого аудіо; перевірте, чи рухається смужка мікрофона";
  }
  if (!transcriptChars) {
    if (emptyReason === "discarded_noise") {
      return "аудіо оброблено; корисної мови не знайдено, у поля нічого не додано";
    }
    return weakAudio
      ? "аудіо оброблено; мікрофон майже не почув мову"
      : "аудіо оброблено; текст поки порожній";
  }
  if (emptyReason === "educational_content" || source.startsWith("file_test")) {
    return "транскрипція працює; Лакмус не заповнюється, бо це тестовий або навчальний матеріал";
  }
  if (!draftFields) {
    return "транскрипція працює, але для трьох полів Лакмуса бракує безпечних клінічних фактів";
  }
  if (draftFields < 3) {
    return `транскрипція працює; підготовлено ${draftFields}/3 полів, решту перевірити вручну`;
  }
  return "транскрипція і текст для трьох полів готові до лікарської перевірки";
}

function sessionQualityVerdict(metrics, noSpeechReason = noSpeechDetectedMessage(metrics)) {
  const doneCount = Number(metrics?.doneChunkCount || 0);
  const pendingCount = Number(metrics?.pendingChunkCount || 0);
  const errorCount = Number(metrics?.errorCount || 0) + Number(metrics?.errorChunkCount || 0);
  const transcriptChars = Number(metrics?.transcriptChars || 0);
  const draftFields = Number(metrics?.draftFieldsWithText || 0);
  const draftEligibleSegments = Number(metrics?.draftEligibleSegmentCount || 0);
  const inputRms = Number(metrics?.avgInputRmsDb);
  const activeRatio = Number(metrics?.avgActiveFrameRatio);
  const suspicious = Number(metrics?.suspiciousSegmentCount || 0);
  const emptyReason = String(metrics?.draftEmptyReason || "");
  const source = String(metrics?.sessionSource || "").toLowerCase();
  const veryWeakAudio = (
    Number.isFinite(inputRms) && inputRms <= MIC_PREFLIGHT_WARN_RMS_DBFS
  ) || (
    Number.isFinite(activeRatio) && activeRatio < MIC_PREFLIGHT_WARN_ACTIVE_RATIO
  );
  const weakAudio = (
    Number.isFinite(inputRms) && inputRms < -34
  ) || (
    Number.isFinite(activeRatio) && activeRatio < 0.18
  );
  const pipeline = recordingPipelineState(metrics);

  if (errorCount > 0) {
    return { level: "bad", label: "Проблема обробки", hint: "Є помилки чанків або companion. Запис не втрачено, перевірте діагностику." };
  }
  if (pipeline.level === "bad") {
    return { level: "bad", label: pipeline.label, hint: pipeline.hint };
  }
  if (!doneCount) {
    return pendingCount
      ? { level: "waiting", label: "Чекаю Whisper", hint: "Аудіо в черзі, текст з'явиться після обробки." }
      : { level: "waiting", label: "Чекаю перший чанк", hint: "Говоріть кілька чітких фраз, смужка мікрофона має рухатись." };
  }
  if (pipeline.level === "warn") {
    return { level: "warn", label: pipeline.label, hint: pipeline.hint };
  }
  if (emptyReason === "educational_content" || source.startsWith("file_test")) {
    return {
      level: "warn",
      label: "Тестова транскрипція",
      hint: "Текст розпізнано, але це тестовий або навчальний матеріал; поля Лакмуса навмисно залишені порожніми."
    };
  }
  if (noSpeechReason || !transcriptChars) {
    return veryWeakAudio
      ? { level: "bad", label: "Мікрофон майже не почув мову", hint: "Перед консультацією змініть мікрофон або посадіть джерело голосу ближче." }
      : { level: "warn", label: "Whisper не знайшов корисний текст", hint: "Повторіть тест із чіткою медичною фразою біля мікрофона." };
  }
  if (weakAudio) {
    return { level: "warn", label: "Звук слабкий", hint: "Транскрипція є, але якість може просідати. Краще говорити ближче." };
  }
  if (suspicious > 0) {
    return { level: "warn", label: "Потрібна ручна перевірка", hint: "Є фрагменти зі слабкою впевненістю або невизначеним співрозмовником." };
  }
  if (!draftFields) {
    if (!draftEligibleSegments) {
      return { level: "warn", label: "Клінічний текст не знайдено", hint: "Повна транскрипція є, але в поля не дописую: немає безпечних скарг або анамнезу." };
    }
    return { level: "warn", label: "Текст для полів не сформовано", hint: "Є клінічні фрагменти, але mapper не заповнив поля. Перевірте форму вручну." };
  }
  return { level: "ok", label: "Готово до перевірки", hint: "Текст і поля сформовані, перегляньте перед дописуванням." };
}

function recordingPipelineState(metrics) {
  if (!metrics) {
    return { level: "waiting", label: "Pipeline ще невідомий", hint: "" };
  }
  const source = String(metrics.sessionSource || "").toLowerCase();
  const policy = String(metrics.clientLiveChunkPolicy || "").toLowerCase();
  const pipeline = String(metrics.clientAudioPipeline || "").toLowerCase();
  const mimeTypes = metrics.mimeTypes && typeof metrics.mimeTypes === "object" ? metrics.mimeTypes : {};
  const hasWebm = Object.keys(mimeTypes).some((type) => type.includes("webm"));
  const hasWav = Object.keys(mimeTypes).some((type) => type.includes("wav"));
  const isMicrophone = !source || source === "microphone";
  if (source.startsWith("file_test")) {
    return {
      level: "ok",
      label: "File-to-Whisper тест",
      hint: "Транскрипція перевіряється з файлу без мікрофона і без відтворення через динаміки."
    };
  }
  if (isMicrophone && hasWebm) {
    return {
      level: "bad",
      label: "Старий WEBM-формат",
      hint: "Цей запис може мати EBML-помилки. Новий recorder має давати WAV-only."
    };
  }
  if (isMicrophone && Number(metrics.chunkCount || 0) > 0 && (!policy || !pipeline)) {
    return {
      level: "warn",
      label: "Старий запис без pipeline metadata",
      hint: "Текст можна перевіряти, але для нового тесту треба бачити webaudio_pcm_wav / wav_only."
    };
  }
  if (isMicrophone && policy && policy !== "wav_only") {
    return {
      level: "warn",
      label: "Нетипова політика live-запису",
      hint: `Очікується wav_only, зараз: ${policy}.`
    };
  }
  if (isMicrophone && pipeline && pipeline !== "webaudio_pcm_wav") {
    return {
      level: "warn",
      label: "Нетиповий audio pipeline",
      hint: `Очікується webaudio_pcm_wav, зараз: ${pipeline}.`
    };
  }
  if (hasWav && policy === "wav_only" && pipeline === "webaudio_pcm_wav") {
    return {
      level: "ok",
      label: "WAV-only pipeline",
      hint: "Новий формат запису активний."
    };
  }
  return { level: "waiting", label: "Pipeline очікує перший чанк", hint: "" };
}

function recordingPipelineLabel(metrics) {
  if (!metrics) {
    return "очікує метрики";
  }
  const mimeTypes = metrics.mimeTypes && typeof metrics.mimeTypes === "object"
    ? Object.entries(metrics.mimeTypes).map(([key, value]) => `${key}:${value}`).join(", ")
    : "";
  return [
    metrics.clientExtensionVersion ? `v${metrics.clientExtensionVersion}` : "v?",
    metrics.clientAudioPipeline || "pipeline?",
    metrics.clientLiveChunkPolicy || "policy?",
    mimeTypes || "mime?"
  ].join(" / ");
}

function formatMetricSeconds(value) {
  const seconds = Math.max(0, Math.round(Number(value || 0)));
  if (seconds < 60) {
    return `${seconds}с`;
  }
  return `${Math.floor(seconds / 60)}хв ${String(seconds % 60).padStart(2, "0")}с`;
}

function formatMetricMinutes(value) {
  const minutes = Math.max(0, Number(value || 0));
  if (!Number.isFinite(minutes) || !minutes) {
    return "0 хв";
  }
  if (minutes < 1) {
    return "<1 хв";
  }
  const rounded = Math.round(minutes * 10) / 10;
  return `${String(rounded).replace(".", ",")} хв`;
}

function escapeHtml(value) {
  return String(value || "")
    .replaceAll("&", "&amp;")
    .replaceAll("<", "&lt;")
    .replaceAll(">", "&gt;")
    .replaceAll('"', "&quot;")
    .replaceAll("'", "&#039;");
}

function lakmusDraftFromReviewForm(baseDraft) {
  const values = {};
  for (const input of elsRecorder.draftInputs || []) {
    const key = input.dataset.draftKey;
    values[key] = String(input.value || "").trim();
  }
  return {
    ...(baseDraft || {}),
    fields: [
      { key: "complaints", label: "Скарги", value: values.complaints || "" },
      { key: "disease_history", label: "Анамнез захворювання", value: values.disease_history || "" },
      { key: "life_history", label: "Анамнез життя", value: values.life_history || "" }
    ]
  };
}

function formatLakmusDraftForCopy() {
  const values = lakmusDraftValues(lakmusDraftFromReviewForm(latestDraft));
  return [
    ["Скарги", values.complaints],
    ["Анамнез захворювання", values.disease_history],
    ["Анамнез життя", values.life_history]
  ].map(([label, value]) => `${label}\n${value || ""}`.trimEnd()).join("\n\n");
}

function hasLakmusDraftValues(draft) {
  return Boolean(Object.keys(lakmusDraftValues(draft)).length);
}

function lakmusDraftValues(draft) {
  const result = {};
  const allowed = new Set(["complaints", "disease_history", "life_history"]);
  if (!draft) {
    return result;
  }

  const items = Array.isArray(draft.fields)
    ? draft.fields
    : Object.entries(draft.fields || {}).map(([key, value]) => ({ key, ...(value || {}) }));
  for (const item of items) {
    if (!allowed.has(item.key)) {
      continue;
    }
    const value = String(item.value || "").trim();
    if (value) {
      result[item.key] = value;
    }
  }
  return result;
}

function lakmusDraftSignature(draft) {
  const values = lakmusDraftValues(draft);
  return ["complaints", "disease_history", "life_history"]
    .map((key) => `${key}:${values[key] || ""}`)
    .join("\n");
}

function lakmusDraftDeltaValues(draft) {
  const full = lakmusDraftValues(draft);
  const delta = {};
  for (const [key, value] of Object.entries(full)) {
    const previous = String(appliedLakmusValues[key] || "").trim();
    if (!previous) {
      delta[key] = value;
      continue;
    }
    if (value.startsWith(previous)) {
      const nextPart = value.slice(previous.length).trim();
      if (nextPart) {
        delta[key] = nextPart;
      }
    }
  }
  return delta;
}

function markAppliedLakmusFields(response, draft) {
  if (!response?.filled?.length) {
    return;
  }
  const full = lakmusDraftValues(draft);
  const labelToKey = {
    "Скарги": "complaints",
    "Анамнез захворювання": "disease_history",
    "Анамнез життя": "life_history"
  };
  for (const label of response.filled) {
    const key = labelToKey[label];
    if (key && full[key]) {
      appliedLakmusValues[key] = full[key];
    }
  }
}

async function callCompanion(path, payload) {
  const response = await fetchWithTimeout(`${companionBaseUrl()}${path}`, {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify(payload)
  }, companionRequestTimeout(path));
  const data = await response.json().catch(() => ({}));
  if (!response.ok) {
    const error = new Error(`Сервер транскрипції повернув помилку: ${companionUserMessage({
      companionCode: data.code || "",
      companionMessage: data.message || "",
      httpStatus: response.status
    })}`);
    error.companionCode = data.code || "";
    error.companionMessage = data.message || "";
    error.httpStatus = response.status;
    throw error;
  }
  return data;
}

function companionRequestTimeout(path) {
  if (path === "/v1/session/chunk") {
    return CHUNK_SEND_TIMEOUT_MS;
  }
  if (path === "/v1/session/finish") {
    return FINISH_TIMEOUT_MS;
  }
  if (path === "/v1/session/status" || path === "/v1/diagnostics") {
    return STATUS_TIMEOUT_MS;
  }
  return FETCH_TIMEOUT_MS;
}

function isNonRetryableCompanionError(error) {
  const code = String(error?.companionCode || "");
  if (code === "too_many_active_sessions") {
    return false;
  }
  if ([
    "session_not_found",
    "session_auth_mismatch",
    "license_auth",
    "license_inactive",
    "request_too_large",
    "invalid_audio",
    "invalid_chunk",
    "invalid_session",
    "chunk_limit"
  ].includes(code)) {
    return true;
  }
  const httpStatus = Number(error?.httpStatus || 0);
  return httpStatus >= 400 && httpStatus < 500 && httpStatus !== 408 && httpStatus !== 429;
}

function isLocalLicenseAuthError(error) {
  return /ліценз|license|термін дії/i.test(String(error?.message || ""));
}

function companionUserMessage(error) {
  const code = String(error?.companionCode || "");
  if (code === "session_not_found") {
    return "Попередню сесію не знайдено на сервері. Якщо це стара черга, натисніть \"Очистити чергу\".";
  }
  if (code === "session_auth_mismatch") {
    return "Ця сесія належить іншій ліцензії або пристрою. Для старої черги використайте \"Очистити чергу\".";
  }
  if (code === "license_auth" || code === "license_inactive") {
    return "Ліцензія не підтвердилась. Відкрийте Parallax Health і перевірте доступ.";
  }
  if (code === "chunk_limit") {
    return "Досягнуто ліміт чанків цієї сесії. Завершіть або очистіть стару чергу.";
  }
  if (code === "too_many_active_sessions") {
    return "На сервері забагато незавершених сесій. Завершіть попередні або очистіть стару чергу.";
  }
  if (code === "request_too_large" || code === "invalid_audio" || code === "invalid_chunk" || code === "invalid_session") {
    return "Аудіофрагмент не прийнято сервером. Очистіть стару чергу або почніть новий запис.";
  }
  if (error?.name === "AbortError") {
    return "Відправка або обробка аудіо триває довше звичного.";
  }
  const companionMessage = String(error?.companionMessage || "").trim();
  if (companionMessage) {
    return companionMessage.slice(0, 180);
  }
  const message = String(error?.message || "").trim();
  if (message && !message.includes("Failed to fetch")) {
    return message.slice(0, 180);
  }
  return "Сервер не відповідає.";
}

async function fetchWithTimeout(url, options = {}, timeoutMs = FETCH_TIMEOUT_MS) {
  const controller = new AbortController();
  const timer = setTimeout(() => controller.abort(), timeoutMs);
  try {
    return await fetch(url, { ...options, signal: controller.signal });
  } finally {
    clearTimeout(timer);
  }
}

async function getLicenseAuth() {
  const values = await chrome.storage.local.get(Object.values(LICENSE_STORAGE_KEYS));
  const license = values[LICENSE_STORAGE_KEYS.license];
  const deviceId = values[LICENSE_STORAGE_KEYS.deviceId];
  if (!license?.active || !license.email || !license.licenseKey || !deviceId) {
    throw new Error("Для запису консультації потрібна активована ліцензія.");
  }
  if (license.expiresAt && new Date(license.expiresAt).getTime() < Date.now()) {
    throw new Error("Термін дії ліцензії завершився.");
  }
  return {
    productId: RECORDER_CONFIG.productId || "med-tools-print-ua",
    email: String(license.email).trim().toLowerCase(),
    licenseKey: String(license.licenseKey).trim().toUpperCase(),
    deviceId: String(deviceId),
    extensionVersion: chrome.runtime.getManifest().version
  };
}

function companionBaseUrl() {
  return String(DEFAULT_COMPANION_URL).replace(/\/+$/, "");
}

async function blobToBase64(blob) {
  if (!blob || !blob.size) {
    throw new Error("порожній аудіофрагмент");
  }
  return readBlobAsBase64(blob);
}

function readBlobAsBase64(blob) {
  return new Promise((resolve, reject) => {
    const reader = new FileReader();
    reader.onerror = () => reject(reader.error || new Error("не вдалося прочитати аудіофрагмент"));
    reader.onload = () => {
      const dataUrl = String(reader.result || "");
      const commaIndex = dataUrl.indexOf(",");
      if (commaIndex < 0) {
        reject(new Error("аудіофрагмент має некоректний формат"));
        return;
      }
      resolve(dataUrl.slice(commaIndex + 1));
    };
    reader.readAsDataURL(blob);
  });
}

function base64ToBytes(value) {
  const binary = atob(value);
  const bytes = new Uint8Array(binary.length);
  for (let index = 0; index < binary.length; index += 1) {
    bytes[index] = binary.charCodeAt(index);
  }
  return bytes;
}

function renderRecorderState(state) {
  const controls = recorderControlState(state, {
    recordingActive,
    startInProgress,
    sessionId,
    livePendingCount: livePendingCount(),
    encodingCount: encodingTasks.size,
    consultationFinished,
    transcriptionTestActive
  });
  const { isRecording, isStarting, isProcessing, isFinishPending, isFinished, isStopped } = controls;
  elsRecorder.startButton.disabled = controls.startDisabled;
  elsRecorder.startButton.textContent = sessionId && !isRecording ? "Новий запис" : "Почати запис";
  if (elsRecorder.testTranscriptionButton) {
    elsRecorder.testTranscriptionButton.disabled = controls.testDisabled;
  }
  if (elsRecorder.micTestButton) {
    elsRecorder.micTestButton.disabled = controls.testDisabled;
  }
  if (elsRecorder.testFileButton) {
    elsRecorder.testFileButton.disabled = controls.testDisabled;
  }
  elsRecorder.stopButton.disabled = controls.stopDisabled;
  elsRecorder.stopButton.textContent = isStarting ? "Скасувати" : "Стоп";
  elsRecorder.finishButton.disabled = controls.finishDisabled;
  if (elsRecorder.microphoneSelect) {
    elsRecorder.microphoneSelect.disabled = isRecording || isStarting || isProcessing;
  }
  if (elsRecorder.refreshMicrophonesButton) {
    elsRecorder.refreshMicrophonesButton.disabled = isRecording || isStarting || isProcessing;
  }
  updateStaleQueueControls();
  const statusClass = isRecording
    ? "recording"
    : (isStarting || isProcessing || isFinishPending)
      ? "processing"
      : (isStopped || isFinished)
        ? "stopped"
        : "idle";
  const statusLabel = isRecording
    ? "Запис"
    : isStarting
      ? "Запуск"
      : isProcessing
      ? "Обробка"
      : isFinishPending
        ? "Досилаю"
        : isFinished
          ? "Завершено"
          : isStopped
            ? "Зупинено"
            : "Очікує";
  const compactLabel = isRecording
    ? "Запис"
    : isStarting
      ? "Старт"
      : isProcessing
      ? "Обробка"
      : isFinishPending
        ? "Черга"
        : isFinished
          ? "Готово"
          : isStopped
            ? "Стоп"
            : "Очікує";
  elsRecorder.recordingBadge.className = `badge ${statusClass}`;
  elsRecorder.recordingBadge.textContent = statusLabel;
  elsRecorder.compactStatus.className = `compact-status ${statusClass}`;
  elsRecorder.compactStatus.textContent = compactLabel;
  notifyHostStatus({
    statusClass,
    statusLabel: compactLabel
  });
  if (!isRecording) {
    stopDurationTimer();
  }
  updateExportButtonState();
}

function recorderControlState(state, snapshot = {}) {
  const livePending = Number(snapshot.livePendingCount || 0);
  const encodingCount = Number(snapshot.encodingCount || 0);
  const isRecording = state === "recording" || Boolean(snapshot.recordingActive);
  const isStarting = !isRecording && (state === "starting" || Boolean(snapshot.startInProgress));
  const isProcessing = !isRecording && !isStarting && state === "processing";
  const isFinishPending = state === "finish_pending";
  const isFinished = state === "finished";
  const isStopped = state === "stopped";
  const hasSessionWork = Boolean(snapshot.sessionId) || isRecording || isStarting || livePending > 0 || encodingCount > 0;
  const busyForTests = isRecording || isStarting || isProcessing || isFinishPending || Boolean(snapshot.transcriptionTestActive) || livePending > 0 || encodingCount > 0;
  return {
    isRecording,
    isStarting,
    isProcessing,
    isFinishPending,
    isFinished,
    isStopped,
    hasSessionWork,
    startDisabled: isRecording || isStarting || isProcessing,
    stopDisabled: !(isRecording || isStarting),
    finishDisabled: !hasSessionWork || Boolean(snapshot.consultationFinished),
    testDisabled: busyForTests
  };
}

function setStatus(message, isError = false) {
  elsRecorder.statusLine.textContent = message;
  elsRecorder.statusLine.classList.toggle("error", isError);
  appendEventLog(message, isError);
  updateExportButtonState();
}

function appendEventLog(message, isError = false) {
  if (!elsRecorder.eventLog || !message) {
    return;
  }
  const row = document.createElement("div");
  row.className = isError ? "error" : "";
  const time = document.createElement("time");
  time.textContent = new Date().toLocaleTimeString("uk-UA", { hour: "2-digit", minute: "2-digit", second: "2-digit" });
  row.append(time, document.createTextNode(message));
  elsRecorder.eventLog.prepend(row);
  while (elsRecorder.eventLog.children.length > 12) {
    elsRecorder.eventLog.lastElementChild.remove();
  }
}
