/*
  Toasts and dialogs.

  Replaces window.confirm/prompt, which cannot be styled, ignore the theme, and
  block the whole tab. Dialogs are built on <dialog>, so focus trapping, Escape
  and inertness of the page behind come from the browser rather than from
  hand-written key handling.

  Everything returns a Promise, so callers read as if they were still using the
  built-ins:

      if (await lembas.confirm({ message: "Delete this?" })) { ... }
      const name = await lembas.prompt({ message: "New name", value: old });
      lembas.notify("Saved.", { kind: "success" });
*/
(function () {
  "use strict";

  var TOAST_MS = 4000;

  function el(tag, className, text) {
    var node = document.createElement(tag);
    if (className) node.className = className;
    // textContent, never innerHTML: these messages carry filenames, chat
    // titles and upstream error text, none of which is ours to trust.
    if (text != null) node.textContent = text;
    return node;
  }

  /* --- Toasts ------------------------------------------------------------ */
  function toastHost() {
    var host = document.getElementById("toasts");
    if (!host) {
      host = el("div", "toasts");
      host.id = "toasts";
      // Announced politely so a screen reader hears it without being yanked
      // away from whatever it was reading.
      host.setAttribute("role", "status");
      host.setAttribute("aria-live", "polite");
      document.body.appendChild(host);
    }
    return host;
  }

  function notify(message, options) {
    options = options || {};
    var toast = el("div", "toast toast--" + (options.kind || "info"));
    toast.appendChild(el("span", "toast__text", message));

    var close = el("button", "toast__close");
    close.type = "button";
    close.setAttribute("aria-label", "Dismiss");
    close.textContent = "×";
    close.addEventListener("click", function () { dismiss(toast); });
    toast.appendChild(close);

    toastHost().appendChild(toast);
    // Next frame, so the entry transition has a state to move from.
    requestAnimationFrame(function () { toast.classList.add("is-in"); });

    var timeout = options.timeout == null ? TOAST_MS : options.timeout;
    if (timeout > 0) setTimeout(function () { dismiss(toast); }, timeout);
    return toast;
  }

  function dismiss(toast) {
    if (!toast || toast.dataset.going) return;
    toast.dataset.going = "1";
    toast.classList.remove("is-in");
    setTimeout(function () { toast.remove(); }, 180);
  }

  /* --- Dialogs ----------------------------------------------------------- */
  function buildDialog(options) {
    var dialog = el("dialog", "dialog");
    var form = el("form", "dialog__form");
    form.method = "dialog";

    if (options.title) form.appendChild(el("h2", "dialog__title", options.title));
    if (options.message) form.appendChild(el("p", "dialog__message", options.message));

    var input = null;
    if (options.kind === "prompt") {
      input = el("input", "input");
      input.type = "text";
      input.value = options.value || "";
      if (options.placeholder) input.placeholder = options.placeholder;
      input.setAttribute("aria-label", options.title || options.message || "Value");
      form.appendChild(input);
    }

    var actions = el("div", "dialog__actions");

    var cancel = el("button", "btn", options.cancelLabel || "Cancel");
    cancel.type = "button";
    cancel.value = "cancel";
    actions.appendChild(cancel);

    var accept = el(
      "button",
      "btn " + (options.danger ? "btn--danger-solid" : "btn--primary"),
      options.confirmLabel || "OK"
    );
    accept.type = "submit";
    accept.value = "accept";
    actions.appendChild(accept);

    form.appendChild(actions);
    dialog.appendChild(form);
    document.body.appendChild(dialog);

    return { dialog: dialog, form: form, input: input, cancel: cancel, accept: accept };
  }

  function open(options) {
    return new Promise(function (resolve) {
      var parts = buildDialog(options);
      var settled = false;

      function finish(value) {
        if (settled) return;
        settled = true;
        resolve(value);
        parts.dialog.close();
        // Let the closing transition finish before the node disappears.
        setTimeout(function () { parts.dialog.remove(); }, 200);
      }

      parts.cancel.addEventListener("click", function () { finish(options.kind === "prompt" ? null : false); });
      parts.form.addEventListener("submit", function (event) {
        event.preventDefault();
        finish(options.kind === "prompt" ? (parts.input.value || "") : true);
      });
      // Escape and the backdrop both mean "no".
      parts.dialog.addEventListener("cancel", function (event) {
        event.preventDefault();
        finish(options.kind === "prompt" ? null : false);
      });
      parts.dialog.addEventListener("click", function (event) {
        if (event.target === parts.dialog) finish(options.kind === "prompt" ? null : false);
      });

      parts.dialog.showModal();
      if (parts.input) {
        parts.input.focus();
        parts.input.select();
      } else {
        (options.danger ? parts.cancel : parts.accept).focus();
      }
    });
  }

  function confirm(options) {
    if (typeof options === "string") options = { message: options };
    return open(Object.assign({ kind: "confirm", confirmLabel: "OK" }, options));
  }

  function prompt(options) {
    if (typeof options === "string") options = { message: options };
    return open(Object.assign({ kind: "prompt", confirmLabel: "Save" }, options));
  }

  window.lembas = window.lembas || {};
  window.lembas.notify = notify;
  window.lembas.confirm = confirm;
  window.lembas.prompt = prompt;

  /* --- htmx integration --------------------------------------------------
     hx-confirm normally calls window.confirm. Intercepting the event lets every
     existing hx-confirm attribute keep working while getting the themed dialog,
     with no change at the call sites. */
  document.addEventListener("htmx:confirm", function (event) {
    if (!event.detail.question) return;  // no confirmation asked for
    event.preventDefault();

    var trigger = event.detail.elt;
    confirm({
      title: trigger && trigger.dataset.confirmTitle,
      message: event.detail.question,
      confirmLabel: (trigger && trigger.dataset.confirmLabel) || "Delete",
      danger: !trigger || trigger.dataset.confirmDanger !== "false",
    }).then(function (ok) {
      if (ok) event.detail.issueRequest(true);
    });
  });

  /* A submit button that acts on its own (formaction) rather than the form it
     sits in. Confirming the whole form would be wrong: the same form also has
     a plain Save. */
  document.addEventListener("click", function (event) {
    var button = event.target.closest("[data-confirm-button]");
    if (!button || button.dataset.confirmed) return;
    event.preventDefault();
    event.stopPropagation();
    confirm({
      title: button.dataset.confirmTitle,
      message: button.dataset.confirmButton,
      confirmLabel: button.dataset.confirmLabel || "Delete",
      danger: button.dataset.confirmDanger !== "false",
    }).then(function (ok) {
      if (!ok) return;
      button.dataset.confirmed = "1";
      button.click();
      delete button.dataset.confirmed;
    });
  }, true);

  /* Asking for one line of text before a request goes out: renaming a folder,
     renaming a chat.

     Deliberately NOT htmx's own hx-prompt. htmx calls the browser's prompt()
     synchronously and only then fires htmx:prompt with the answer already in
     hand -- so intercepting the event cannot supply a different one, and the
     native box appears regardless. Cancelling the event only aborts the
     request. This is the data-confirm-button shape instead: swallow the click,
     ask in our own dialog, write the answer where htmx will collect it, and
     click again behind a guard flag.

     The answer goes into hx-vals as a normal field rather than into a header,
     because every route that wants it already reads a form. htmx reads
     attributes when the request is built, so setting it just before the second
     click is enough. It is JSON.stringify'd, never concatenated: a folder
     called `"` would otherwise produce hx-vals that does not parse, and the
     request would go out with the field missing rather than with the name. */
  document.addEventListener("click", function (event) {
    var el = event.target.closest("[data-prompt]");
    if (!el || el.dataset.prompted) return;
    event.preventDefault();
    event.stopPropagation();

    var field = el.dataset.promptField || "name";
    prompt({
      title: el.dataset.promptTitle,
      message: el.dataset.prompt,
      value: el.dataset.promptValue || "",
      confirmLabel: el.dataset.promptLabel || "Save",
    }).then(function (value) {
      /* null is Cancel. An empty string is somebody clearing the box and
         pressing Save, which is not a rename either -- the routes ignore a
         blank name, so sending it would be a request that does nothing. */
      if (value === null || !String(value).trim()) return;
      var values = {};
      values[field] = String(value).trim();
      el.setAttribute("hx-vals", JSON.stringify(values));
      el.dataset.prompted = "1";
      el.click();
      delete el.dataset.prompted;
    });
  }, true);

  /* Plain forms opt in with data-confirm, so they need no inline onsubmit. */
  document.addEventListener("submit", function (event) {
    var form = event.target;
    if (!form.dataset || !form.dataset.confirm || form.dataset.confirmed) return;
    event.preventDefault();
    confirm({
      title: form.dataset.confirmTitle,
      message: form.dataset.confirm,
      confirmLabel: form.dataset.confirmLabel || "Delete",
      danger: form.dataset.confirmDanger !== "false",
    }).then(function (ok) {
      if (!ok) return;
      form.dataset.confirmed = "1";
      form.submit();
    });
  }, true);
})();

