// PHYSIO SAMOS — admin console, shared layer: API client, icons, dialogs, toasts, routing, form
// primitives and the console's Greek wording (the console is Greek only — owner, 2026-09-14).
//
// Every admin file is wrapped in its own function scope and shares through window.PSA. The public
// site's .jsx files share top-level names instead; here a second file declaring `const Icon` would
// be a SyntaxError that blanks the console, so nothing is left at the top level.
(() => {
  const { useState, useEffect, useRef, useLayoutEffect, useContext, createContext, useCallback } = React;
  const PSA = (window.PSA = window.PSA || {});

  // ---------- API ----------
  class ApiError extends Error {
    constructor(status, code, message, data) {
      super(message || code);
      this.status = status;
      this.code = code;
      this.data = data || {};
    }
  }
  PSA.ApiError = ApiError;

  // Every call carries X-PS-Admin: the Worker refuses a state change without it (CSRF, see
  // worker/admin.js). A 401 anywhere but the session probe and the sign-in means the session ended.
  PSA.api = async (path, { method = "GET", body, form, signal } = {}) => {
    const headers = { "X-PS-Admin": "1" };
    let payload;
    if (form) payload = form;
    else if (body !== undefined) { headers["Content-Type"] = "application/json"; payload = JSON.stringify(body); }
    let res;
    try {
      res = await fetch(path, { method, headers, body: payload, signal, credentials: "same-origin" });
    } catch (err) {
      if (err.name === "AbortError") throw err;
      throw new ApiError(0, "network");
    }
    let data = null;
    try { data = await res.json(); } catch { data = null; }
    if (!res.ok) {
      if (res.status === 401 && path !== "/api/session" && path !== "/api/auth/login") window.dispatchEvent(new Event("psa:signed-out"));
      // The console's own API answers { error }; Better Auth's endpoints answer { code } in capitals.
      const code = (data && (data.error || (data.code && String(data.code).toLowerCase()))) || `http_${res.status}`;
      throw new ApiError(res.status, code, data && data.message, data);
    }
    return data;
  };

  // ---------- wording ----------
  PSA.STATE_LABEL = { draft: "Πρόχειρο", scheduled: "Προγραμματισμένο", published: "Δημοσιευμένο", trash: "Στον κάδο" };
  PSA.LANGS = ["el", "en", "de", "fr", "it", "tr"];
  PSA.LANG_NAME = { el: "Ελληνικά", en: "Αγγλικά", de: "Γερμανικά", fr: "Γαλλικά", it: "Ιταλικά", tr: "Τουρκικά" };

  const ERRORS = {
    network: "Δεν υπάρχει σύνδεση με τον διακομιστή. Ελέγξτε το ίντερνετ και δοκιμάστε ξανά.",
    invalid_credentials: "Λάθος email ή κωδικός.",
    invalid_email_or_password: "Λάθος email ή κωδικός.",
    http_429: "Πολλές προσπάθειες σε λίγο χρόνο. Περιμένετε ένα λεπτό και δοκιμάστε ξανά.",
    invalid_password: "Ο κωδικός που γράψατε δεν είναι σωστός.",
    password_too_long: "Ο κωδικός είναι πολύ μεγάλος.",
    must_change_password: "Ορίστε πρώτα τον δικό σας κωδικό.",
    signed_out: "Η σύνδεση έληξε. Συνδεθείτε ξανά.",
    version_conflict: "Το άρθρο αποθηκεύτηκε στο μεταξύ από άλλη καρτέλα ή συσκευή.",
    not_ready: "Υπάρχουν εκκρεμότητες πριν από τη δημοσίευση.",
    slug_taken: "Αυτή η διεύθυνση χρησιμοποιείται ήδη από άλλο άρθρο.",
    date_past: "Διαλέξτε ημερομηνία και ώρα στο μέλλον.",
    bad_writer: "Ο συντάκτης δεν βρέθηκε. Διαλέξτε άλλον.",
    bad_cover: "Η εικόνα εξωφύλλου δεν βρέθηκε. Διαλέξτε την ξανά.",
    bad_date: "Η ημερομηνία δεν είναι έγκυρη. Διαλέξτε την ξανά.",
    bad_slug: "Η διεύθυνση δέχεται μόνο λατινικά πεζά γράμματα, αριθμούς και παύλες.",
    too_long: "Το κείμενο ξεπερνά το επιτρεπτό μήκος.",
    in_trash: "Το άρθρο είναι στον κάδο. Επαναφέρετέ το πρώτα.",
    in_use: "Η εικόνα χρησιμοποιείται σε άρθρο.",
    unsupported_image: "Υποστηρίζονται μόνο εικόνες JPEG, PNG και WebP.",
    too_large: "Το αρχείο είναι πολύ μεγάλο.",
    bad_small: "Η εικόνα δεν επεξεργάστηκε σωστά. Δοκιμάστε ξανά.",
    small_required: "Η εικόνα δεν επεξεργάστηκε σωστά. Δοκιμάστε ξανά.",
    bad_dimensions: "Οι διαστάσεις της εικόνας δεν βγήκαν σωστές. Δοκιμάστε ξανά.",
    file_required: "Διαλέξτε ένα αρχείο εικόνας.",
    daily_limit: "Εξαντλήθηκαν οι αυτόματες μεταφράσεις για σήμερα. Δοκιμάστε αύριο ή μεταφράστε χειροκίνητα.",
    ai_budget: "Το δωρεάν ημερήσιο όριο αυτόματης μετάφρασης δεν φτάνει για αυτό το κείμενο. Ανανεώνεται στις 03:00 ώρα Ελλάδας (02:00 τον χειμώνα)· μέχρι τότε μεταφράστε χειροκίνητα.",
    translation_failed: "Η υπηρεσία μετάφρασης δεν απάντησε. Δοκιμάστε ξανά σε λίγο.",
    translation_empty: "Η υπηρεσία μετάφρασης δεν επέστρεψε κείμενο. Δοκιμάστε ξανά.",
    translation_unavailable: "Η αυτόματη μετάφραση δεν είναι διαθέσιμη.",
    too_long_to_translate: "Το άρθρο είναι πολύ μεγάλο για αυτόματη μετάφραση.",
    same_as_source: "Αυτή είναι η γλώσσα του πρωτοτύπου.",
    lang_has_translation: "Υπάρχει ήδη μετάφραση σε αυτή τη γλώσσα. Διαγράψτε την πρώτα.",
    not_published: "Το άρθρο δεν είναι δημοσιευμένο.",
    bad_status: "Η κατάσταση της μετάφρασης δεν είναι έγκυρη. Ανανεώστε τη σελίδα.",
    bad_source_hash: "Το πρωτότυπο άλλαξε στο μεταξύ. Ανανεώστε τη σελίδα και δοκιμάστε ξανά.",
    bad_email: "Το email δεν είναι έγκυρο.",
    bad_name: "Γράψτε ένα όνομα (έως 80 χαρακτήρες).",
    bad_name_latin: "Το όνομα με λατινικούς χαρακτήρες δέχεται μόνο λατινικά γράμματα, κενά, τελείες, παύλες και αποστρόφους.",
    writer_name_taken: "Υπάρχει ήδη συντάκτης με αυτό το όνομα.",
    writer_in_use: "Ο συντάκτης υπογράφει άρθρα. Διαλέξτε ποιος θα τα αναλάβει πριν τη διαγραφή.",
    bad_reassign: "Διαλέξτε άλλον συντάκτη για τα άρθρα.",
    photo_required: "Η φωτογραφία του συντάκτη είναι υποχρεωτική.",
    bad_photo: "Η φωτογραφία δεν επεξεργάστηκε σωστά. Διαλέξτε την ξανά.",
    multipart_required: "Το αίτημα δεν στάλθηκε σωστά. Ανανεώστε τη σελίδα και δοκιμάστε ξανά.",
    json_required: "Το αίτημα δεν στάλθηκε σωστά. Ανανεώστε τη σελίδα και δοκιμάστε ξανά.",
    bad_form: "Το αίτημα δεν στάλθηκε σωστά. Ανανεώστε τη σελίδα και δοκιμάστε ξανά.",
    bad_json: "Το αίτημα δεν στάλθηκε σωστά. Ανανεώστε τη σελίδα και δοκιμάστε ξανά.",
    password_too_short: "Ο κωδικός θέλει τουλάχιστον 10 χαρακτήρες.",
    wrong_password: "Ο τρέχων κωδικός δεν είναι σωστός.",
    not_in_trash: "Μόνο άρθρα στον κάδο διαγράφονται οριστικά.",
    not_found: "Δεν βρέθηκε.",
    default: "Κάτι πήγε στραβά. Δοκιμάστε ξανά.",
  };
  PSA.errorText = (err) => {
    if (!err) return "";
    if (!(err instanceof ApiError)) return err.message || ERRORS.default;
    if (err.code === "too_many_attempts") return `Πολλές αποτυχημένες προσπάθειες. Δοκιμάστε ξανά σε ${Math.ceil((err.data.retryAfter || 900) / 60)} λεπτά.`;
    return ERRORS[err.code] || (err.status >= 500 ? "Σφάλμα στον διακομιστή. Δοκιμάστε ξανά σε λίγο." : ERRORS.default);
  };

  const PROBLEMS = {
    writer_required: "Διαλέξτε συντάκτη.",
    title_required: "Λείπει ο τίτλος.",
    body_required: "Το κείμενο είναι κενό.",
    slug_placeholder: "Ορίστε τη διεύθυνση (URL) του άρθρου.",
    cover_required: "Προσθέστε εικόνα εξωφύλλου πριν από τη δημοσίευση.",
    cover_alt_required: "Η εικόνα εξωφύλλου χρειάζεται περιγραφή.",
    image_alt: "Μια εικόνα στο κείμενο δεν έχει περιγραφή.",
    image_external: "Μια εικόνα προέρχεται από άλλο ιστότοπο. Ανεβάστε την στις Εικόνες και βάλτε την από εκεί.",
  };
  PSA.problemText = (p) => `${p.lang ? `${PSA.LANG_NAME[p.lang]}: ` : ""}${PROBLEMS[p.code] || p.code}`;

  // ---------- formatting ----------
  // Greek time on a 24-hour clock whatever the device's own zone: Intl's "el" default is 12-hour ("09:05 μ.μ.").
  const dateFmt = new Intl.DateTimeFormat("el", { timeZone: "Europe/Athens", day: "numeric", month: "short", year: "numeric", hour: "2-digit", minute: "2-digit", hourCycle: "h23" });
  const clockFmt = new Intl.DateTimeFormat("el", { timeZone: "Europe/Athens", hour: "2-digit", minute: "2-digit", hourCycle: "h23" });
  const dayFmt = new Intl.DateTimeFormat("el", { timeZone: "Europe/Athens", day: "numeric", month: "long", year: "numeric" });
  const rtf = new Intl.RelativeTimeFormat("el", { numeric: "auto" });
  PSA.fmtDate = (ms) => (ms ? dateFmt.format(new Date(ms)) : "—");
  PSA.fmtDay = (ms) => (ms ? dayFmt.format(new Date(ms)) : "—");
  PSA.fmtClock = (ms) => (ms ? clockFmt.format(new Date(ms)) : "—");
  PSA.fmtRelative = (ms) => {
    if (!ms) return "—";
    const diff = ms - Date.now(), abs = Math.abs(diff);
    for (const [unit, size] of [["year", 31536e6], ["month", 2592e6], ["week", 6048e5], ["day", 864e5], ["hour", 36e5], ["minute", 6e4]]) {
      if (abs >= size) return rtf.format(Math.round(diff / size), unit);
    }
    return "μόλις τώρα";
  };
  PSA.fmtBytes = (n) => (n < 1024 ? `${n} B` : n < 1048576 ? `${Math.round(n / 1024)} KB` : `${(n / 1048576).toFixed(1)} MB`);
  PSA.plural = (n, one, many) => `${n} ${n === 1 ? one : many}`;

  // ---------- icons ----------
  const Icon = ({ name, size, className = "", label }) => {
    const inner = (window.PS_ADMIN_ICONS || {})[name];
    if (!inner) { console.warn(`[admin] no icon "${name}" — add it to tools/build-admin-icons.sh`); return null; }
    return (
      <svg className={`icon${size ? ` icon--${size}` : ""}${className ? ` ${className}` : ""}`} viewBox="0 0 24 24"
           aria-hidden={label ? undefined : "true"} role={label ? "img" : undefined} aria-label={label}
           dangerouslySetInnerHTML={{ __html: inner }} />
    );
  };
  PSA.Icon = Icon;

  // ---------- routing ----------
  // History routing on the admin host: the Worker answers every non-API path with this page.
  // `PSA.leaveGuard` lets the editor hold a navigation while it has unsaved changes.
  PSA.leaveGuard = null;
  PSA.navigate = (to, { replace = false, force = false } = {}) => {
    if (to === location.pathname + location.search) return;
    if (!force && PSA.leaveGuard && !PSA.leaveGuard()) return;
    history[replace ? "replaceState" : "pushState"](null, "", to);
    window.scrollTo(0, 0);
    window.dispatchEvent(new Event("psa:navigate"));
  };
  PSA.usePath = () => {
    const read = () => location.pathname + location.search;
    const [path, setPath] = useState(read);
    useEffect(() => {
      const on = () => setPath(read());
      window.addEventListener("popstate", on);
      window.addEventListener("psa:navigate", on);
      return () => { window.removeEventListener("popstate", on); window.removeEventListener("psa:navigate", on); };
    }, []);
    return path;
  };
  PSA.Link = ({ to, onClick, children, ...rest }) => (
    <a href={to} {...rest} onClick={(e) => {
      if (onClick) onClick(e);
      if (e.defaultPrevented || e.button !== 0 || e.metaKey || e.ctrlKey || e.shiftKey || e.altKey || rest.target) return;
      e.preventDefault();
      PSA.navigate(to);
    }}>{children}</a>
  );

  // ---------- session ----------
  PSA.SessionContext = createContext(null);
  PSA.useSession = () => useContext(PSA.SessionContext);

  // ---------- data ----------
  PSA.useAsync = (fn, deps = []) => {
    const [state, setState] = useState({ loading: true, data: null, error: null });
    const [tick, setTick] = useState(0);
    useEffect(() => {
      let alive = true;
      const ctrl = new AbortController();
      setState((s) => ({ ...s, loading: true, error: null }));
      Promise.resolve(fn(ctrl.signal)).then(
        (data) => { if (alive) setState({ loading: false, data, error: null }); },
        (error) => { if (alive && error.name !== "AbortError") setState((s) => ({ loading: false, data: s.data, error })); },
      );
      return () => { alive = false; ctrl.abort(); };
    }, [...deps, tick]);
    const reload = useCallback(() => setTick((t) => t + 1), []);
    const setData = useCallback((d) => setState((s) => ({ ...s, data: typeof d === "function" ? d(s.data) : d })), []);
    return { ...state, reload, setData };
  };

  // ---------- toasts ----------
  PSA.toast = (message, opts = {}) => window.dispatchEvent(new CustomEvent("psa:toast", { detail: { message, ...opts } }));
  PSA.Toasts = () => {
    const [items, setItems] = useState([]);
    useEffect(() => {
      let id = 0;
      const on = (e) => {
        const t = { id: ++id, ...e.detail };
        setItems((x) => [...x.slice(-2), t]);
        setTimeout(() => setItems((x) => x.filter((i) => i.id !== t.id)), t.duration || (t.kind === "error" ? 8000 : 4000));
      };
      window.addEventListener("psa:toast", on);
      return () => window.removeEventListener("psa:toast", on);
    }, []);
    return (
      <div className="toasts" role="status" aria-live="polite">
        {items.map((t) => (
          <div key={t.id} className={`toast${t.kind === "error" ? " toast--error" : ""}`}>
            <Icon name={t.kind === "error" ? "warning" : "check"} size="sm" />
            <span>{t.message}</span>
            {t.action && <button type="button" onClick={() => { t.action.onClick(); setItems((x) => x.filter((i) => i.id !== t.id)); }}>{t.action.label}</button>}
          </div>
        ))}
      </div>
    );
  };

  // ---------- dialogs ----------
  // A native <dialog> opened with showModal(): focus trap, Escape, top layer and inert page for free.
  const DialogInner = ({ onClose, title, children, footer, wide }) => {
    const ref = useRef(null);
    const titleId = useRef(`dlg-${Math.random().toString(36).slice(2)}`).current;
    const back = useRef(document.activeElement);
    const closeRef = useRef(onClose);
    closeRef.current = onClose;
    useEffect(() => {
      const d = ref.current;
      let mounted = true;
      d.showModal();
      const requestClose = () => { if (closeRef.current) closeRef.current(); };
      // Escape normally arrives as a cancelable `cancel`: the dialog stays and React decides. Without
      // fresh user activation the browser makes it uncancelable and closes the dialog natively while
      // React still has it mounted, which left "Νέος συντάκτης" dead. So a native close reopens it and
      // the close request goes through React afterwards.
      const onCancel = (e) => { if (e.cancelable) { e.preventDefault(); requestClose(); } };
      const onNativeClose = () => {
        if (!mounted) return;
        try { d.showModal(); } catch (err) { /* already open again */ }
        setTimeout(() => { if (mounted) requestClose(); }, 0);
      };
      d.addEventListener("cancel", onCancel);
      d.addEventListener("close", onNativeClose);
      return () => {
        mounted = false;
        d.removeEventListener("cancel", onCancel);
        d.removeEventListener("close", onNativeClose);
        if (d.open) d.close();
        if (back.current && back.current.focus) back.current.focus();
      };
    }, []);
    return (
      <dialog ref={ref} className={`dialog${wide ? " dialog--wide" : ""}`} aria-labelledby={titleId}
              onMouseDown={(e) => { if (e.target === ref.current && onClose) onClose(); }}>
        <div className="dialog__head">
          <h2 id={titleId}>{title}</h2>
          {onClose && <button type="button" className="btn btn--quiet btn--icon btn--sm" onClick={onClose} aria-label="Κλείσιμο"><Icon name="close" /></button>}
        </div>
        <div className="dialog__body">{children}</div>
        {footer && <div className="dialog__foot">{footer}</div>}
      </dialog>
    );
  };
  PSA.Dialog = ({ open, ...props }) => (open ? <DialogInner {...props} /> : null);

  PSA.confirm = (opts) => new Promise((resolve) => window.dispatchEvent(new CustomEvent("psa:confirm", { detail: { ...opts, resolve } })));
  PSA.ConfirmHost = () => {
    const [c, setC] = useState(null);
    useEffect(() => {
      const on = (e) => setC(e.detail);
      window.addEventListener("psa:confirm", on);
      return () => window.removeEventListener("psa:confirm", on);
    }, []);
    if (!c) return null;
    const done = (v) => { c.resolve(v); setC(null); };
    return (
      <PSA.Dialog open title={c.title} onClose={() => done(false)} footer={<>
        <button type="button" className="btn" onClick={() => done(false)} autoFocus={!!c.danger}>{c.cancelLabel || "Άκυρο"}</button>
        <button type="button" className={`btn ${c.danger ? "btn--danger" : "btn--primary"}`} onClick={() => done(true)} autoFocus={!c.danger}>{c.confirmLabel || "Συνέχεια"}</button>
      </>}>
        {typeof c.body === "string" ? <p>{c.body}</p> : c.body}
      </PSA.Dialog>
    );
  };

  // ---------- form primitives ----------
  PSA.Field = ({ label, hint, error, count, max, htmlFor, labelId, optional, children }) => (
    <div className={`field${error ? " is-error" : ""}`}>
      {label && (
        <label id={labelId} className="label" htmlFor={htmlFor}>
          <span>{label}{optional && <span className="muted" style={{ fontWeight: 500 }}> · προαιρετικό</span>}</span>
          {max != null && <span className={`mono${count > max ? " is-over" : ""}`}>{count}/{max}</span>}
        </label>
      )}
      {children}
      {error ? <p className="field__error">{error}</p> : hint ? <p className="hint">{hint}</p> : null}
    </div>
  );

  PSA.Switch = ({ checked, onChange, label, disabled }) => (
    <label className="switch">
      <input type="checkbox" role="switch" checked={checked} disabled={disabled} onChange={(e) => onChange(e.target.checked)} />
      <span className="switch__track" aria-hidden="true" />
      {label && <span>{label}</span>}
    </label>
  );

  PSA.Segmented = ({ options, value, onChange, label }) => (
    <div className="segmented" role="group" aria-label={label}>
      {options.map((o) => (
        <button key={o.value} type="button" aria-pressed={value === o.value} onClick={() => onChange(o.value)}>
          {o.icon && <Icon name={o.icon} size="sm" />}<span className="segmented__label">{o.label}</span>{o.count != null && <span className="count">{o.count}</span>}
        </button>
      ))}
    </div>
  );

  PSA.StateBadge = ({ state }) => <span className={`state state--${state}`}>{PSA.STATE_LABEL[state]}</span>;

  PSA.Spinner = ({ label = "Φόρτωση…" }) => (
    <span className="muted" role="status" style={{ display: "inline-flex", alignItems: "center", gap: 8 }}>
      <Icon name="loader" size="sm" className="spin" />{label}
    </span>
  );

  PSA.ErrorNotice = ({ error, onRetry }) => (
    <div className="notice notice--danger" role="alert">
      <Icon name="warning" size="sm" />
      <div style={{ flex: 1 }}>{PSA.errorText(error)}</div>
      {onRetry && <button type="button" className="btn btn--sm" onClick={onRetry}>Δοκιμή ξανά</button>}
    </div>
  );

  // Textareas that grow with their content, so the page scrolls instead of a box inside it.
  // Grows a textarea to its text. It re-measures when the box changes width and when the webfont lands:
  // measuring only on a value change left a title cropped once the column narrowed or the display face
  // arrived and wrapped it onto another line. The border is added back, since boxes are border-box.
  const fitHeight = (el) => {
    el.style.height = "auto";
    el.style.height = `${el.scrollHeight + el.offsetHeight - el.clientHeight}px`;
  };
  PSA.useAutosize = (ref, value) => {
    useLayoutEffect(() => { if (ref.current) fitHeight(ref.current); }, [value]);
    useEffect(() => {
      const el = ref.current;
      if (!el) return undefined;
      let width = el.clientWidth, alive = true;
      const ro = new ResizeObserver(() => { if (el.clientWidth !== width) { width = el.clientWidth; fitHeight(el); } });
      ro.observe(el);
      if (document.fonts) document.fonts.ready.then(() => { if (alive) fitHeight(el); });
      return () => { alive = false; ro.disconnect(); };
    }, []);
  };

  // The shared Markdown/slug module loads as an ES module; it is in place before Babel runs, but a
  // component asks through this so a slow load degrades to plain text instead of a crash.
  PSA.text = () => window.PSBlogText || null;

  PSA.initials = (name) => String(name || "?").trim().split(/\s+/).map((w) => w[0]).slice(0, 2).join("").toUpperCase();
})();
