// ============ AcademIA8 · Consola — cliente API real (arq_05) ============
// Adaptador entre el modelo del front (curso con unidades de lista mixta) y los
// contratos del backend (arq_05). MAPEOS:
//  - Ítem de texto  → PASO (texto; orden = posición en la lista).
//  - Pregunta       → PASO con checkpoint embebido (opcion_multiple, bloqueante,
//                     reorientacion) + CONCEPTO autogenerado 1:1 (invisible).
//  - Reordenar la lista → re-PUT de los pasos con su nuevo `orden`.
// Los fragmentos clásicos / items_recall sueltos de cursos backfilleados se
// preservan y muestran read-only (no se editan desde la consola en v1).

const A8Api = (() => {
  const CFG = () => window.A8_CONFIG || {};
  const GENERIC_REORIENT = "Repasemos ese punto con calma antes de seguir.";
  const DEFAULT_CRITERIO = {
    tipo: "intento_recall_correcto_minimo",
    parametros: { minimo_intentos_correctos: 1 },
  };

  // Migración del textarea único viejo (bug: todo se guardaba en si_incorrecto,
  // ver commit 9a71ac0). Si el autor pegó texto con separadores tipo "Si
  // correcto:", "Correcto:" o "*Correcto:*" (con o sin "Si", con o sin
  // asteriscos alrededor de la palabra o los dos puntos, con o sin bullet/guion
  // inicial, insensible a mayúsculas — variantes reales vistas en curso_ywprl6),
  // lo separamos en los tres campos reales. Si ningún separador matchea, todo
  // se queda en si_incorrecto — mismo comportamiento que hasta ahora, no se
  // inventa nada.
  function parseReorientLegacy(text) {
    const empty = { si_correcto: null, si_parcial: null, si_incorrecto: null };
    if (!text || !text.trim()) return empty;
    const re = /\*?\b(?:si\s+)?(correcto|parcial|incorrecto)\b\*?\s*:\*?/gi;
    const matches = [...text.matchAll(re)];
    if (!matches.length) return { ...empty, si_incorrecto: text.trim() };
    const out = { ...empty };
    const keyOf = { correcto: "si_correcto", parcial: "si_parcial", incorrecto: "si_incorrecto" };
    for (let i = 0; i < matches.length; i++) {
      const key = keyOf[matches[i][1].toLowerCase()];
      const start = matches[i].index + matches[i][0].length;
      const end = i + 1 < matches.length ? matches[i + 1].index : text.length;
      // El próximo bloque puede empezar con un bullet ("- Si parcial: ..." o
      // "- *Parcial:*..."); ese "-"/"•"/"*" queda pegado al final de ESTE valor
      // al cortar por índice. Lo sacamos junto con el trim final, sin tocar el
      // texto real de la guía.
      const value = text.slice(start, end).trim().replace(/[-•*]\s*$/, "").trim();
      if (key) out[key] = value || null;
    }
    return out;
  }

  // Campos de reorientación ya separados (autoría YAML, o guardado con el front
  // nuevo) vs. legado (todo en si_incorrecto, posiblemente con prefijos pegados
  // a mano) — decide cuál rama aplicar. Devuelve los nombres que usa el ítem del
  // front (reorientCorrecto/reorientParcial/reorientIncorrecto), no los del
  // backend (si_correcto/si_parcial/si_incorrecto).
  function reorientFields(reor) {
    if (reor.si_correcto || reor.si_parcial) {
      return {
        reorientCorrecto: reor.si_correcto || "",
        reorientParcial: reor.si_parcial || "",
        reorientIncorrecto: reor.si_incorrecto || "",
      };
    }
    const parsed = parseReorientLegacy(reor.si_incorrecto || "");
    return {
      reorientCorrecto: parsed.si_correcto || "",
      reorientParcial: parsed.si_parcial || "",
      reorientIncorrecto: parsed.si_incorrecto || "",
    };
  }

  // ---- request de bajo nivel ----
  class ApiError extends Error {
    constructor(code, message, status) {
      super(message);
      this.code = code;
      this.status = status;
    }
  }

  async function request(method, path, body) {
    const cfg = CFG();
    const resp = await fetch(cfg.apiBase + path, {
      method,
      headers: { "Content-Type": "application/json", ...window.A8Auth.authHeader() },
      body: body === undefined ? undefined : JSON.stringify(body),
    });
    if (resp.status === 401) {
      // token vencido/ inválido → reautenticar
      window.A8Auth.logout();
      throw new ApiError("unauthorized", "Sesión expirada", 401);
    }
    let data = null;
    const text = await resp.text();
    if (text) { try { data = JSON.parse(text); } catch (e) { data = null; } }
    if (!resp.ok) {
      const err = (data && data.error) || {};
      throw new ApiError(err.code || "server", err.message || ("HTTP " + resp.status), resp.status);
    }
    return data;
  }

  // ============ traducción backend → modelo del front ============
  // Paso "propio" de la consola → ítem editable (texto o pregunta).
  // Pregunta de recall en 2 sabores (arq_07-adyacente, editor de C7): opcion_multiple
  // (con opciones + índice correcto) o abierta (con rúbrica, sin opciones — el tutor
  // evalúa la respuesta libre del alumno contra la rúbrica).
  function pasoToItem(p) {
    if (p.checkpoint_id) {
      const tipoPregunta = p.checkpoint_tipo === "abierta" ? "abierta" : "opcion_multiple";
      const reor = p.checkpoint_reorientacion || {};
      const base = {
        id: p.id,
        kind: "chk",
        tipoPregunta,
        question: p.checkpoint_enunciado,
        blocking: !!p.checkpoint_bloqueante,
        ...reorientFields(reor),
        _conceptId: p.checkpoint_concepto_id,
      };
      if (tipoPregunta === "abierta") {
        base.rubrica = p.checkpoint_rubrica || "";
        // Sin opciones (evaluada por rúbrica) — SIEMPRE array, nunca undefined:
        // un consumidor que asuma `options` presente (ej. UnitPreview, vista
        // Publicado) no debe recibir un shape distinto según el tipo de pregunta.
        base.options = [];
      } else {
        const opts = p.checkpoint_opciones || [];
        base.options = opts;
        base.correct = Math.max(0, opts.indexOf(p.checkpoint_respuesta_referencia));
      }
      return base;
    }
    if (p.media_tipo) {
      // C4 (arq_06): paso solo-media o media+texto. `s3Key`/`caption` viajan
      // tal cual al editor; `text` puede estar vacío (paso solo-media).
      // C4bis (arq_06bis): `textoComoCaption`/`esperaRespuesta`, default false
      // (paso sin flags = comportamiento de hoy).
      return {
        id: p.id, kind: "frag", type: p.media_tipo,
        title: p.media_caption || p.id,
        text: p.texto || "",
        s3Key: p.media_s3_key || "",
        caption: p.media_caption || "",
        textoComoCaption: !!p.texto_como_caption,
        esperaRespuesta: !!p.espera_respuesta,
      };
    }
    return { id: p.id, kind: "frag", type: "texto", text: p.texto, esperaRespuesta: !!p.espera_respuesta };
  }

  // Cantidad de opciones de un ítem `kind:'chk'` (para vistas de solo-conteo,
  // ej. UnitPreview en la pestaña Publicado). `pasoToItem` ya garantiza
  // `options` siempre array, pero un consumidor no debe volver a asumirlo sin
  // guarda: esto blindó un crash real (pantalla en blanco, curso_ywprl6 unidad
  // 1 — 8 checkpoints tipo 'abierta' sin `options`, `c.options.length` sin
  // optional chaining reventaba el render, sin ErrorBoundary en front/console).
  function chkOptionsCount(c) {
    return (c && c.options ? c.options : []).length;
  }

  // ¿El paso vino de un curso IMPORTADO (backfill del formato original) o lo creó
  // la consola? La consola solo produce pasos con forma canónica: la pregunta es
  // `opcion_multiple` o `abierta` (C7-editor), autor "consola", su enunciado ES el
  // texto del paso, y nunca setea `clase`. Cualquier paso fuera de esa forma es de
  // un guion conversacional importado: su `texto` es material de enseñanza que NO
  // hay que descartar (ese descarte era el bug: un paso con checkpoint se mostraba
  // solo como cuestionario).
  function pasoIsImported(p) {
    if (p.clase !== undefined && p.clase !== null) return true;
    if (p.checkpoint_id) {
      if (
        p.checkpoint_tipo &&
        p.checkpoint_tipo !== "opcion_multiple" &&
        p.checkpoint_tipo !== "abierta"
      ) return true;
      if (p.checkpoint_autor && p.checkpoint_autor !== "consola") return true;
      if ((p.texto || "") !== (p.checkpoint_enunciado || "")) return true;
    }
    return false;
  }
  // Una unidad es importada (solo lectura en v1) si trae entidades clásicas
  // (fragmento / item_recall sueltos) o cualquier paso con forma importada.
  function unitIsImported(u) {
    if ((u.fragmentos || []).length) return true;
    if ((u.items_recall || []).length) return true;
    return (u.pasos || []).some(pasoIsImported);
  }

  function readonlyFrag(f) {
    return { id: f.id, kind: "frag", type: "texto", text: f.texto, _readonly: true };
  }
  function readonlyItem(it) {
    return {
      id: it.id, kind: "chk", question: it.enunciado, options: it.opciones || [],
      correct: Math.max(0, (it.opciones || []).indexOf(it.respuesta_referencia)),
      blocking: true, reorient: "", _readonly: true,
    };
  }
  // Un paso importado se muestra FIEL a lo que recibe el alumno: su texto de
  // enseñanza como contenido de texto (visible) y, si lleva checkpoint, la
  // pregunta como cuestionario. Ambos en solo lectura. Un mismo paso puede rendir
  // dos ítems (texto + cuestionario).
  function importedPasoItems(p) {
    const out = [];
    if ((p.texto || "").trim()) {
      out.push({ id: p.id, kind: "frag", type: "texto", text: p.texto, _readonly: true });
    }
    if (p.checkpoint_id) {
      const opts = p.checkpoint_opciones || [];
      out.push({
        id: p.checkpoint_id,
        kind: "chk",
        question: p.checkpoint_enunciado || "",
        options: opts,
        correct: Math.max(0, opts.indexOf(p.checkpoint_respuesta_referencia)),
        blocking: !!p.checkpoint_bloqueante,
        reorient: "",
        _readonly: true,
      });
    }
    return out;
  }

  function unitToFront(u) {
    if (unitIsImported(u)) {
      // Solo lectura: cada paso → texto (visible) + su pregunta; luego cualquier
      // fragmento / item_recall clásico suelto. Nada editable en v1.
      const items = [];
      (u.pasos || [])
        .slice()
        .sort((a, b) => (a.orden || 0) - (b.orden || 0))
        .forEach((p) => items.push(...importedPasoItems(p)));
      (u.fragmentos || [])
        .slice()
        .sort((a, b) => (a.posicion || 0) - (b.posicion || 0))
        .forEach((f) => items.push(readonlyFrag(f)));
      (u.items_recall || []).forEach((it) => items.push(readonlyItem(it)));
      return { id: u.id, title: u.titulo, posicion: u.posicion, items, readonly: true };
    }
    // Unidad "propia" de la consola: pasos editables (texto o pregunta).
    const items = (u.pasos || [])
      .slice()
      .sort((a, b) => (a.orden || 0) - (b.orden || 0))
      .map(pasoToItem);
    return { id: u.id, title: u.titulo, posicion: u.posicion, items, readonly: false };
  }

  function courseToFront(cid, borrador) {
    const meta = borrador.curso || {};
    const crono = borrador.cronograma || {};
    const units = (borrador.unidades || [])
      .slice()
      .sort((a, b) => (a.posicion || 0) - (b.posicion || 0))
      .map(unitToFront);
    const trayPasos = (borrador.bandeja && borrador.bandeja.paso) || [];
    const tray = { items: trayPasos.map(pasoToItem) };
    return {
      id: cid,
      meta_draft: {
        name: meta.titulo || "",
        description: meta.descripcion || "",
        welcome: meta.bienvenida || "",
        // Media real adjunta a la bienvenida (video, etc. — uploader v2).
        welcomeMediaTipo: meta.bienvenida_media_tipo || "",
        welcomeMediaS3Key: meta.bienvenida_media_s3_key || "",
        welcomeMediaCaption: meta.bienvenida_media_caption || "",
        welcomeComoCaption: !!meta.bienvenida_como_caption,
        schedule: crono.ritmo_sugerido || "Entrega diaria de contenido",
      },
      draft: { units, tray },
      _loaded: true,
    };
  }

  // ============ traducción front → cuerpo del backend ============
  function itemToPasoBody(item, orden, unidadId) {
    if (item.kind === "chk") {
      const tipoPregunta = item.tipoPregunta || "opcion_multiple";
      const checkpoint = {
        id: item.id,
        concepto_id: "k-" + item.id,
        enunciado: item.question || "",
        tipo: tipoPregunta,
        bloqueante: !!item.blocking,
        reorientacion: {
          si_correcto: item.reorientCorrecto ? item.reorientCorrecto.trim() || null : null,
          si_parcial: item.reorientParcial ? item.reorientParcial.trim() || null : null,
          si_incorrecto: (item.reorientIncorrecto || "").trim() || GENERIC_REORIENT,
        },
        estado_ciclo_vida: "borrador",
        autor: "consola",
      };
      if (tipoPregunta === "abierta") {
        checkpoint.rubrica = item.rubrica || "Evaluar según el criterio del tutor.";
      } else {
        const opts = item.options || [];
        const correct = opts[item.correct] !== undefined ? opts[item.correct] : (opts[0] || "");
        checkpoint.opciones = opts;
        checkpoint.respuesta_referencia = correct;
        checkpoint.rubrica = "Respuesta correcta: " + correct;
      }
      return {
        orden,
        texto: item.question || "(pregunta)",
        unidad_id: unidadId,
        checkpoint,
      };
    }
    // ítem de media (C4, arq_06): tipo/s3_key/caption estructurados; el texto
    // que lo acompaña (si hay) sigue viajando en `texto`.
    if (item.type && item.type !== "texto") {
      const body = {
        orden, texto: item.text || "", unidad_id: unidadId,
        texto_como_caption: !!item.textoComoCaption,
        espera_respuesta: !!item.esperaRespuesta,
      };
      if (item.s3Key) {
        body.media = { tipo: item.type, s3_key: item.s3Key, caption: item.caption || null };
      }
      return body;
    }
    // ítem de texto
    return {
      orden, texto: item.text || "", unidad_id: unidadId,
      espera_respuesta: !!item.esperaRespuesta,
    };
  }

  // ============ API de alto nivel (una por action del front) ============
  const enc = encodeURIComponent;

  async function listCourses() {
    return request("GET", "/cursos");
  }
  async function getCourse(id, copia) {
    // copia opcional: "publicado" trae el snapshot que ven los alumnos (solo lectura).
    const qs = copia === "publicado" ? "?copia=publicado" : "";
    const b = await request("GET", "/cursos/" + enc(id) + qs);
    return courseToFront(id, b);
  }
  async function createCourse(id, meta) {
    await request("POST", "/cursos", {
      id,
      titulo: meta.name || "Curso nuevo",
      descripcion: meta.description || "",
      estado: "borrador",
      bienvenida: meta.welcome || "",
      cronograma: {
        ritmo_sugerido: meta.schedule || "Entrega diaria de contenido",
        politica_repaso: { intervalos_dias: [1] },
      },
    });
  }
  async function deleteCourse(id) {
    await request("DELETE", "/cursos/" + enc(id));
  }
  async function updateMeta(id, meta) {
    const body = {
      titulo: meta.name,
      descripcion: meta.description,
      bienvenida: meta.welcome,
    };
    if (meta.welcomeMediaS3Key) {
      body.bienvenida_media = {
        tipo: meta.welcomeMediaTipo,
        s3_key: meta.welcomeMediaS3Key,
        caption: meta.welcomeMediaCaption || null,
      };
      body.bienvenida_como_caption = !!meta.welcomeComoCaption;
    }
    await request("PATCH", "/cursos/" + enc(id), body);
  }

  async function createUnit(id, unitId, title, posicion) {
    await request("POST", "/cursos/" + enc(id) + "/unidades", {
      id: unitId,
      curso_id: id,
      posicion,
      titulo: title,
      criterio_completitud: DEFAULT_CRITERIO,
    });
  }
  async function renameUnit(id, unitId, title) {
    await request("PATCH", "/cursos/" + enc(id) + "/unidades/" + enc(unitId), { titulo: title });
  }
  async function deleteUnit(id, unitId) {
    await request("DELETE", "/cursos/" + enc(id) + "/unidades/" + enc(unitId));
  }
  async function reorderUnits(id, orderedIds) {
    const posiciones = {};
    orderedIds.forEach((uid, i) => { posiciones[uid] = i + 1; });
    await request("POST", "/cursos/" + enc(id) + "/unidades/reorder", { posiciones });
  }

  // ítems: texto o pregunta → paso (+ concepto si es pregunta)
  async function saveItem(id, unitId, item, orden) {
    if (item.kind === "chk") {
      await request("PUT", "/cursos/" + enc(id) + "/items/concepto/k-" + enc(item.id), {
        id: "k-" + item.id,
        nombre: (item.question || "pregunta").slice(0, 80),
        unidad_id: unitId,
      });
    }
    await request(
      "PUT", "/cursos/" + enc(id) + "/items/paso/" + enc(item.id),
      itemToPasoBody(item, orden, unitId)
    );
  }
  async function deleteItem(id, itemId) {
    await request("DELETE", "/cursos/" + enc(id) + "/items/paso/" + enc(itemId));
  }
  async function moveItemToUnit(id, itemId, unitId) {
    // unitId null = bandeja
    await request("POST", "/cursos/" + enc(id) + "/items/paso/" + enc(itemId) + "/move", {
      unidad_id: unitId,
    });
  }
  // Mueve el concepto autogenerado de una pregunta junto con su paso, para que la
  // referencia checkpoint→concepto siga viviendo en la misma unidad (si no, la
  // publicación fallaría: el concepto quedaría en la bandeja / otra unidad).
  async function moveConcepto(id, itemId, unitId) {
    await request("POST", "/cursos/" + enc(id) + "/items/concepto/k-" + enc(itemId) + "/move", {
      unidad_id: unitId,
    });
  }
  // reordenar pasos = re-PUT con nuevo orden (arq_05: el `orden` del paso es atributo)
  async function reorderItems(id, unitId, orderedItems) {
    for (let i = 0; i < orderedItems.length; i++) {
      const it = orderedItems[i];
      if (it._readonly) continue;
      await request(
        "PUT", "/cursos/" + enc(id) + "/items/paso/" + enc(it.id),
        itemToPasoBody(it, i + 1, unitId)
      );
    }
  }

  async function publish(id) {
    return request("POST", "/cursos/" + enc(id) + "/publish");
  }
  async function estado(id) {
    const r = await request("GET", "/cursos/" + enc(id) + "/estado");
    return r.estado;
  }
  async function diff(id, opts) {
    const detalle = opts && opts.detalle;
    return request("GET", "/cursos/" + enc(id) + "/diff" + (detalle ? "?detalle=true" : ""));
  }
  async function disclaimer(id) {
    return request("GET", "/cursos/" + enc(id) + "/disclaimer");
  }
  async function getConocimiento(id) {
    return request("GET", "/cursos/" + enc(id) + "/conocimiento");
  }

  // ---- Costos y precio (COSTOS-1) ----
  async function getParametrosCostos() {
    return request("GET", "/costos/parametros");
  }
  async function setParametrosCostos(params) {
    return request("POST", "/costos/parametros", params);
  }
  async function calcularCostos(id, input) {
    return request("POST", "/cursos/" + enc(id) + "/costos/calcular", input);
  }
  async function getComercialCurso(id) {
    return request("GET", "/cursos/" + enc(id) + "/costos");
  }
  async function confirmarPrecioCurso(id, input) {
    return request("POST", "/cursos/" + enc(id) + "/costos/confirmar", input);
  }
  async function marcarCursoGratuito(id, motivo) {
    return request("POST", "/cursos/" + enc(id) + "/costos/gratuito", { motivo });
  }

  // ---- Alumnos (gestión desde la consola) ----
  async function listAlumnos(id) {
    return request("GET", "/cursos/" + enc(id) + "/alumnos");
  }
  // ALTA-1: `accionConflicto` es null en el primer intento (el backend chequea
  // solo y devuelve `necesita_decision` si el teléfono ya está activo en otro
  // curso); en el reintento se manda la decisión del operador ("pausar_otra" /
  // "continuar_ambas" / "cancelar").
  async function inscribirAlumno(id, phone, accionConflicto, nombreCompleto, email) {
    const body = accionConflicto ? { phone, accion_conflicto: accionConflicto } : { phone };
    if (nombreCompleto) body.nombre_completo = nombreCompleto;
    if (email) body.email = email;
    return request("POST", "/cursos/" + enc(id) + "/alumnos", body);
  }
  async function pausarAlumno(id, alumnoId) {
    await request("POST", "/cursos/" + enc(id) + "/alumnos/" + enc(alumnoId) + "/pausar");
  }
  async function reactivarAlumno(id, alumnoId) {
    await request("POST", "/cursos/" + enc(id) + "/alumnos/" + enc(alumnoId) + "/reactivar");
  }
  // C6 (jul 2026): completa nombre/email de un alumno ya inscripto — sin esto
  // el certificado de fin de curso se queda en c6_pendiente.
  async function actualizarDatosAlumno(id, alumnoId, nombreCompleto, email) {
    const body = {};
    if (nombreCompleto) body.nombre_completo = nombreCompleto;
    if (email) body.email = email;
    await request("POST", "/cursos/" + enc(id) + "/alumnos/" + enc(alumnoId) + "/datos", body);
  }

  // ---- Duplicar curso ----
  async function duplicarCurso(id, destino, titulo) {
    return request("POST", "/cursos/" + enc(id) + "/duplicar", { destino, titulo });
  }

  // ---- Pruebas (reset por curso de un número de la allowlist, C4bis-reset) ----
  // El front nunca ve ni manda un teléfono completo: cada número de la
  // allowlist trae un `id` opaco (front/console/pruebas.jsx lo usa como
  // referencia); el backend resuelve id->teléfono contra la allowlist fresca
  // de SSM en cada llamada, nunca confía en nada que mande el cliente. El
  // alcance es SIEMPRE un curso puntual (el botón vive dentro del curso): el
  // reset borra solo la inscripción a ESE curso, no las de otros.
  async function listPruebasAllowlist() {
    const r = await request("GET", "/pruebas/allowlist");
    return r.numeros;
  }
  async function pruebasAllowlistAdd(telefono) {
    const r = await request("POST", "/pruebas/allowlist", { telefono });
    return r.numeros;
  }
  async function pruebasAllowlistRemove(id) {
    const r = await request("DELETE", "/pruebas/allowlist/" + enc(id));
    return r.numeros;
  }
  async function pruebasInventario(cursoId, id) {
    return request("GET", "/cursos/" + enc(cursoId) + "/pruebas/" + enc(id) + "/inventario");
  }
  async function pruebasReset(cursoId, id) {
    return request("POST", "/cursos/" + enc(cursoId) + "/pruebas/" + enc(id) + "/reset", { confirmar: true });
  }

  // ---- Uploader real v2 (C4): presigned PUT directo a S3 ----
  // 1) pedimos al backend una URL firmada (valida extensión + tamaño ANTES de
  //    firmar, arq_06 uploader v2); 2) subimos el archivo directo a S3 con esa
  //    URL. El backend nunca ve los bytes — pasan browser → S3 directo.
  async function getMediaUploadUrl(id, { filename, sizeBytes, unidadId }) {
    return request("POST", "/cursos/" + enc(id) + "/media-upload-url", {
      filename,
      size_bytes: sizeBytes,
      unidad_id: unidadId || null,
    });
  }

  // XMLHttpRequest (no fetch) porque necesitamos el evento de progreso de subida
  // real para la barra de progreso — fetch no lo expone.
  function uploadFileToS3(uploadUrl, file, contentType, onProgress) {
    return new Promise((resolve, reject) => {
      const xhr = new XMLHttpRequest();
      xhr.open("PUT", uploadUrl);
      // Content-Type TIENE que ser exactamente el que se firmó (mismo valor que
      // devolvió getMediaUploadUrl) — S3 rechaza la firma si no coincide.
      xhr.setRequestHeader("Content-Type", contentType);
      xhr.upload.onprogress = (e) => {
        if (e.lengthComputable && onProgress) onProgress(Math.round((e.loaded / e.total) * 100));
      };
      xhr.onload = () => {
        if (xhr.status >= 200 && xhr.status < 300) resolve();
        else reject(new Error("Error al subir el archivo (HTTP " + xhr.status + ")"));
      };
      xhr.onerror = () => reject(new Error("Error de red al subir el archivo"));
      xhr.send(file);
    });
  }

  // Estado de compresión de un video (C4bis compresor): sidecar que escribe
  // la Lambda de compresión, `{status: null|"procesando"|"listo"|"error", ...}`.
  async function getMediaStatus(id, s3Key) {
    return request("GET", "/cursos/" + enc(id) + "/media-status?s3_key=" + enc(s3Key));
  }

  // Polling simple: consulta cada `intervalMs` hasta un estado terminal
  // (listo/error) o `status:null` (nunca hubo nada que comprimir — no-op del
  // guard <=16MB). `onUpdate` se llama en cada tick, incluida la primera.
  // Sin librerías: setTimeout recursivo, se corta solo al llegar a terminal
  // o al vencer `timeoutMs`.
  function pollMediaStatus(id, s3Key, onUpdate, { intervalMs = 4000, timeoutMs = 15 * 60 * 1000 } = {}) {
    const start = Date.now();
    const tick = async () => {
      let st;
      try {
        st = await getMediaStatus(id, s3Key);
      } catch (e) {
        onUpdate({ status: "error", motivo: (e && e.message) || String(e) });
        return;
      }
      onUpdate(st);
      if (!st || st.status === "listo" || st.status === "error" || !st.status) return;
      if (Date.now() - start < timeoutMs) setTimeout(tick, intervalMs);
    };
    tick();
  }

  return {
    ApiError, listCourses, getCourse, createCourse, deleteCourse, updateMeta,
    createUnit, renameUnit, deleteUnit, reorderUnits,
    saveItem, deleteItem, moveItemToUnit, moveConcepto, reorderItems,
    publish, estado, diff, disclaimer, getConocimiento,
    getParametrosCostos, setParametrosCostos, calcularCostos, getComercialCurso,
    confirmarPrecioCurso, marcarCursoGratuito,
    listAlumnos, inscribirAlumno, pausarAlumno, reactivarAlumno, actualizarDatosAlumno, duplicarCurso,
    getMediaUploadUrl, uploadFileToS3, getMediaStatus, pollMediaStatus,
    listPruebasAllowlist, pruebasAllowlistAdd, pruebasAllowlistRemove, pruebasInventario, pruebasReset,
    chkOptionsCount,
    // Derivación de tipos, pura y sin I/O: se exporta para tests (Node).
    _derive: {
      pasoToItem, pasoIsImported, unitIsImported, importedPasoItems, unitToFront, courseToFront,
      itemToPasoBody, parseReorientLegacy, reorientFields, chkOptionsCount,
    },
  };
})();

window.A8Api = A8Api;