/*
  The model picker.

  A <select> cannot render an avatar, a description or capability badges, so
  the control is built out of buttons and a hidden input. Keyboard behaviour is
  written out by hand for the same reason -- there is no native widget doing it
  for us.
*/
(function () {
  "use strict";

  function close(picker) {
    var menu = picker.querySelector("[data-picker-menu]");
    var toggle = picker.querySelector("[data-picker-toggle]");
    if (!menu || menu.hidden) return;
    menu.hidden = true;
    toggle.setAttribute("aria-expanded", "false");
  }

  function closeAll(except) {
    document.querySelectorAll("[data-picker]").forEach(function (picker) {
      if (picker !== except) close(picker);
    });
  }

  function open(picker) {
    var menu = picker.querySelector("[data-picker-menu]");
    var toggle = picker.querySelector("[data-picker-toggle]");
    closeAll(picker);
    menu.hidden = false;
    toggle.setAttribute("aria-expanded", "true");

    var filter = menu.querySelector("[data-picker-filter]");
    if (filter) {
      filter.value = "";
      applyFilter(menu, "");
      filter.focus();
    } else {
      var selected = menu.querySelector(".picker__option.is-selected") ||
                     menu.querySelector(".picker__option");
      if (selected) selected.focus();
    }
    // Keep the chosen model in view when the list is long.
    var current = menu.querySelector(".picker__option.is-selected");
    if (current) current.scrollIntoView({ block: "nearest" });
  }

  function applyFilter(menu, needle) {
    var shown = 0;
    menu.querySelectorAll(".picker__option").forEach(function (option) {
      var match = !needle || option.dataset.pickerSearch.indexOf(needle) !== -1;
      option.hidden = !match;
      if (match) shown += 1;
    });
    var empty = menu.querySelector("[data-picker-empty]");
    if (empty) empty.hidden = shown > 0;
  }

  function choose(picker, value) {
    var navigate = picker.querySelector("[data-picker-navigate]");
    if (navigate) {
      window.location = navigate.dataset.pickerNavigate + encodeURIComponent(value);
      return;
    }
    var input = picker.querySelector("[data-picker-input]");
    if (input) {
      input.value = value;
      // htmx listens for change on the input; assigning .value does not fire it.
      input.dispatchEvent(new Event("change", { bubbles: true }));
    }

    // Reflect the choice immediately rather than waiting for a reload.
    picker.querySelectorAll(".picker__option").forEach(function (option) {
      var selected = option.dataset.pickerValue === value;
      option.classList.toggle("is-selected", selected);
      option.setAttribute("aria-selected", selected ? "true" : "false");
    });
    var chosen = picker.querySelector('[data-picker-value="' + CSS.escape(value) + '"]');
    var label = picker.querySelector(".picker__label");
    var avatar = picker.querySelector(".picker__button .picker__avatar");
    if (chosen && label) {
      label.textContent = chosen.querySelector(".picker__option-name").textContent.trim();
    }
    if (chosen && avatar) {
      var source = chosen.querySelector(".picker__avatar");
      if (source) avatar.replaceWith(source.cloneNode(true));
    }
    close(picker);
    if (window.lembas && window.lembas.notify) {
      window.lembas.notify("Model switched to " + (label ? label.textContent : value), {
        kind: "info", timeout: 2000,
      });
    }
  }

  document.addEventListener("click", function (event) {
    var toggle = event.target.closest("[data-picker-toggle]");
    if (toggle) {
      var picker = toggle.closest("[data-picker]");
      var menu = picker.querySelector("[data-picker-menu]");
      if (menu.hidden) open(picker); else close(picker);
      return;
    }

    var option = event.target.closest("[data-picker-value]");
    if (option) {
      choose(option.closest("[data-picker]"), option.dataset.pickerValue);
      return;
    }

    /* A menu item is an action, so the menu has served its purpose the moment
       one is pressed. Only `choose` used to close anything, which left the
       attach menu standing open over the composer after picking from it. The
       item's own handler -- htmx, or the [data-attach] and [data-toggle]
       delegates in app.js -- still runs; this only puts the menu away. */
    var item = event.target.closest('[data-picker-menu] [role="menuitem"]');
    if (item) {
      close(item.closest("[data-picker]"));
      return;
    }

    if (!event.target.closest("[data-picker-menu]")) closeAll(null);
  });

  document.addEventListener("input", function (event) {
    if (!event.target.matches("[data-picker-filter]")) return;
    applyFilter(
      event.target.closest("[data-picker-menu]"),
      event.target.value.trim().toLowerCase()
    );
  });

  document.addEventListener("keydown", function (event) {
    var picker = event.target.closest("[data-picker]");
    if (!picker) return;
    var menu = picker.querySelector("[data-picker-menu]");

    if (event.key === "Escape" && !menu.hidden) {
      event.preventDefault();
      close(picker);
      picker.querySelector("[data-picker-toggle]").focus();
      return;
    }

    if (menu.hidden) {
      if (event.key === "ArrowDown" || event.key === "Enter") {
        if (event.target.matches("[data-picker-toggle]")) {
          event.preventDefault();
          open(picker);
        }
      }
      return;
    }

    if (event.key !== "ArrowDown" && event.key !== "ArrowUp") return;
    event.preventDefault();
    var options = Array.prototype.filter.call(
      menu.querySelectorAll(".picker__option"), function (o) { return !o.hidden; }
    );
    if (!options.length) return;
    var at = options.indexOf(document.activeElement);
    var step = event.key === "ArrowDown" ? 1 : -1;
    var next = at === -1 ? 0 : (at + step + options.length) % options.length;
    options[next].focus();
  });
})();

