// Represents: /launchurls (launchurls.index) · LaunchUrlController::index — see docs/ISYSTEM_REFERENCE.md §Batch 4 "Launch Urls"
/* Host CMS ▾ → Launch URLs (prototype nav label "Game Launch URL").
   Iwakiri (White Label) back-office screen — Iwakiri red, not PayBO indigo:
   every brand colour comes from the `--p-*` ramp, which resolves to the red
   ramp under html[data-theme="iwakiri"].

   Real chain — this is the platform's *reference architecture* feature
   (CLAUDE.md "New Feature Blueprint"): LaunchUrlController → StoreLaunchUrlRequest
   → LaunchUrlDto → LaunchUrlService → LaunchUrlRepositoryInterface →
   LaunchUrlRepository (extends BaseRepository) → LaunchUrl model.
     GET  /launchurls/       launchurls.index   admin.php:1282
     POST /launchurls/save/  launchurls.save    admin.php:1283  (upsert)
     POST /launchurls/delete/ launchurls.delete admin.php:1284
   Gate on all three: isadmin() — Super Admin only (users.user_level === 0).

   Cross-screen: `providers.custom_launch_url_id` stores a `launch_urls.id`
   (0 = "use the integration default host"). The Providers editor on
   CMS → Game import (src/pages/HostCmsGamesImport.jsx) reads the same table
   directly, so both pages name the same environments with nothing shared
   between them — same terminology as there ("Launch URL environment").

   Editing model is faithful to the real Blade view: no modal and no separate
   form — Name and URL are editable inputs in the row itself, "Add Launch URL"
   prepends a `.lu-new` row whose ID cell reads "New", and the same
   launchurls.save endpoint upserts on the presence of `id`. The chevron
   reveals a detail row that repeats ID / Providers using / Last updated
   (the real view's compact-view repeat — it is deliberately the same three
   read-only values, not extra fields).

   Delete is pre-disabled with a tooltip in exactly the two cases the backend
   refuses: `is_system` rows (double-enforced — controller ajaxError +
   LaunchUrlService::delete() RuntimeException) and rows a provider still
   references (controller-only guard, LaunchUrlRepository::referencedByProviders()).

   Label policy: nearly every backend.* key on this screen resolves nowhere in
   the committed public/default-lang/en/backend.php (real strings live in the
   gitignored storage/lang), so the operator-facing copy below is inferred and
   marked. Resolved keys reused verbatim: backend.id "ID", backend.name "Name",
   backend.save "Save", backend.insert_name "Insert name", backend.new "New",
   backend.operation_ok "Operation performed successfully.".

   <!-- SUGGESTION: every create/update/delete writes a launch_url_logs row (LaunchUrlCreated/Updated/Deleted → LogLaunchUrlTimelineJob), but no admin screen ever reads that table — it is a write-only audit trail. A per-row "History" drawer over launch_url_logs would make the audit data an operator can actually use, and it is the only piece of this feature's blueprint that has no UI. -->
   <!-- SUGGESTION: the is_system delete guard is enforced twice (controller + LaunchUrlService::delete()), but the in-use guard (isReferenced) lives only in the controller — a service-level delete would happily orphan every provider pointing at the row. Move the reference check into LaunchUrlService::delete() alongside the isSystem check. -->
   <!-- SUGGESTION: "Providers using" shows a count with no way to see which providers those are. usageCountsByLaunchUrl() already groups the rows; returning the provider names would let an operator clear a reference without hunting through the Providers list to find what is blocking a delete. -->
   <!-- SUGGESTION: save() returns only {id} and the page hard-reloads on success, throwing away scroll position and any other row being edited. Returning the saved row (name, url, updated_at, usage) would let the table patch itself in place. --> */

const { useState: useStateLU, useEffect: useEffectLU } = React;

/* The six seeded environments used to live here as a LU_DEFAULT literal and
   were persisted to localStorage. They are rows in `launch_urls` now, inserted
   by supabase/003_games_catalogue.sql from App\Constants\LaunchUrlEnvironment,
   with is_system = true — so the same six are undeletable, for the same reason,
   without this file asserting anything about them.

   One thing the constant used to carry is deliberately NOT reproduced: the
   "19 providers" and "4 providers" counts. Those were invented. The count now
   comes from providers(count) — the reverse embed over
   providers.custom_launch_url_id — so a zero here means zero, and the delete
   guard below is driven by the same number the backend guard would use. */