/*
  Something arrived.

  The sidebar polls /api/chats/unread; the response carries out-of-band spans
  for the dots and, when something has just landed, an HX-Trigger listing it.
  Announcing it here rather than server-side keeps the wording and the timing in
  one place.

  Three things happen, and they are deliberately not the same thing three times:

  - A **toast**, always. It is the answer for somebody who is looking at the
    page, and it is the only one of the three that needs no permission and
    cannot be switched off by an operating system.
  - A **count in the tab title**, while the tab is not the one being looked at.
    This is the part that was missing and that nothing else replaces: a schedule
    that fires while you are in another tab lit a green dot in a corner you
    could not see. Cleared the moment the page is looked at again, because a
    badge you have to dismiss is worse than none.
  - A **browser notification**, if the reader has asked for one. Only while the
    page is hidden -- notifying somebody about something they are watching
    happen is the behaviour that gets notifications turned off for good.
*/
(function () {
  var NOTIFY_KEY = "lembas-desktop-notifications";
  /* Kept per browser rather than on the account, because the *permission* is
     per browser and per origin. A preference that followed somebody to a
     machine where they had never granted it would be a switch that reads "on"
     and does nothing. */
  var baseTitle = document.title;
  var pending = 0;

  function wanted() {
    try {
      return window.localStorage.getItem(NOTIFY_KEY) === "1";
    } catch (error) {
      return false;
    }
  }

  function setWanted(on) {
    try {
      window.localStorage.setItem(NOTIFY_KEY, on ? "1" : "0");
    } catch (error) { /* private mode; the toast still works */ }
  }

  function retitle() {
    document.title = pending > 0 ? "(" + pending + ") " + baseTitle : baseTitle;
  }

  /* The title is rewritten by navigation and by a rename arriving out of band,
     so the base is re-read rather than captured once. Without this, renaming a
     chat while something is unread would pin the old name until a reload. */
  function rebase() {
    var shown = document.title;
    var stripped = shown.replace(/^\(\d+\)\s*/, "");
    if (stripped !== baseTitle) { baseTitle = stripped; retitle(); }
  }

  function clear() {
    if (!pending) return;
    pending = 0;
    retitle();
  }

  document.addEventListener("visibilitychange", function () {
    if (!document.hidden) clear();
  });
  window.addEventListener("focus", clear);

  function describe(items) {
    if (items.length > 1) return items.length + " new arrivals";
    var item = items[0];
    if (item.kind === "report") return "Report filed: “" + item.title + "”";
    if (item.kind === "message") return "New message";
    return "Reply ready in “" + item.title + "”";
  }

  /* `registration.showNotification` where there is a service worker, because
     `new Notification()` throws outright on Android Chrome -- so the plain
     constructor alone would work on every desktop it was tested on and on no
     phone at all. */
  function show(message, url) {
    if (!wanted() || !("Notification" in window)) return;
    if (Notification.permission !== "granted") return;

    /* `tag` collapses several into one: a browser left in the background for an
       hour must not come back to a stack of them. */
    var options = {
      body: message,
      tag: "lembas-unread",
      icon: "/static/img/icon-192.png",
      data: { url: url },
    };
    if (navigator.serviceWorker && navigator.serviceWorker.ready) {
      navigator.serviceWorker.ready
        .then(function (registration) { registration.showNotification("LLeMbas", options); })
        .catch(function () { plain(options, url); });
      return;
    }
    plain(options, url);
  }

  function plain(options, url) {
    try {
      var notification = new Notification("LLeMbas", options);
      notification.onclick = function () { window.focus(); if (url) location.href = url; };
    } catch (error) { /* unsupported; the toast and the title still carry it */ }
  }

  document.addEventListener("lembas:unread", function (event) {
    var items = (event.detail && event.detail.items) || [];
    if (!items.length) return;

    var message = describe(items);
    if (window.lembas && window.lembas.notify) {
      window.lembas.notify(message, { kind: "success", timeout: 6000 });
    }
    if (document.hidden) {
      rebase();
      pending += items.length;
      retitle();
      show(message, items.length === 1 ? items[0].url : "");
    }
  });

  /* Asking. `requestPermission` must be called from a gesture, so every path to
     it is a button somebody pressed -- a preference restored on load and acted
     on is refused by the browser with nothing said anywhere.

     Shared by the Settings toggle and by the one-time offer below, because two
     copies of "ask, then interpret the three answers" is two places for the
     denied case to be got wrong. */
  function ask(onSettled) {
    if (!("Notification" in window)) {
      window.lembas.notify("This browser has no notifications to offer.", { kind: "error" });
      return;
    }
    if (Notification.permission === "denied") {
      window.lembas.notify(
        "Notifications are blocked for this site in your browser's own settings, " +
        "which is the only place that can be undone.",
        { kind: "error", timeout: 8000 }
      );
      return;
    }
    Notification.requestPermission().then(function (result) {
      if (result !== "granted") {
        window.lembas.notify("Left off — nothing was changed.");
        if (onSettled) onSettled();
        return;
      }
      setWanted(true);
      if (onSettled) onSettled();
      subscribe().then(function (pushed) {
        window.lembas.notify(
          pushed
            ? "Notifications on, including while LLeMbas is closed."
            : "Notifications on while LLeMbas is open.",
          { kind: "success", timeout: 6000 }
        );
      });
    });
  }

  /*
    Registering with the browser's push service.

    This is what makes a notification arrive with nothing of ours running --
    the poll above needs an open page, and the case worth notifying about is a
    schedule firing at seven in the morning.

    Best effort, always. It needs a service worker (so HTTPS or localhost), a
    push service the browser can reach, and a `PushManager` that some browsers
    do not have; every one of those fails to "notifications while LLeMbas is
    open", which still works. A permission granted and a subscription refused
    must not read as a failure, because most of the feature is still there.
  */
  function subscribe() {
    if (!("serviceWorker" in navigator) || !("PushManager" in window)) {
      return Promise.resolve(false);
    }
    return fetch("/api/push/key")
      .then(function (response) { return response.ok ? response.json() : null; })
      .then(function (data) {
        if (!data || !data.key) return false;
        return navigator.serviceWorker.ready.then(function (registration) {
          return registration.pushManager.subscribe({
            /* Required, and not merely conventional: a browser refuses a
               subscription that does not promise every push will be shown to
               somebody. It is also why the worker's `push` handler always ends
               in a notification unless a window is focused. */
            userVisibleOnly: true,
            applicationServerKey: bytes(data.key),
          });
        }).then(function (subscription) {
          return fetch("/api/push/subscribe", {
            method: "POST",
            headers: { "Content-Type": "application/json" },
            body: JSON.stringify(subscription.toJSON()),
          }).then(function (response) { return response.ok; });
        });
      })
      .catch(function () { return false; });
  }

  function unsubscribe() {
    if (!("serviceWorker" in navigator)) return Promise.resolve();
    return navigator.serviceWorker.ready
      .then(function (registration) { return registration.pushManager.getSubscription(); })
      .then(function (subscription) {
        if (!subscription) return null;
        var endpoint = subscription.endpoint;
        return subscription.unsubscribe().then(function () {
          return fetch("/api/push/unsubscribe", {
            method: "POST",
            headers: { "Content-Type": "application/json" },
            body: JSON.stringify({ endpoint: endpoint }),
          });
        });
      })
      .catch(function () { return null; });
  }

  /* base64url to bytes. `applicationServerKey` wants the raw 65-byte point and
     will not take the string, and `atob` will not take base64url -- the two
     substitutions and the padding are the whole of this. */
  function bytes(text) {
    var padded = (text + "===".slice((text.length + 3) % 4))
      .replace(/-/g, "+")
      .replace(/_/g, "/");
    var raw = window.atob(padded);
    var out = new Uint8Array(raw.length);
    for (var i = 0; i < raw.length; i++) out[i] = raw.charCodeAt(i);
    return out;
  }

  document.addEventListener("click", function (event) {
    var button = event.target.closest("[data-notify-toggle]");
    if (!button) return;
    event.preventDefault();

    if (wanted()) {
      setWanted(false);
      // The registration goes too. Leaving it would mean this server kept
      // sending to a browser that has been told to stop showing them, which is
      // traffic to a third party for something switched off.
      unsubscribe();
      paint(button);
      return;
    }
    ask(function () { paint(button); });
  });

  /*
    Offering, once.

    The browser's own permission box cannot be called on page load and should
    not be: it appears with no context, and a box somebody dismisses without
    reading is a permission that can only be undone in browser settings they
    will never find. So the offer is ours first -- a themed dialog that says
    what the notifications are for -- and pressing its button is the gesture the
    browser needs.

    Once, ever, per browser. "Not now" is recorded exactly as firmly as "yes":
    an offer that comes back is the thing that makes people block a site to
    silence it, and Settings has the switch for anybody who changes their mind.
  */
  var ASKED_KEY = "lembas-notifications-asked";

  function offer() {
    if (!document.body || !document.body.dataset.authenticated) return;
    if (!("Notification" in window) || Notification.permission !== "default") return;
    try {
      if (window.localStorage.getItem(ASKED_KEY)) return;
      window.localStorage.setItem(ASKED_KEY, "1");
    } catch (error) {
      return;  // no way to remember having asked, so do not ask
    }
    if (!window.lembas || !window.lembas.confirm) return;

    window.lembas.confirm({
      title: "Notifications",
      message:
        "Let LLeMbas tell you when a reply, a report or a scheduled run arrives " +
        "while you are looking at something else?",
      confirmLabel: "Turn on",
      cancelLabel: "Not now",
    }).then(function (yes) { if (yes) ask(scan); });
  }

  function paint(button) {
    var on = wanted() && "Notification" in window && Notification.permission === "granted";
    button.textContent = on ? "Turn off notifications" : "Turn on notifications";
    button.setAttribute("aria-pressed", on ? "true" : "false");
    var hint = document.querySelector("[data-notify-state]");
    if (!hint) return;
    if (!("Notification" in window)) {
      hint.textContent = "This browser has no notifications to offer.";
    } else if (Notification.permission === "denied") {
      hint.textContent =
        "Blocked for this site in your browser's settings, which is the only " +
        "place that can be undone.";
    } else if (on) {
      hint.textContent = "On in this browser. Nothing is shown while you are looking at the page.";
    } else {
      hint.textContent = "Off in this browser.";
    }
  }

  function scan() {
    document.querySelectorAll("[data-notify-toggle]").forEach(paint);
  }

  function start() {
    scan();
    /* After the page has settled rather than during it: the offer is a dialog,
       and one that appears while the shell is still being painted reads as an
       error rather than as a question. */
    setTimeout(offer, 1500);
  }

  document.addEventListener("DOMContentLoaded", start);
  document.body && start();
  document.addEventListener("htmx:afterSettle", scan);
})();

/*
  Two number fields set together.

  Image sizes come in pairs and nobody types 1024 and then 1536; they pick
  "portrait". A button rather than a select of pairs, because a select would
  have to enumerate every combination somebody might want and these are only
  the common ones — the two boxes are still there and still authoritative.

  Delegated and keyed on the attributes rather than on the image page's ids, so
  the next screen with a paired number wants no new code.
*/
document.addEventListener("click", function (event) {
  var preset = event.target.closest("[data-width][data-height]");
  if (!preset) return;
  var row = preset.closest("[data-size-presets]");
  if (!row) return;
  event.preventDefault();

  var form = preset.closest("form");
  if (!form) return;
  var width = form.querySelector('[name="default_width"]');
  var height = form.querySelector('[name="default_height"]');
  if (width) width.value = preset.dataset.width;
  if (height) height.value = preset.dataset.height;
});

/*
  A toast asked for by the server.

  Some routes answer 204 because there is nothing to swap, and still have
  something to say -- answering a question that has already timed out, for
  instance. `HX-Trigger: {"lembas:notify": {"message": …}}` is how they say it.
*/
document.addEventListener("lembas:notify", function (event) {
  var detail = event.detail || {};
  if (!detail.message || !window.lembas || !window.lembas.notify) return;
  window.lembas.notify(detail.message, { kind: detail.kind || "" });
});