const hluTs = (iso) => {
  if (!iso) return "—";
  const d = new Date(iso);
  if (isNaN(d)) return "—";
  const p = (n) => String(n).padStart(2, "0");
  return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())} ${p(d.getHours())}:${p(d.getMinutes())}:${p(d.getSeconds())}`;
};

/* PostgREST answers a count embed as [{count: n}] — an array, not a number. */
const hluCount = (v) => (Array.isArray(v) ? Number(v[0] && v[0].count) || 0 : Number(v) || 0);

const hluRow = (r) => ({
  id: Number(r.id),
  name: String(r.name == null ? "" : r.name),
  url: String(r.url == null ? "" : r.url),
  providers: hluCount(r.providers),
  updated: hluTs(r.updated_at || r.created_at),
  isSystem: !!r.is_system,
});

/* The Providers editor used to borrow this screen's list through
   window.getLaunchUrls(). It reads `launch_urls` itself now, so the accessor
   and its localStorage backing are gone — two screens reading one table need
   nothing to keep in step. */

/* StoreLaunchUrlRequest rules, in Laravel's own evaluation order:
     name → required|string|max:100
     url  → required|string|max:255|url|regex:/^https?:\/\//|not_regex:/\/$/
   Returns {field, msg} so the offending input can be red-outlined the way the
   real ajaxError({campierrati:[…]}) payload drives the shared AJAX modal.
   Messages are inferred (backend.insert_launch_url / launch_url_invalid /
   launch_url_scheme / launch_url_no_trailing_slash resolve nowhere); only
   "Insert name" (backend.insert_name) is the committed string. */
const hluValidate = (name, url) => {
  const n = (name || "").trim(), u = (url || "").trim();
  if (!n) return { field: "name", msg: "Insert name" };
  if (n.length > 100) return { field: "name", msg: "Name must be 100 characters or fewer." };
  if (!u) return { field: "url", msg: "Insert the launch URL." };
  if (u.length > 255) return { field: "url", msg: "URL must be 255 characters or fewer." };
  if (!/^[a-z][a-z0-9+.-]*:\/\/[^\s/?#]+\.?[^\s]*$/i.test(u)) return { field: "url", msg: "Enter a valid URL." };
  if (!/^https?:\/\//i.test(u)) return { field: "url", msg: "URL must start with http:// or https://." };
  if (/\/$/.test(u)) return { field: "url", msg: "URL must not end with a slash." };
  return null;
};

/* Permission honesty — isadmin() is the only gate, and it is checked three times. */
const HLU_GATE = (
  <>Real-platform access: <code>isadmin()</code> — <b>Super Admin only</b> (<code>users.user_level === 0</code>).
    Checked three times: the CMS ▾ sidebar entry (itself wrapped in a redundant nested <code>@if (isadmin())</code>),
    <code> LaunchUrlController::index()</code> (<code>abort(404)</code> on failure), and both write endpoints
    <code> save()</code> / <code>delete()</code> (<code>ajaxError(backend.permission_error)</code>). No skin
    feature flag is involved, and the routes carry no route-level permission middleware — the controller is the gate.</>
);

const LaunchUrls = () => {
  window.useLocale && window.useLocale();
  const feed = useHrsFetch(() => window.sb.list("launchUrls", { limit: 200 }), []);
  const save = useHrsSave(feed);
  const rows = React.useMemo(() => (feed.data || []).map(hluRow), [feed.data]);
  const [drafts, setDrafts] = useStateLU({});
  const [pending, setPending] = useStateLU([]); // not-yet-saved rows, each its own draft
  const [expanded, setExpanded] = useStateLU(() => new Set());
  const [invalid, setInvalid] = useStateLU({});  // row id / pending key → offending field (the campierrati highlight)
  const [search, setSearch] = useStateLU("");

  /* Drafts are seeded from the server rows once they arrive, and re-seeded on
     every refetch. Any row the operator has since edited keeps its draft — a
     background refresh must not silently discard typing. */
  useEffectLU(() => {
    setDrafts(d => {
      const next = {};
      rows.forEach(r => { next[r.id] = d[r.id] || { name: r.name, url: r.url }; });
      return next;
    });
  }, [rows]);

  const toggleExpand = (id) => setExpanded(s => {
    const n = new Set(s);
    n.has(id) ? n.delete(id) : n.add(id);
    return n;
  });

  const clearInvalid = (key) => setInvalid(v => (v[key] ? { ...v, [key]: null } : v));
  const setDraft = (id, field, val) => { clearInvalid(id); setDrafts(d => ({ ...d, [id]: { ...(d[id] || {}), [field]: val } })); };

  const isDirty = (id) => {
    const d = drafts[id];
    const r = rows.find(x => x.id === id);
    if (!d || !r) return false;
    return d.name !== r.name || d.url !== r.url;
  };

  /* POST launchurls.save with {id, name, url}. LaunchUrlService::update()
     forgets the launch_urls.all cache and dispatches LaunchUrlUpdated. */
  const handleSave = (id) => {
    const d = drafts[id];
    if (!d) return;
    const err = hluValidate(d.name, d.url);
    if (err) {
      setInvalid(v => ({ ...v, [id]: err.field }));
      hrsToast("Save failed", `${err.msg} — StoreLaunchUrlRequest::failedValidation() answers with ajaxError(message, {campierrati: ["${err.field}"]}), which red-outlines the field in the shared AJAX modal.`); /* label inferred — backend.save_failed */
      return;
    }
    clearInvalid(id);
    const name = d.name.trim(), url = d.url.trim();
    save.run(() => window.sb.update("launchUrls", id, { name, url }),
      { done: "Operation performed successfully.", fail: "Save failed" });
  };

  /* POST launchurls.delete with {id}. Refused when is_system (controller +
     service) or when a provider still references the row (controller only) —
     the button is pre-disabled in both cases, exactly as the Blade view does. */
  const handleDelete = (row) => {
    if (row.isSystem || (row.providers || 0) > 0) return; // guarded — button is disabled in this case
    if (!confirm(`Delete "${row.name}"? This can't be undone.`)) return; /* label inferred — backend.confirm_delete_launch_url */
    /* Both guards already passed to get here: is_system is false and
       providers(count) is 0. The row carries deleted_at, so sb.remove soft
       deletes — a launch URL a provider once pointed at stays resolvable. */
    save.run(() => window.sb.remove("launchUrls", row.id),
      { done: "Operation performed successfully.", fail: "Delete failed" });
  };

  /* "Add Launch URL" prepends a .lu-new row with the ID cell reading "New";
     the same save endpoint upserts on the absence of `id`. Every click opens
     another independent pending row, so several environments can be drafted
     and saved in any order. */
  const addPendingRow = () => setPending(p => [...p, { key: `p${Date.now()}${Math.random().toString(36).slice(2)}`, name: "", url: "" }]);
  const setPendingField = (key, field, val) => { clearInvalid(key); setPending(p => p.map(x => x.key === key ? { ...x, [field]: val } : x)); };
  const discardPending = (key) => { clearInvalid(key); setPending(p => p.filter(x => x.key !== key)); };
  const savePendingRow = (key) => {
    const draft = pending.find(x => x.key === key);
    if (!draft) return;
    const err = hluValidate(draft.name, draft.url);
    if (err) {
      setInvalid(v => ({ ...v, [key]: err.field }));
      hrsToast("Save failed", `${err.msg} — StoreLaunchUrlRequest::failedValidation() answers with ajaxError(message, {campierrati: ["${err.field}"]}), which red-outlines the field in the shared AJAX modal.`); /* label inferred — backend.save_failed */
      return;
    }
    clearInvalid(key);
    /* is_system is not sent: it defaults to false, and only the migration's
       seeded environments are system rows. The identity column assigns the id. */
    save.run(() => window.sb.create("launchUrls", { name: draft.name.trim(), url: draft.url.trim() }),
      { done: "Operation performed successfully.", fail: "Save failed" })
      .then(res => { if (res && res.ok) setPending(p => p.filter(x => x.key !== key)); });
  };

  const copyUrl = (url) => {
    if (!url) return;
    /* Both failure paths used to be silent: no `else` when the Clipboard API is
       absent (it needs a secure context), and no `catch` when the write is
       refused. Either way the operator clicked and nothing at all happened. */
    const failed = () => hrsToast("Could not copy", "The browser refused clipboard access — select the URL in the row and copy it manually.");
    if (navigator.clipboard?.writeText) {
      navigator.clipboard.writeText(url)
        .then(() => hrsToast("URL copied", "Clipboard only — the copy button sends no request.")) /* label inferred — backend.copy_url */
        .catch(failed);
    } else {
      failed();
    }
  };

  // Client-side substring filter on name OR url, exactly like the real #lu-search.
  const q = search.trim().toLowerCase();
  const filteredRows = q ? rows.filter(r => r.name.toLowerCase().includes(q) || r.url.toLowerCase().includes(q)) : rows;
  const totalProviders = rows.reduce((s, r) => s + (r.providers || 0), 0);
  const inv = (key, field) => (invalid[key] === field ? " lu-invalid" : "");

  return (
    <div className="page lu-page">
      <div className="page__header">
        <div>
          <div className="page__title">
            Launch URLs{/* label inferred — backend.launch_urls */}
            <Tip>{HLU_GATE}</Tip>
          </div>
          <div className="page__subtitle">Gamelauncher environments referenced by the provider "Custom Launch URL" selector</div>{/* label inferred — backend.launch_urls_subtitle */}
        </div>
        <div className="page__actions">
          <button className="btn btn--primary" onClick={addPendingRow}><Icon name="plus" size={13} /> Add Launch URL</button>{/* label inferred — backend.add_launch_url */}
        </div>
      </div>

      {/* Both cards come straight from the controller: $launch_urls->count() and
          $usage->sum() (providers rows with custom_launch_url > 0). */}
      <div className="grid grid-2 lu-kpis">
        <div className="kpi">
          <div className="lu-kpi__head">
            <Icon name="globe" size={12} />
            <div className="kpi__label">Environments</div>{/* label inferred — backend.environments */}
          </div>
          <div className="kpi__value">{rows.length}</div>
        </div>
        <div className="kpi">
          <div className="lu-kpi__head">
            <Icon name="tag" size={12} />
            <div className="kpi__label">Providers referencing</div>{/* label inferred — backend.providers_referencing */}
          </div>
          <div className="kpi__value">{totalProviders}</div>
        </div>
      </div>

      <Explainer compact title="How this works">{/* label inferred — backend.how_this_works + backend.launch_urls_hint */}
        Each row is a gamelauncher host a provider can be pinned to: <code>providers.custom_launch_url</code> stores this
        row's id, and <code>0</code> means "use the integration's default host". Changes apply to game launches on all
        servers as soon as they save — <code>LaunchUrlService</code> caches the list for an hour and busts that cache on
        every write. URLs must start with <code>http://</code> or <code>https://</code> and must not end with a slash.
        The six seeded environments are system rows and can never be deleted; any other entry can, but only once no
        provider references it. Every save and delete is written to <code>launch_url_logs</code>, which no screen reads back.
      </Explainer>

      <div className="lu-toolbar">
        <div className="lu-search">
          <Icon name="search" size={13} className="lu-search__icon" />
          <input className="input lu-search__input" placeholder="Search name or URL…"
            value={search} onChange={e => setSearch(e.target.value)} />{/* label inferred — backend.search_name_or_url */}
        </div>
      </div>

      {/* HrsAsync owns loading and error. `empty` is deliberately NOT delegated
          to it: a pending "New" row must still be editable on an empty table,
          and the tbody below already prints the two empty messages. So the
          guard is on feed.error only — the zero-row case falls through. */}
      {feed.loading ? <HrsSkeleton rows={6} cols={7} />
       : feed.error ? <HrsError error={feed.error} onRetry={feed.retry} />
       : (
      /* Fixed ORDER BY id (LaunchUrlRepository::allOrderedById()) — no sortable
         columns and no pagination on the real screen either. */
      <div className="panel lu-panel">
        <table className="data-table lu-table">
          <thead>
            <tr>
              <th className="lu-h-expand"></th>
              <th className="lu-h-id">ID</th>
              <th className="lu-h-name">Name</th>
              <th className="lu-h-url">URL</th>{/* label inferred — backend.url */}
              <th className="lu-h-prov">Providers using</th>{/* label inferred — backend.providers_using */}
              <th className="lu-h-upd">Last updated</th>{/* label inferred — backend.last_updated */}
              <th className="lu-h-act"></th>
            </tr>
          </thead>
          <tbody>
            {pending.map(p => (
              <tr key={p.key} className="lu-pending-row">
                <td className="lu-c-expand"></td>
                <td className="lu-c-id"><span className="lu-new-badge">New</span></td>
                <td className="lu-c-name">
                  <input className={`input lu-input${inv(p.key, "name")}`} placeholder="Insert name" maxLength={100} autoFocus
                    value={p.name} onChange={e => setPendingField(p.key, "name", e.target.value)} />
                </td>
                <td className="lu-c-url">
                  <div className="lu-url-cell">
                    <input className={`input lu-input${inv(p.key, "url")}`} placeholder="https://..." maxLength={255}
                      value={p.url} onChange={e => setPendingField(p.key, "url", e.target.value)} />
                  </div>
                </td>
                <td className="lu-c-prov">—</td>
                <td className="lu-c-upd">—</td>
                <td className="lu-c-actions">
                  <div className="lu-actions">
                    <button className="btn btn--primary btn--sm" disabled={save.busy} onClick={() => savePendingRow(p.key)}>Save</button>
                    <button className="lu-icon-btn" title="Discard" onClick={() => discardPending(p.key)}><Icon name="x" size={13} /></button>
                  </div>
                </td>
              </tr>
            ))}
            {filteredRows.map(r => {
              const d = drafts[r.id] || { name: r.name, url: r.url };
              const dirty = isDirty(r.id);
              const isOpen = expanded.has(r.id);
              // Two independent delete blocks, both mirrored from the backend.
              const blocked = r.isSystem
                ? "System launch URLs cannot be deleted."               /* label inferred — backend.launch_url_system */
                : (r.providers || 0) > 0
                  ? `In use by ${r.providers} provider${r.providers === 1 ? "" : "s"} — clear the reference first.` /* label inferred — backend.launch_url_in_use */
                  : null;
              return (
                <React.Fragment key={r.id}>
                  <tr className={isOpen ? "lu-row lu-row--open" : "lu-row"}>
                    <td className="lu-c-expand">
                      <button className="lu-expand-btn" onClick={() => toggleExpand(r.id)}
                        aria-expanded={isOpen} title={isOpen ? "Hide details" : "Show details"}>
                        <Icon name={isOpen ? "chevron_down" : "chevron_right"} size={13} />
                      </button>
                    </td>
                    <td className="lu-c-id">{r.id}</td>
                    <td className="lu-c-name">
                      <input className={`input lu-input${inv(r.id, "name")}`} value={d.name} maxLength={100}
                        onChange={e => setDraft(r.id, "name", e.target.value)} />
                    </td>
                    <td className="lu-c-url">
                      <div className="lu-url-cell">
                        <input className={`input lu-input${inv(r.id, "url")}`} value={d.url} maxLength={255}
                          onChange={e => setDraft(r.id, "url", e.target.value)} />
                        <button className="lu-icon-btn" title="Copy URL" onClick={() => copyUrl(d.url)}><Icon name="copy" size={13} /></button>
                      </div>
                    </td>
                    <td className="lu-c-prov">{r.providers}</td>
                    <td className="lu-c-upd">{r.updated}</td>
                    <td className="lu-c-actions">
                      <div className="lu-actions">
                        <button className={`btn btn--ghost btn--sm${dirty ? " lu-save--dirty" : ""}`}
                          disabled={save.busy} onClick={() => handleSave(r.id)}>
                          {dirty && <span className="lu-dirty-dot" />}{save.busy ? "Saving…" : "Save"}
                        </button>
                        <button className="lu-icon-btn lu-icon-btn--danger" disabled={!!blocked || save.busy}
                          title={blocked || "Delete"}
                          onClick={() => handleDelete(r)}>
                          <Icon name="trash" size={13} />
                        </button>
                      </div>
                    </td>
                  </tr>
                  {isOpen && (
                    /* The real view's hidden detail row: it repeats ID / Providers
                       using / Last updated rather than exposing anything new. On a
                       narrow viewport those three columns collapse out of the card,
                       so this row is where they are read. */
                    <tr className="lu-detail-row">
                      <td className="lu-c-spacer"></td>
                      <td colSpan={6}>
                        <div className="lu-detail">
                          <div className="lu-detail__item"><span className="lu-detail__label">ID</span><span className="lu-detail__value">{r.id}</span></div>
                          <div className="lu-detail__item"><span className="lu-detail__label">Providers using</span><span className="lu-detail__value">{r.providers}</span></div>
                          <div className="lu-detail__item"><span className="lu-detail__label">Last updated</span><span className="lu-detail__value">{r.updated}</span></div>
                        </div>
                      </td>
                    </tr>
                  )}
                </React.Fragment>
              );
            })}
            {filteredRows.length === 0 && pending.length === 0 && (
              <tr className="lu-empty-row">
                <td colSpan={7}>
                  {q ? <>No environments match "{search}".</>
                     : <>No launch URLs in <code>launch_urls</code>. The six <code>LaunchUrlEnvironment</code>
                        rows are seeded by the migration, so an empty table here usually means the read
                        was filtered to nothing rather than that the table is empty — check the session.</>}
                </td>
              </tr>
            )}
          </tbody>
        </table>
      </div>
       )}
    </div>
  );
};

window.LaunchUrls = LaunchUrls;