/*
  Send becomes Stop while a reply is being written.

  One button in the markup (see chat/_composer.html), retargeted here. The
  composer and the streaming bubble are far apart in the document, so the link
  between them is made at runtime: whenever the thread changes, look for a
  message that is still streaming and point the button at it. A
  MutationObserver rather than htmx events, because the bubble is replaced by
  an SSE swap that does not always surface as one.

  This used to build a second button and hide it with the `hidden` attribute,
  which did nothing at all: `.btn` sets `display: inline-flex`, and that beats
  the browser's `[hidden] { display: none }`. app.css now forces the attribute
  to win, and there is only one button to get wrong.
*/
(function () {
  "use strict";

  function streamingMessage() {
    var live = document.querySelector(".msg[sse-connect]");
    if (!live) return null;
    var id = live.id.replace(/^msg-/, "");
    var chat = (live.getAttribute("sse-connect") || "").match(/\/api\/chats\/([^/]+)\//);
    return chat ? { messageId: id, chatId: chat[1] } : null;
  }

  function sync() {
    var button = document.querySelector("[data-composer-action]");
    if (!button) return;
    var active = streamingMessage();

    button.dataset.composerAction = active ? "stop" : "send";
    // As a submit button the form sends; as a plain button the click handler
    // below stops. Nothing else distinguishes the two states.
    button.type = active ? "button" : "submit";
    button.setAttribute("aria-label", active ? "Stop generating" : "Send");
    button.title = active ? "Stop generating" : "";
    button.disabled = false;
  }

  document.addEventListener("click", function (event) {
    var button = event.target.closest('[data-composer-action="stop"]');
    if (!button) return;
    event.preventDefault();

    var target = streamingMessage();
    if (!target) return;
    // Disabled until the next sync, so a second click cannot fire a second
    // request at a generation that is already stopping.
    button.disabled = true;
    fetch(
      "/api/chats/" + target.chatId + "/messages/" + target.messageId + "/stop",
      { method: "POST", credentials: "same-origin" }
    ).catch(function () { button.disabled = false; });
  });

  function watch() {
    var thread = document.getElementById("thread");
    if (thread) {
      new MutationObserver(sync).observe(thread, { childList: true, subtree: true });
    }
    sync();
  }

  document.addEventListener("DOMContentLoaded", watch);
  document.body && document.body.addEventListener("htmx:afterSettle", sync);
})();

/*
  Chat or Agent, on the new-chat composer.

  Two radios rather than a checkbox because they are two kinds of conversation,
  not a setting on one -- and the choice is permanent, so it should read as a
  fork. Picking Agent reveals the connection and directory; picking Chat hides
  them and sets the hidden `kind` back, so a form submitted either way carries
  exactly what it means.
*/
(function () {
  /* The last segment of a path, with trailing slashes ignored so `/srv/app/`
     reads as `app` rather than as nothing. `/` is itself, since it has no name
     of its own and "the root" is what somebody means by it. */
  function baseName(path) {
    var trimmed = String(path || "").replace(/\/+$/, "");
    if (!trimmed) return "/";
    return trimmed.slice(trimmed.lastIndexOf("/") + 1) || "/";
  }

  function wire(root) {
    var kind = root.querySelector("#chat-kind");
    var extra = root.querySelector("[data-agent-extra]");
    var picker = root.querySelector('select[name="ssh_profile_id"]');
    var dir = root.querySelector("[data-dir-value]");
    var dirLabel = root.querySelector("[data-dir-label]");
    if (!kind || !extra) return;

    /* The directory is a hidden field plus a button, so the two have to be set
       together or the button shows one path and the form submits another.

       The button shows the directory's own name; the full path goes in the
       tooltip and, of course, in the field that is submitted. A real project
       path is long enough that showing it whole made the button eat the row and
       squeeze the mode select beside it, and the leading directories are the
       part nobody is reading -- what you check before sending is that you are
       in `myproject` rather than `myproject-old`. */
    function setDir(value) {
      if (!dir) return;
      dir.value = value || "";
      if (dirLabel) dirLabel.textContent = value ? baseName(value) : "the login directory";
      var button = dirLabel && dirLabel.closest("[data-dir-browse]");
      if (button) button.title = value || "The connection's own login directory";
      announce();
    }

    function sync() {
      var chosen = root.querySelector('input[name="kind_choice"]:checked');
      var agent = chosen && chosen.value === "agent";
      kind.value = agent ? "agent" : "chat";
      extra.hidden = !agent;
    }

    function profileDefault() {
      var option = picker && picker.options[picker.selectedIndex];
      return (option && option.dataset.dir) || "";
    }

    /* Which machine and which directory the panels should be looking at.
       Dispatched rather than read, because nothing here owns the terminal or
       the canvas -- and because assigning to a hidden field's `.value` fires no
       event of its own, so `setDir` has to say so out loud. */
    function announce() {
      var chosen = kind && kind.value === "agent" ? (picker ? picker.value : "") : "";
      document.dispatchEvent(new CustomEvent("lembas:agent-target", {
        detail: { profileId: chosen, projectDir: dir ? dir.value : "" }
      }));
    }

    root.addEventListener("change", function (event) {
      if (event.target.name === "kind_choice") { sync(); announce(); }
      // Following the profile's own directory is a convenience, not a rule:
      // once someone has chosen their own it is left alone.
      if (event.target === picker && dir && !dir.dataset.touched) {
        setDir(profileDefault());
      }
      if (event.target === picker) announce();
    });

    root.addEventListener("click", function (event) {
      if (!event.target.closest("[data-dir-browse]")) return;
      event.preventDefault();
      var profileId = picker ? picker.value : "";
      if (!profileId) return;
      window.lembas.chooseDirectory(profileId, dir.value, function (chosen) {
        dir.dataset.touched = "1";
        setDir(chosen);
      });
    });

    sync();
    /* Seeded from whichever profile the select is actually showing, not from
       the first in the list -- an unverified first profile renders `disabled`,
       so the two disagreed and the box offered a directory on a machine the
       chat was not going to use. */
    setDir(profileDefault());
    announce();
  }

  /* The same directory picker, on a form that is not the composer.

     Scoped to `[data-dir-field]`, which the composer's own markup does not
     carry -- otherwise this and `wire()` above would both answer one click and
     open two dialogs. The composer keeps its own handler because it has more to
     do: it follows the selected profile's default directory until somebody
     chooses their own, which only makes sense while a chat is being created.

     A path is something you would rather find than spell, and the text box this
     replaced was the one control on the folder page that asked somebody to
     remember an absolute path on another machine. */
  document.addEventListener("click", function (event) {
    var field = event.target.closest("[data-dir-field]");
    if (!field) return;

    var value = field.querySelector("[data-dir-value]");
    var label = field.querySelector("[data-dir-label]");
    if (!value) return;

    function show(path) {
      value.value = path || "";
      if (label) label.textContent = path || "the connection's own default";
    }

    if (event.target.closest("[data-dir-clear]")) {
      event.preventDefault();
      show("");
      return;
    }
    if (!event.target.closest("[data-dir-browse]")) return;
    event.preventDefault();

    /* The connection this folder points at. Browsing needs one, and saying so
       beats a dialog that opens onto nothing. */
    var form = field.closest("form");
    var picker = form && form.querySelector('select[name="ssh_profile_id"]');
    var profileId = picker ? picker.value : "";
    if (!profileId) {
      window.lembas.notify("Choose a connection first — there is nothing to browse without one.");
      return;
    }
    window.lembas.chooseDirectory(profileId, value.value, show);
  });

  function scan() {
    document.querySelectorAll("[data-agent-picker]").forEach(function (el) {
      if (!el.dataset.wired) { el.dataset.wired = "1"; wire(el); }
    });
  }

  /* The two panel buttons, on the screen where the server cannot answer.

     Both the canvas and the terminal need an agent chat on a chosen connection,
     and before a chat exists both of those are radio buttons and a select in the
     composer -- nothing the server has seen. It used to answer with
     `profiles[0]`, so the buttons were offered on the new-chat screen whatever
     the toggle said and whatever was selected, and pressing either opened a
     panel that could not work.

     So the markup renders them `hidden` carrying `data-agent-only`, and this
     follows the event `wire()` already dispatches. Anything without that
     attribute is left alone: on a chat that exists the server's answer is
     complete and this must not second-guess it.

     Closing a panel whose target has just gone is not tidiness. The panel is
     still pointed at the old connection, and leaving it open would show one
     machine's files under a heading naming another. */
  document.addEventListener("lembas:agent-target", function (event) {
    var ready = !!(event.detail && event.detail.profileId);
    document.querySelectorAll("[data-agent-only]").forEach(function (button) {
      button.hidden = !ready;
      if (ready) return;
      var selector = button.dataset.toggle || "";
      var panel = selector && document.querySelector(selector);
      if (panel && !panel.hidden && window.lembas && window.lembas.setPanel) {
        window.lembas.setPanel(selector, false);
      }
    });
  });

  document.addEventListener("DOMContentLoaded", scan);
  document.body && scan();
  document.addEventListener("htmx:afterSettle", scan);
})();

/*
  Tabs remember their scroll position, and that made short panels look empty.

  A tab is a radio and a panel is shown by CSS, so switching one changes nothing
  about `.tabs__body` -- which is the element that scrolls. Read half way down
  the long Tools panel on /admin/prompts, click Context, and the container keeps
  a scrollTop the new panel is not tall enough to fill: the browser clamps it to
  that panel's bottom, and what lands on screen is the end of it above a screen
  of nothing. It reads as a page that failed to load, and the way out is to
  scroll up before scrolling down.

  Nothing in CSS can reset a scroll position, so this is the smallest amount of
  JavaScript that fixes it: on a tab change, put the container that actually
  scrolls back to the top. Delegated and keyed on the class rather than on any
  one page, because every tabbed screen here has the same problem.

  Which container that is depends on where the tabs are, and assuming it was
  always `.tabs__body` is why this did nothing at all on /admin/prompts for the
  whole life of the fix. `.tabs__body` scrolls only when `.tabs` is a flex child
  of something bounded -- true on the settings page, false under the admin
  layout, where the scroller is the `.admin-scroll` above it and `.tabs__body`
  has `height: auto`. Setting `scrollTop = 0` on an element that does not scroll
  is a silent no-op, which is exactly the kind of failure that survives review.

  So: walk up from the bar and reset the first ancestor that can scroll. That is
  correct on both shapes without knowing which one it is looking at.
*/
(function () {
  function scroller(node) {
    for (var el = node; el && el !== document.body; el = el.parentElement) {
      var overflow = getComputedStyle(el).overflowY;
      if ((overflow === "auto" || overflow === "scroll") && el.scrollHeight > el.clientHeight) {
        return el;
      }
    }
    return null;
  }

  document.addEventListener("change", function (event) {
    var radio = event.target;
    if (!radio || radio.type !== "radio") return;
    var bar = radio.closest && radio.closest(".tabs__bar");
    if (!bar) return;
    /* The body first, because on the settings page it is the scroller and is
       also the thing whose *content* changed; then whatever encloses the tabs.
       Both, not either: on the admin layout the body may still hold a scrolled
       inner panel while the page itself is what the reader is lost in. */
    var body = bar.parentElement && bar.parentElement.querySelector(".tabs__body");
    if (body) body.scrollTop = 0;

    /* And where the page itself is the scroller, put the bar back at the top of
       it -- not the page at zero. There is content above the tabs on
       /admin/prompts and the reader has just asked to look at a tab, so the tab
       bar is where they want to be.

       This has to happen *after* the panel has swapped, which it has: :checked
       applies before `change` fires. That order is the whole failure -- the
       browser scrolls the focused radio into view first, then the shorter panel
       shrinks the document and scrollTop is clamped to the new maximum, which
       for a short panel is somewhere below everything. */
    var outer = scroller(bar);
    if (outer && outer !== body) bar.scrollIntoView({ block: "start" });
  });
})();

/*
  Opening a file into the canvas, by looking rather than by spelling.

  The button cannot carry an `hx-post` because the path is not known until the
  dialog closes -- so this posts it once it is, through htmx's own `ajax` so the
  response lands in the panel exactly as every other canvas action's does. Doing
  it with `fetch` would mean parsing and swapping the fragment by hand, and then
  there would be two ways the canvas gets replaced.

  The key is `agent:<path>`, which is the same key the model's own reads produce
  -- so a file opened here and the same file opened by a tool call are one tab
  rather than two spellings of it. That is `canvas.path_key`'s whole job, and it
  is why the prefix is added here rather than asked of the reader.
*/
(function () {
  document.addEventListener("click", function (event) {
    var button = event.target.closest && event.target.closest("[data-canvas-open]");
    if (!button) return;
    event.preventDefault();

    var profile = button.dataset.profile;
    var chat = button.dataset.chat;
    if (!profile || !chat) {
      window.lembas.notify("This chat is not pointed at a machine, so there is nothing to browse.");
      return;
    }

    window.lembas.chooseFile(profile, button.dataset.dir || "", function (path) {
      if (!path) return;
      window.htmx.ajax("POST", "/api/chats/" + encodeURIComponent(chat) + "/canvas/tabs", {
        target: "#canvas-inner",
        swap: "innerHTML",
        values: { key: "agent:" + path }
      });
    });
  });
})();
