<?php

require __DIR__ . '/../bootstrap.php';

use KeyLair\Auth;
use KeyLair\AuditLog;
use KeyLair\Csrf;
use KeyLair\Database;
use KeyLair\DemoMask;
use KeyLair\Helpers;
use KeyLair\I18n;
use KeyLair\Platform;

// Unlike keys_export.php, this page doesn't have to turn a reviewer/demo
// account away entirely just because it shows key values -- DemoMask::
// keyCode() (used below) already masks those the same way Redemptions
// does, so anyone logged in can browse the list read-only; only the actual
// mutations (POST below) still require real edit access.
$admin = Auth::requireLogin();
$canEdit = Auth::canEdit();
$isDemo = $admin['role'] === 'demo';
$pdo = Database::connection();

$productId = (int) ($_GET['product_id'] ?? $_POST['product_id'] ?? 0);
$platform = $_GET['platform'] ?? $_POST['platform'] ?? '';

if (!$productId || !Platform::exists($platform)) {
    http_response_code(404);
    die(I18n::t('keys_pool.error_unknown_product_platform'));
}

$productStmt = $pdo->prepare('SELECT id, name FROM products WHERE id = :id');
$productStmt->execute(['id' => $productId]);
$product = $productStmt->fetch();
if (!$product) {
    http_response_code(404);
    die(I18n::t('keys_pool.error_product_not_found'));
}

$flash = null;

if ($_SERVER['REQUEST_METHOD'] === 'POST') {
    // The page view itself only needs requireLogin() above (values are
    // masked for a read-only account) -- but every mutation below is real,
    // so it still needs the full edit check regardless of how it got here.
    Auth::requireEditAccess();
    Csrf::requireValid();
    $action = $_POST['form_action'] ?? '';
    $ids = array_filter(array_map('intval', $_POST['key_ids'] ?? []));

    if ($action === 'bulk_set') {
        if (empty($ids)) {
            $flash = ['type' => 'error', 'message' => I18n::t('keys_pool.flash.select_key_first')];
        } else {
            $placeholders = implode(',', array_fill(0, count($ids), '?'));
            $sets = [];
            $params = [];
            $summary = [];

            $releaseVal = $_POST['bulk_release'] ?? '';
            if (in_array($releaseVal, ['active', 'inactive'], true)) {
                $sets[] = "pool = ?";
                $params[] = $releaseVal;
                $summary[] = 'release=' . $releaseVal;
            }

            $statusVal = $_POST['bulk_status'] ?? '';
            if (in_array($statusVal, ['available', 'redeemed', 'exported'], true)) {
                $sets[] = "status = ?";
                $params[] = $statusVal;
                // A key manually reset back to available didn't just get
                // un-redeemed in real life -- its old redemption timestamp
                // would be actively misleading left in place.
                if ($statusVal === 'available') {
                    $sets[] = "redeemed_at = NULL";
                }
                $summary[] = 'status=' . $statusVal;
            }

            $campaignVal = $_POST['bulk_campaign'] ?? '';
            if ($campaignVal === 'none') {
                $sets[] = "campaign_id = NULL";
                $summary[] = 'campaign=none';
            } elseif ($campaignVal !== '' && (int) $campaignVal > 0) {
                $sets[] = "campaign_id = ?";
                $params[] = (int) $campaignVal;
                $summary[] = 'campaign=' . (int) $campaignVal;
            }

            if (empty($sets)) {
                $flash = ['type' => 'error', 'message' => I18n::t('keys_pool.flash.choose_something')];
            } else {
                $sql = 'UPDATE `keys` SET ' . implode(', ', $sets) . " WHERE product_id = ? AND platform = ? AND id IN ({$placeholders})";
                $stmt = $pdo->prepare($sql);
                $stmt->execute(array_merge($params, [$productId, $platform], $ids));
                AuditLog::log($admin['id'], 'keys_pool_bulk_set', "product={$productId} platform={$platform} " . implode(' ', $summary) . ' count=' . $stmt->rowCount());
                $flash = ['type' => 'success', 'message' => sprintf(I18n::t('keys_pool.flash.keys_updated'), $stmt->rowCount())];
            }
        }
    } elseif ($action === 'set_claimed_by') {
        // Only meaningful for a key with no real (system) redemption -- the
        // UI never renders this input for one that has, but re-check here
        // too rather than trust that alone. The value can come from either
        // typing a name or picking an existing user's name from a dropdown
        // (a convenience autofill, not a real link to the users table), so
        // either way it lands in the same claimed_by_note column as plain
        // free text.
        $keyId = (int) ($_POST['key_id'] ?? 0);
        $note = trim((string) ($_POST['claimed_by_note'] ?? ''));
        if (mb_strlen($note) > 190) {
            $note = mb_substr($note, 0, 190);
        }
        // Locking a claimed-by note means this key left circulation --
        // someone has it -- exactly like a real redemption does, just
        // through a channel outside the system instead of a claim link.
        // Treated the same way: status flips to redeemed. Clearing the
        // note back to empty (unlock, then clear, then lock) reverses
        // that -- nobody actually has it after all, back to available.
        $newStatus = $note !== '' ? 'redeemed' : 'available';
        $newRedeemedAt = $note !== '' ? date('Y-m-d H:i:s') : null;
        $stmt = $pdo->prepare(
            'UPDATE `keys` k
             LEFT JOIN redemptions r ON r.key_id = k.id
             SET k.claimed_by_note = ?, k.status = ?, k.redeemed_at = ?
             WHERE k.id = ? AND k.product_id = ? AND k.platform = ? AND r.id IS NULL'
        );
        $stmt->execute([$note !== '' ? $note : null, $newStatus, $newRedeemedAt, $keyId, $productId, $platform]);
        AuditLog::log($admin['id'], 'key_claimed_by_note_set', "product={$productId} platform={$platform} key={$keyId} status={$newStatus}");
        // Names what actually happened (locked with this note and marked
        // redeemed, vs. unlocked/cleared back to available) instead of a
        // generic "saved" that doesn't say which of the two.
        $flash = ['type' => 'success', 'message' => $note !== '' ? sprintf(I18n::t('keys_pool.flash.claimed_locked'), $note) : I18n::t('keys_pool.flash.claimed_unlocked')];
    } elseif ($action === 'release_redemption') {
        // Releases a key claimed through the real system (a claim link),
        // not just the manual note fallback above -- undoes the claim
        // entirely: the redemption record is removed, the creator's claim
        // link gets that claim back against its limit, and the key itself
        // goes back to available (and selectable again in the list).
        $keyId = (int) ($_POST['key_id'] ?? 0);
        $redemptionStmt = $pdo->prepare(
            'SELECT id, claim_link_id, product_id FROM redemptions WHERE key_id = ? AND product_id = ?'
        );
        $redemptionStmt->execute([$keyId, $productId]);
        $redemption = $redemptionStmt->fetch();
        if (!$redemption) {
            $flash = ['type' => 'error', 'message' => I18n::t('keys_pool.flash.no_redemption_found')];
        } else {
            $pdo->beginTransaction();
            try {
                $pdo->prepare('DELETE FROM redemptions WHERE id = ?')->execute([$redemption['id']]);
                $pdo->prepare(
                    'UPDATE claim_link_products SET claims_used = GREATEST(claims_used - 1, 0) WHERE claim_link_id = ? AND product_id = ?'
                )->execute([$redemption['claim_link_id'], $redemption['product_id']]);
                $pdo->prepare(
                    "UPDATE `keys` SET status = 'available', redeemed_at = NULL WHERE id = ? AND product_id = ? AND platform = ?"
                )->execute([$keyId, $productId, $platform]);
                $pdo->commit();
                AuditLog::log($admin['id'], 'key_redemption_released', "product={$productId} platform={$platform} key={$keyId} redemption={$redemption['id']} claim_link={$redemption['claim_link_id']}");
                $flash = ['type' => 'success', 'message' => I18n::t('keys_pool.flash.redemption_released')];
            } catch (\Throwable $e) {
                $pdo->rollBack();
                error_log('release_redemption failed: ' . $e->getMessage());
                $flash = ['type' => 'error', 'message' => I18n::t('keys_pool.flash.redemption_release_error')];
            }
        }
    }
}

// claimed_by_creator/claimed_by_group_id come from the real redemption
// record (via its claim link) when one exists -- a key can only ever be
// redeemed once (status flips away from 'available' the moment it is), so
// this LEFT JOIN can't multiply rows. claimed_by_note is the manual
// fallback for a key that left circulation outside the system entirely.
$keys = $pdo->prepare(
    'SELECT k.id, k.key_value, k.status, k.pool, k.created_at, k.redeemed_at, k.campaign_id, c.name AS campaign_name,
        k.claimed_by_note, u.name AS claimed_by_creator, cl.user_id AS claimed_by_user_id, cl.creator_group_id AS claimed_by_group_id
     FROM `keys` k
     LEFT JOIN campaigns c ON c.id = k.campaign_id
     LEFT JOIN redemptions r ON r.key_id = k.id
     LEFT JOIN claim_links cl ON cl.id = r.claim_link_id
     LEFT JOIN users u ON u.id = cl.user_id
     WHERE k.product_id = :product_id AND k.platform = :platform
     ORDER BY k.status ASC, k.pool ASC, k.created_at ASC'
);
$keys->execute(['product_id' => $productId, 'platform' => $platform]);
$keys = $keys->fetchAll();

// For the manual Claimed-by picker's dropdown -- every real user, so an
// admin can point a manually-claimed key at a known one by name instead of
// retyping it. Just a convenience autofill into claimed_by_note, not a
// real link to that user -- the manual note stays free text either way.
$allUserNames = $pdo->query("SELECT name FROM users WHERE name IS NOT NULL AND name != '' ORDER BY name ASC")->fetchAll(PDO::FETCH_COLUMN);

// Only campaigns actually represented among this product+platform's own
// keys, so the filter doesn't list one that would always match nothing.
$poolCampaignsStmt = $pdo->prepare(
    'SELECT DISTINCT c.id, c.name FROM `keys` k JOIN campaigns c ON c.id = k.campaign_id
     WHERE k.product_id = :product_id AND k.platform = :platform ORDER BY c.name ASC'
);
$poolCampaignsStmt->execute(['product_id' => $productId, 'platform' => $platform]);
$poolCampaigns = $poolCampaignsStmt->fetchAll();

$pageTitle = I18n::t('keys_pool.page_title');
$activeNav = 'keys';
$extraAdminCss = 'admin-product-editor';
require __DIR__ . '/includes/layout_header.php';
?>
<div class="topbar">
  <h1><?= Helpers::e($product['name']) ?> · <?= Helpers::e(Platform::label($platform)) ?></h1>
  <?php if ($canEdit): ?>
    <div class="toolbar-2col-row" style="display:flex; gap:10px; flex-wrap:wrap;">
      <a href="keys_export_form.php?product_id=<?= (int) $productId ?>&platform=<?= urlencode($platform) ?>" class="campaign-select" style="text-decoration:none; color:var(--text-muted);"><?= Helpers::e(I18n::t('keys_pool.export_keys')) ?></a>
      <button type="button" class="campaign-select" onclick="kvExportSelected();" style="color:var(--text-muted);"><?= Helpers::e(I18n::t('keys_pool.export_selected')) ?></button>
      <a href="keys_import.php?product_id=<?= (int) $productId ?>&platform=<?= urlencode($platform) ?>" class="campaign-select" style="text-decoration:none; color:var(--signal); border-color:var(--signal-dim);"><?= Helpers::e(I18n::t('keys_pool.import_keys')) ?></a>
    </div>
  <?php elseif ($isDemo): ?>
    <div class="toolbar-2col-row" style="display:flex; gap:10px; flex-wrap:wrap;">
      <span class="campaign-select" style="text-decoration:none; color:var(--text-faint); border-color:var(--signal-dim); cursor:not-allowed;"><?= Helpers::e(I18n::t('keys_pool.export_keys')) ?></span>
      <span class="campaign-select" style="text-decoration:none; color:var(--text-faint); border-color:var(--signal-dim); cursor:not-allowed;"><?= Helpers::e(I18n::t('keys_pool.import_keys')) ?></span>
    </div>
  <?php endif; ?>
</div>

<div id="export-flash"></div>

<?php // Deliberately outside #pool-form -- a <form> can't nest inside another,
// so this one submits a single claimed-by action on its own -- set/lock a
// note or Creator Group, or release a real redemption. Whichever JS
// function fills in the key id (and note/group for a lock) and the right
// form_action right before submitting it. ?>
<form id="claimed-by-form" method="post" style="display:none;">
  <?= Csrf::field() ?>
  <input type="hidden" name="form_action" id="claimed-by-form-action" value="set_claimed_by">
  <input type="hidden" name="product_id" value="<?= (int) $productId ?>">
  <input type="hidden" name="platform" value="<?= Helpers::e($platform) ?>">
  <input type="hidden" name="key_id" id="claimed-by-key-id">
  <input type="hidden" name="claimed_by_note" id="claimed-by-note-value">
</form>

<?php if ($flash): ?>
  <div class="panel flash-panel" style="border-color:<?= $flash['type'] === 'success' ? 'var(--signal-dim)' : 'var(--danger)' ?>;">
    <span style="color:<?= $flash['type'] === 'success' ? 'var(--signal)' : 'var(--danger)' ?>; font-size:13px;"><?= Helpers::e($flash['message']) ?></span>
  </div>
<?php endif; ?>

<form method="post" id="pool-form" onsubmit="return kvConfirmBulkSet();">
  <?= Csrf::field() ?>
  <input type="hidden" name="product_id" value="<?= (int) $productId ?>">
  <input type="hidden" name="platform" value="<?= Helpers::e($platform) ?>">

  <div class="panel">
    <?php if (empty($keys)): ?>
      <p style="color:var(--text-faint); font-size:13px;"><?= Helpers::e(I18n::t('keys_pool.no_keys_for_product')) ?></p>
    <?php else: ?>
      <?php
        // Shared by every row's own selects/buttons, so they all form one
        // consistent equal-width column grid within their own row. Same
        // explicit min-height too, so a <select>/<button>'s differing
        // default padding/line-height can't throw them out of sync.
        // min-height, not height -- on narrow mobile widths "Move selected
        // to Inactive" wraps onto two lines, and a hard height would clip it.
        $keysToolbarItem = 'flex:1 1 0; min-width:110px; min-height:41px; box-sizing:border-box; font-size:13px;';
      ?>
      <div class="filter-row keys-toolbar-row" style="display:flex; flex-wrap:wrap; gap:10px; margin-bottom:10px;">
        <select id="filter-campaign" onchange="kvApplyKeyFilters();" style="<?= $keysToolbarItem ?> background:var(--bg-panel); border:1px solid var(--line); color:var(--text-primary); border-radius:8px; padding:0 12px;">
          <option value=""><?= Helpers::e(I18n::t('keys_pool.filter.all_campaigns')) ?></option>
          <option value="none"><?= Helpers::e(I18n::t('keys_pool.filter.no_campaign')) ?></option>
          <?php foreach ($poolCampaigns as $c): ?>
            <option value="<?= (int) $c['id'] ?>"><?= Helpers::e($c['name']) ?></option>
          <?php endforeach; ?>
        </select>
        <select id="filter-status" onchange="kvApplyKeyFilters();" style="<?= $keysToolbarItem ?> background:var(--bg-panel); border:1px solid var(--line); color:var(--text-primary); border-radius:8px; padding:0 12px;">
          <option value=""><?= Helpers::e(I18n::t('keys_pool.filter.all_statuses')) ?></option>
          <option value="available:active"><?= Helpers::e(I18n::t('keys_pool.filter.status_available_released')) ?></option>
          <option value="available:inactive"><?= Helpers::e(I18n::t('keys_pool.filter.status_available_held_back')) ?></option>
          <option value="redeemed"><?= Helpers::e(I18n::t('keys_pool.status_redeemed')) ?></option>
          <option value="exported"><?= Helpers::e(I18n::t('keys_pool.status_exported')) ?></option>
        </select>
        <input type="text" id="filter-claimed-by" oninput="kvApplyKeyFilters();" placeholder="<?= Helpers::e(I18n::t('keys_pool.filter.claimed_by_placeholder')) ?>" style="<?= $keysToolbarItem ?> background:var(--bg-panel); border:1px solid var(--line); color:var(--text-primary); border-radius:8px; padding:0 12px;">
        <?php // One bordered box holding both date inputs (borderless/
        // transparent themselves) plus a dash between them, so "Imported"
        // reads as a single field alongside the others instead of its own
        // separate row of pieces. ?>
        <div style="<?= $keysToolbarItem ?> background:var(--bg-panel); border:1px solid var(--line); border-radius:8px; padding:0 8px; display:flex; align-items:center; gap:4px;" title="<?= Helpers::e(I18n::t('keys_pool.filter.imported_between_title')) ?>">
          <input type="date" id="filter-imported-from" onchange="kvApplyKeyFilters();" style="-webkit-appearance:none; appearance:none; background:transparent; border:none; color:var(--text-primary); font-size:12px; padding:0; flex:1 1 0; min-width:0; height:100%;">
          <span style="color:var(--text-faint); font-size:12px; flex-shrink:0;">–</span>
          <input type="date" id="filter-imported-to" onchange="kvApplyKeyFilters();" style="-webkit-appearance:none; appearance:none; background:transparent; border:none; color:var(--text-primary); font-size:12px; padding:0; flex:1 1 0; min-width:0; height:100%;">
        </div>
      </div>
      <p id="filter-empty-hint" style="display:none; color:var(--text-faint); font-size:13px;"><?= Helpers::e(I18n::t('keys_pool.filter.no_matches')) ?></p>
      <?php if (!$canEdit): ?>
      <div style="display:flex; justify-content:flex-end; margin-bottom:14px;">
        <span id="visible-key-count" style="color:var(--text-muted); font-size:12.5px; white-space:nowrap;"><?= Helpers::e(sprintf(I18n::t('keys_pool.count_shown'), count($keys))) ?></span>
      </div>
      <?php endif; ?>
      <?php if ($canEdit): ?>
      <input type="hidden" name="form_action" id="bulk-form-action" value="bulk_set">
      <div class="keys-toolbar-row" style="display:flex; flex-wrap:wrap; gap:10px; margin-bottom:8px;">
        <select name="bulk_campaign" style="<?= $keysToolbarItem ?> background:var(--bg-panel); border:1px solid var(--line); color:var(--text-primary); border-radius:8px; padding:0 12px;">
          <option value=""><?= Helpers::e(I18n::t('keys_pool.bulk.campaign_no_change')) ?></option>
          <option value="none"><?= Helpers::e(I18n::t('keys_pool.bulk.set_prefix') . ' ' . I18n::t('keys_pool.bulk.no_campaign_label')) ?></option>
          <?php foreach ($poolCampaigns as $c): ?>
            <option value="<?= (int) $c['id'] ?>"><?= Helpers::e(I18n::t('keys_pool.bulk.set_prefix')) ?> <?= Helpers::e($c['name']) ?></option>
          <?php endforeach; ?>
        </select>
        <select name="bulk_release" style="<?= $keysToolbarItem ?> background:var(--bg-panel); border:1px solid var(--line); color:var(--text-primary); border-radius:8px; padding:0 12px;">
          <option value=""><?= Helpers::e(I18n::t('keys_pool.bulk.release_no_change')) ?></option>
          <option value="active"><?= Helpers::e(I18n::t('keys_pool.bulk.set_prefix') . ' ' . I18n::t('keys_pool.status_released')) ?></option>
          <option value="inactive"><?= Helpers::e(I18n::t('keys_pool.bulk.set_prefix') . ' ' . I18n::t('keys_pool.status_held_back')) ?></option>
        </select>
        <select name="bulk_status" id="bulk-status-select" style="<?= $keysToolbarItem ?> background:var(--bg-panel); border:1px solid var(--line); color:var(--text-primary); border-radius:8px; padding:0 12px;">
          <option value=""><?= Helpers::e(I18n::t('keys_pool.bulk.status_no_change')) ?></option>
          <option value="available"><?= Helpers::e(I18n::t('keys_pool.bulk.set_prefix') . ' ' . I18n::t('keys_pool.status_available')) ?></option>
          <option value="redeemed"><?= Helpers::e(I18n::t('keys_pool.bulk.set_prefix') . ' ' . I18n::t('keys_pool.status_redeemed')) ?></option>
          <option value="exported"><?= Helpers::e(I18n::t('keys_pool.bulk.set_prefix') . ' ' . I18n::t('keys_pool.status_exported')) ?></option>
        </select>
        <button type="submit" class="btn btn-ghost" style="<?= $keysToolbarItem ?> display:flex; align-items:center; justify-content:center; text-align:center; padding:6px 8px;"><?= Helpers::e(I18n::t('keys_pool.bulk.apply_button')) ?></button>
      </div>
      <div class="keys-toolbar-row" style="display:flex; justify-content:flex-end; gap:12px; align-items:center; margin-bottom:14px;">
        <span id="selected-key-count" style="color:var(--text-muted); font-size:12.5px; white-space:nowrap;"><?= Helpers::e(sprintf(I18n::t('keys_pool.count_selected'), 0)) ?></span>
        <span id="visible-key-count" style="color:var(--text-muted); font-size:12.5px; white-space:nowrap;"><?= Helpers::e(sprintf(I18n::t('keys_pool.count_shown'), count($keys))) ?></span>
      </div>
      <?php endif; ?>
      <table id="pool-table">
        <thead>
          <tr>
            <?php if ($canEdit): ?><th style="width:24px;"><input type="checkbox" onclick="kvToggleAllVisible(this.checked);"></th><?php endif; ?>
            <?php
              // Each sortable header cycles asc -> desc -> asc on repeated
              // clicks; kvSortBy() reads the matching data-sort-* value off
              // each <tr> (set per row below) rather than the cell's own
              // rendered text, so it sorts correctly even where the visible
              // text isn't the raw value (e.g. Status shows "Available ·
              // Released" but sorts on the plain status).
              $sortHeader = static function (string $col, string $label): string {
                  return '<th onclick="kvSortBy(\'' . $col . '\');" style="cursor:pointer; user-select:none;">' . Helpers::e($label) . ' <span class="kv-sort-indicator" data-sort-col="' . $col . '"></span></th>';
              };
              echo $sortHeader('key', I18n::t('keys_pool.col_key'));
              echo $sortHeader('campaign', I18n::t('keys_pool.col_campaign'));
              echo $sortHeader('status', I18n::t('keys_pool.col_status'));
              echo $sortHeader('imported', I18n::t('keys_pool.col_imported'));
              echo $sortHeader('claimedby', I18n::t('keys_pool.col_claimed_by'));
            ?>
          </tr>
        </thead>
        <tbody>
          <?php foreach ($keys as $k): ?>
            <?php
              // claimed_by_user_id (not claimed_by_creator) is the signal for
              // "a real redemption exists" -- the user's name is optional and
              // can be NULL even when a redemption is real, so checking the
              // name itself would misroute a nameless real redemption into
              // the manual-note branch below. A creator name (from a real
              // redemption) is masked the same way Redemptions masks it --
              // demo only, reviewer sees real creator names. The manual note
              // has no such distinction to preserve (it's admin-authored,
              // not a customer's data), so it's shown as-is to anyone who
              // can already see this page.
              $hasRealRedemption = $k['claimed_by_user_id'] !== null;
              $claimedByName = $k['claimed_by_creator'] !== null ? DemoMask::name($k['claimed_by_creator']) : ('User #' . (int) $k['claimed_by_user_id']);
              $claimedByDisplay = $hasRealRedemption ? $claimedByName : $k['claimed_by_note'];
              $claimedByFilterText = $claimedByDisplay ?? '';
              $keyDisplay = DemoMask::keyCode($k['key_value']);
              // Sorting works off what's actually shown, not the raw DB
              // value, so a demo/reviewer's masked key values still sort
              // consistently rather than by the real ones they can't see.
              $importedDate = substr($k['created_at'], 0, 10);
            ?>
            <tr data-campaign="<?= $k['campaign_id'] ? (int) $k['campaign_id'] : 'none' ?>" data-status="<?= Helpers::e($k['status']) ?>" data-pool="<?= Helpers::e($k['pool']) ?>" data-claimed-by="<?= Helpers::e(mb_strtolower($claimedByFilterText)) ?>" data-imported="<?= Helpers::e($importedDate) ?>"
                data-sort-key="<?= Helpers::e(mb_strtolower($keyDisplay)) ?>" data-sort-campaign="<?= Helpers::e(mb_strtolower($k['campaign_name'] ?? '')) ?>" data-sort-status="<?= Helpers::e($k['status'] . ($k['status'] === 'available' ? ':' . $k['pool'] : '')) ?>" data-sort-imported="<?= Helpers::e($k['created_at']) ?>" data-sort-claimedby="<?= Helpers::e(mb_strtolower($claimedByFilterText)) ?>">
              <?php if ($canEdit): ?>
              <td>
                <?php if ($k['status'] === 'available'): ?>
                  <input type="checkbox" class="key-row-check" name="key_ids[]" value="<?= (int) $k['id'] ?>">
                <?php endif; ?>
              </td>
              <?php endif; ?>
              <td class="creator" data-label="<?= Helpers::e(I18n::t('keys_pool.col_key')) ?>" style="font-family:var(--font-mono); font-size:12.5px;"><?= Helpers::e($keyDisplay) ?></td>
              <td class="time-cell" data-label="<?= Helpers::e(I18n::t('keys_pool.col_campaign')) ?>"><?= $k['campaign_name'] ? Helpers::e($k['campaign_name']) : '—' ?></td>
              <td class="time-cell" data-label="<?= Helpers::e(I18n::t('keys_pool.col_status')) ?>">
                <?= Helpers::e(['redeemed' => I18n::t('keys_pool.status_redeemed'), 'exported' => I18n::t('keys_pool.status_exported')][$k['status']] ?? I18n::t('keys_pool.status_available')) ?>
                <?php if ($k['status'] === 'available'): ?>
                  <span style="color:<?= $k['pool'] === 'active' ? 'var(--signal)' : 'var(--key-gold)' ?>;"> · <?= Helpers::e($k['pool'] === 'active' ? I18n::t('keys_pool.status_released') : I18n::t('keys_pool.status_held_back')) ?></span>
                <?php endif; ?>
              </td>
              <td class="time-cell" data-label="<?= Helpers::e(I18n::t('keys_pool.col_imported')) ?>"><?= Helpers::e($k['created_at']) ?></td>
              <td data-label="<?= Helpers::e(I18n::t('keys_pool.col_claimed_by')) ?>">
                <?php if ($hasRealRedemption): ?>
                  <?php // A real system redemption -- Unlock here doesn't just
                  // toggle a UI state, it undoes the claim itself (deletes the
                  // redemption, gives the creator's link that claim back
                  // against its limit, and frees the key), so it always posts
                  // through kvReleaseRedemption()'s confirm() rather than the
                  // instant client-side toggle the manual note below gets. ?>
                  <span style="display:flex; align-items:center; justify-content:space-between; gap:6px;">
                    <?php if ($k['claimed_by_group_id'] && $canEdit): ?>
                      <a href="creator_groups.php?edit=<?= (int) $k['claimed_by_group_id'] ?>" style="color:var(--signal); text-decoration:none;"><?= Helpers::e($claimedByDisplay) ?></a>
                    <?php else: ?>
                      <span><?= Helpers::e($claimedByDisplay) ?></span>
                    <?php endif; ?>
                    <?php if ($canEdit): ?>
                      <button type="button" class="btn btn-ghost" onclick="kvReleaseRedemption(<?= (int) $k['id'] ?>);" style="padding:2px 8px; font-size:10px; flex-shrink:0;"><?= Helpers::e(I18n::t('keys_pool.unlock_button')) ?></button>
                    <?php endif; ?>
                  </span>
                <?php elseif ($canEdit): ?>
                  <?php $noteAlreadySaved = ($k['claimed_by_note'] ?? '') !== ''; ?>
                  <?php if ($noteAlreadySaved): ?>
                    <?php // Locked: reads as plain text, same as every other
                    // cell in this table -- not an input box at all, just a
                    // small Unlock action next to it. kvUnlockClaimedBy()
                    // swaps this span out for the editable version below. ?>
                    <span class="kv-claimedby-locked" data-key-id="<?= (int) $k['id'] ?>" data-note="<?= Helpers::e($k['claimed_by_note']) ?>" style="display:flex; align-items:center; justify-content:space-between; gap:6px;">
                      <span><?= Helpers::e($k['claimed_by_note']) ?></span>
                      <button type="button" class="btn btn-ghost" onclick="kvUnlockClaimedBy(this);" style="padding:2px 8px; font-size:10px; flex-shrink:0;"><?= Helpers::e(I18n::t('keys_pool.unlock_button')) ?></button>
                    </span>
                  <?php else: ?>
                    <div class="kv-claimedby-editor" data-key-id="<?= (int) $k['id'] ?>" style="display:flex; flex-direction:column; gap:4px;">
                      <?php if (!empty($allUserNames)): ?>
                      <?php // Quick-pick convenience only -- fills the text
                      // input below with an existing user's name instead of
                      // retyping it (see kvPickClaimedByCreator()). Not
                      // submitted itself: claimed_by_note stays plain free
                      // text on the key, no id of its own to actually link
                      // to. ?>
                      <select class="kv-cb-pick" onchange="kvPickClaimedByCreator(this);" style="width:100%; background:var(--bg-panel); border:1px solid var(--line); color:var(--text-primary); border-radius:6px; padding:4px 6px; font-size:11px;">
                        <option value=""><?= Helpers::e(I18n::t('keys_pool.claimed_by_pick_placeholder')) ?></option>
                        <?php foreach ($allUserNames as $tag): ?>
                          <option value="<?= Helpers::e($tag) ?>"><?= Helpers::e($tag) ?></option>
                        <?php endforeach; ?>
                      </select>
                      <?php endif; ?>
                      <span style="display:flex; gap:6px; align-items:center;">
                        <input type="text" class="kv-cb-note" value="" placeholder="<?= Helpers::e(I18n::t('keys_pool.claimed_by_placeholder')) ?>" maxlength="190" style="flex:1; min-width:0; background:var(--bg-void); border:1px solid var(--line); color:var(--text-primary); border-radius:6px; padding:5px 8px; font-size:12px;">
                        <button type="button" class="btn btn-ghost" onclick="kvSaveClaimedBy(this.closest('.kv-claimedby-editor'));" style="padding:5px 10px; font-size:11px; flex-shrink:0;"><?= Helpers::e(I18n::t('keys_pool.lock_button')) ?></button>
                      </span>
                    </div>
                  <?php endif; ?>
                <?php else: ?>
                  <?= $claimedByDisplay ? Helpers::e($claimedByDisplay) : '—' ?>
                <?php endif; ?>
              </td>
            </tr>
          <?php endforeach; ?>
        </tbody>
      </table>
      <div id="pool-pagination" style="display:flex; justify-content:center; align-items:center; gap:14px; flex-wrap:wrap; margin-top:16px;">
        <button type="button" class="btn btn-ghost" id="pool-prev-btn" onclick="kvChangePage(-1);" style="padding:7px 14px; font-size:12.5px;"><?= Helpers::e(I18n::t('keys_pool.pagination.prev')) ?></button>
        <span id="pool-page-info" style="color:var(--text-muted); font-size:13px; white-space:nowrap;"></span>
        <button type="button" class="btn btn-ghost" id="pool-next-btn" onclick="kvChangePage(1);" style="padding:7px 14px; font-size:12.5px;"><?= Helpers::e(I18n::t('keys_pool.pagination.next')) ?></button>
        <select id="pool-page-size" onchange="kvChangePageSize();" style="background:var(--bg-panel); border:1px solid var(--line); color:var(--text-primary); font-size:12.5px; padding:7px 12px; border-radius:8px;">
          <option value="10"><?= Helpers::e(sprintf(I18n::t('keys_pool.pagination.per_page_template'), 10)) ?></option>
          <option value="20"><?= Helpers::e(sprintf(I18n::t('keys_pool.pagination.per_page_template'), 20)) ?></option>
          <option value="50" selected><?= Helpers::e(sprintf(I18n::t('keys_pool.pagination.per_page_template'), 50)) ?></option>
          <option value="100"><?= Helpers::e(sprintf(I18n::t('keys_pool.pagination.per_page_template'), 100)) ?></option>
          <option value="200"><?= Helpers::e(sprintf(I18n::t('keys_pool.pagination.per_page_template'), 200)) ?></option>
          <option value="all"><?= Helpers::e(I18n::t('keys_pool.pagination.show_all')) ?></option>
        </select>
      </div>
      <?php if (count($keys) > 20): ?>
      <?php // A long list (especially with "Show all" selected -- this
      // product/platform alone could have thousands of keys) has no other
      // fast way back to either end once you've scrolled into the middle
      // of it. Fixed to the viewport, not the page, so it stays reachable
      // the whole time you're scrolled through the table. ?>
      <div style="position:fixed; right:20px; bottom:20px; display:flex; flex-direction:column; gap:8px; z-index:50;">
        <button type="button" class="btn btn-ghost" onclick="kvScrollToRow('first');" title="<?= Helpers::e(I18n::t('keys_pool.jump_first_row_title')) ?>" style="width:40px; height:40px; padding:0; border-radius:50%; display:flex; align-items:center; justify-content:center; font-size:16px; background:var(--bg-panel); box-shadow:0 2px 8px rgba(0,0,0,0.3);">↑</button>
        <button type="button" class="btn btn-ghost" onclick="kvScrollToRow('last');" title="<?= Helpers::e(I18n::t('keys_pool.jump_last_row_title')) ?>" style="width:40px; height:40px; padding:0; border-radius:50%; display:flex; align-items:center; justify-content:center; font-size:16px; background:var(--bg-panel); box-shadow:0 2px 8px rgba(0,0,0,0.3);">↓</button>
      </div>
      <?php endif; ?>
    <?php endif; ?>
  </div>
</form>

<script>
  // Translated templates for text this script rebuilds/updates client-side
  // after the initial server render (counts, pagination, flash/confirm
  // copy) -- rendered here via I18n::t() same as everywhere else on this
  // page, just handed to JS once instead of echoed directly into HTML.
  const kvShownTemplate = <?= json_encode(I18n::t('keys_pool.count_shown'), JSON_HEX_APOS | JSON_HEX_QUOT) ?>;
  const kvSelectedTemplate = <?= json_encode(I18n::t('keys_pool.count_selected'), JSON_HEX_APOS | JSON_HEX_QUOT) ?>;
  const kvPageInfoTemplate = <?= json_encode(I18n::t('keys_pool.pagination.page_info'), JSON_HEX_APOS | JSON_HEX_QUOT) ?>;

  // Jumps to whichever row is actually first/last among the ones the
  // current filters + pagination leave visible -- not just the first/last
  // in the DOM, which could easily be a filtered-out or off-page row.
  function kvScrollToRow(which) {
    const rows = [...document.querySelectorAll('#pool-table tbody tr')].filter(tr => tr.style.display !== 'none');
    if (rows.length === 0) return;
    const target = which === 'first' ? rows[0] : rows[rows.length - 1];
    target.scrollIntoView({ behavior: 'smooth', block: 'center' });
  }

  // Exporting takes the selected/matched keys out of circulation server-side
  // (keys_export.php marks them 'exported'), so the response is both a file
  // download AND a mutation -- fetch() lets us trigger the download, read
  // back a one-line summary via a response header (never the key values
  // themselves), and reload the list to reflect the now-missing keys, all
  // from one submit instead of a page navigation that would lose the banner.
  async function kvDoExport(url, formData) {
    const res = await fetch(url, { method: 'POST', body: formData });
    if (!res.ok) {
      sessionStorage.setItem('kv-export-flash', JSON.stringify({ type: 'error', message: <?= json_encode(I18n::t('keys_pool.js.export_failed'), JSON_HEX_APOS | JSON_HEX_QUOT) ?> + ' (' + res.status + ').' }));
      location.reload();
      return;
    }
    const rawSummary = res.headers.get('X-Export-Summary');
    const summary = rawSummary ? decodeURIComponent(rawSummary) : <?= json_encode(I18n::t('keys_pool.js.export_complete'), JSON_HEX_APOS | JSON_HEX_QUOT) ?>;
    const blob = await res.blob();
    const disposition = res.headers.get('Content-Disposition') || '';
    const match = disposition.match(/filename="([^"]+)"/);
    const filename = match ? match[1] : 'keys-export.csv';
    const blobUrl = URL.createObjectURL(blob);
    const a = document.createElement('a');
    a.href = blobUrl;
    a.download = filename;
    document.body.appendChild(a);
    a.click();
    a.remove();
    URL.revokeObjectURL(blobUrl);
    sessionStorage.setItem('kv-export-flash', JSON.stringify({ type: 'success', message: summary }));
    location.reload();
  }

  let kvCurrentPage = 1;
  let kvPageSize = 50;

  // Marks which rows match the current filters (data attribute, not
  // display, since pagination decides display -- only a matching row can
  // ever be shown, but not every matching row is on the current page).
  function kvApplyKeyFilters() {
    const campaign = document.getElementById('filter-campaign').value;
    const statusVal = document.getElementById('filter-status').value;
    const claimedBy = document.getElementById('filter-claimed-by').value.trim().toLowerCase();
    const importedFrom = document.getElementById('filter-imported-from').value;
    const importedTo = document.getElementById('filter-imported-to').value;
    // "available:active"/"available:inactive" carry a status AND a pool to
    // match (the Status filter absorbed the old separate Pool filter, same
    // as the table's own Status column now shows both); a bare status
    // ("redeemed", "exported") only checks status.
    let status = statusVal, pool = '';
    if (statusVal.includes(':')) {
      [status, pool] = statusVal.split(':');
    }
    document.querySelectorAll('#pool-table tbody tr').forEach(tr => {
      const imported = tr.dataset.imported || '';
      const matches = (!campaign || tr.dataset.campaign === campaign)
        && (!status || tr.dataset.status === status)
        && (!pool || tr.dataset.pool === pool)
        && (!claimedBy || (tr.dataset.claimedBy || '').includes(claimedBy))
        && (!importedFrom || imported >= importedFrom)
        && (!importedTo || imported <= importedTo);
      tr.dataset.matchesFilter = matches ? '1' : '0';
    });
    kvCurrentPage = 1;
    kvRenderPage();
  }

  // Slices the filter-matching rows down to the current page/page size and
  // shows only those -- everything else (non-matching, or matching but on
  // a different page) is hidden the same way the old filter-only code did.
  function kvRenderPage() {
    const rows = [...document.querySelectorAll('#pool-table tbody tr')];
    const matching = rows.filter(tr => tr.dataset.matchesFilter !== '0');
    const total = matching.length;
    const pageSize = kvPageSize === 'all' ? Math.max(total, 1) : kvPageSize;
    const totalPages = Math.max(1, Math.ceil(total / pageSize));
    if (kvCurrentPage > totalPages) kvCurrentPage = totalPages;
    const start = (kvCurrentPage - 1) * pageSize;
    const end = kvPageSize === 'all' ? total : start + pageSize;
    const shown = new Set(matching.slice(start, end));
    rows.forEach(tr => { tr.style.display = shown.has(tr) ? '' : 'none'; });

    document.getElementById('pool-table').style.display = total === 0 ? 'none' : '';
    document.getElementById('filter-empty-hint').style.display = total === 0 ? '' : 'none';
    document.getElementById('visible-key-count').textContent = kvShownTemplate.replace('%d', total);

    document.getElementById('pool-page-info').textContent = kvPageInfoTemplate.replace('%1$d', kvCurrentPage).replace('%2$d', totalPages);
    document.getElementById('pool-prev-btn').disabled = kvCurrentPage <= 1;
    document.getElementById('pool-next-btn').disabled = kvCurrentPage >= totalPages;
    document.getElementById('pool-pagination').style.display = 'flex';
  }

  function kvChangePage(delta) {
    kvCurrentPage += delta;
    kvRenderPage();
  }

  function kvChangePageSize() {
    const val = document.getElementById('pool-page-size').value;
    kvPageSize = val === 'all' ? 'all' : parseInt(val, 10);
    kvCurrentPage = 1;
    kvRenderPage();
  }

  // Guarded -- this whole toolbar/table only renders when there's at
  // least one key; skip on the empty-state page instead of erroring.
  if (document.getElementById('filter-campaign')) kvApplyKeyFilters();

  let kvSortColumn = null;
  let kvSortAsc = true;

  // Reorders the actual <tr> DOM nodes (not just what's displayed) --
  // kvRenderPage() always re-reads #pool-table tbody tr in DOM order, so
  // sorting here is enough for pagination/filtering to pick up the new
  // order automatically, no separate "sorted list" to keep in sync.
  function kvSortBy(column) {
    if (kvSortColumn === column) {
      kvSortAsc = !kvSortAsc;
    } else {
      kvSortColumn = column;
      kvSortAsc = true;
    }
    const tbody = document.querySelector('#pool-table tbody');
    const rows = [...tbody.querySelectorAll('tr')];
    // dataset property names are camelCase (data-sort-key -> sortKey), so
    // the column name's first letter needs capitalizing before appending
    // it to "sort" -- 'sort' + 'key' alone would look up "sortkey", which
    // doesn't exist and would silently sort everything as equal.
    const prop = 'sort' + column.charAt(0).toUpperCase() + column.slice(1);
    rows.sort((a, b) => {
      const av = a.dataset[prop] || '';
      const bv = b.dataset[prop] || '';
      return av.localeCompare(bv, undefined, { numeric: true, sensitivity: 'base' });
    });
    if (!kvSortAsc) rows.reverse();
    rows.forEach(tr => tbody.appendChild(tr));
    document.querySelectorAll('.kv-sort-indicator').forEach(el => {
      el.textContent = el.dataset.sortCol === kvSortColumn ? (kvSortAsc ? '▲' : '▼') : '';
    });
    kvCurrentPage = 1;
    kvRenderPage();
  }

  // Setting Status directly is the one bulk-set field that bypasses the
  // real claim/export flow instead of just relabeling the same key (unlike
  // Campaign or Release) -- a confirm() catches an accidental click before
  // it desyncs a key from what actually happened to it. Also the one place
  // left checking a selection was made, now that the three selects replaced
  // dedicated Release/Hold-back buttons that used to imply one.
  function kvConfirmBulkSet() {
    const checked = [...document.querySelectorAll('.key-row-check:checked')];
    if (checked.length === 0) {
      alert(<?= json_encode(I18n::t('keys_pool.js.select_key_first'), JSON_HEX_APOS | JSON_HEX_QUOT) ?>);
      return false;
    }
    const statusVal = document.getElementById('bulk-status-select').value;
    const confirmBulkStatusTemplate = <?= json_encode(I18n::t('keys_pool.js.confirm_bulk_status'), JSON_HEX_APOS | JSON_HEX_QUOT) ?>;
    if (statusVal && !confirm(confirmBulkStatusTemplate.replace('%s', statusVal).replace('%d', checked.length))) {
      return false;
    }
    // The reload this submission causes re-renders every checkbox
    // unchecked (nothing about a checked state survives a fresh page
    // load) -- remembering which ones were selected here and restoring
    // them after the reload keeps the selection instead of silently
    // dropping it, e.g. to apply a second bulk-set to the same keys.
    sessionStorage.setItem('kv-selected-keys', JSON.stringify(checked.map(cb => cb.value)));
    return true;
  }

  // A saved note renders as plain text (same as every other cell in this
  // table) with a small Unlock action next to it -- not an input box at
  // all until Unlock is clicked. This swaps that plain-text span out for
  // the same editable input+Lock markup the "never saved yet" state
  // already renders server-side, purely client-side, no server round-trip
  // for that half.
  function kvUnlockClaimedBy(btn) {
    const span = btn.closest('.kv-claimedby-locked');
    const keyId = span.dataset.keyId;
    const note = span.dataset.note;
    const pickOptions = document.querySelector('.kv-cb-pick') ? document.querySelector('.kv-cb-pick').innerHTML : '';
    const editor = document.createElement('div');
    editor.className = 'kv-claimedby-editor';
    editor.dataset.keyId = keyId;
    editor.style.cssText = 'display:flex; flex-direction:column; gap:4px;';
    editor.innerHTML = (pickOptions ? '<select class="kv-cb-pick" onchange="kvPickClaimedByCreator(this);" style="width:100%; background:var(--bg-panel); border:1px solid var(--line); color:var(--text-primary); border-radius:6px; padding:4px 6px; font-size:11px;">' + pickOptions + '</select>' : '')
      + '<span style="display:flex; gap:6px; align-items:center;">'
      + '<input type="text" class="kv-cb-note" placeholder="' + <?= json_encode(I18n::t('keys_pool.claimed_by_placeholder'), JSON_HEX_APOS | JSON_HEX_QUOT) ?> + '" maxlength="190" style="flex:1; min-width:0; background:var(--bg-void); border:1px solid var(--line); color:var(--text-primary); border-radius:6px; padding:5px 8px; font-size:12px;">'
      + '<button type="button" class="btn btn-ghost" style="padding:5px 10px; font-size:11px; flex-shrink:0;">' + <?= json_encode(I18n::t('keys_pool.lock_button'), JSON_HEX_APOS | JSON_HEX_QUOT) ?> + '</button>'
      + '</span>';
    const input = editor.querySelector('.kv-cb-note');
    input.value = note;
    editor.querySelector('button').addEventListener('click', () => kvSaveClaimedBy(editor));
    span.replaceWith(editor);
    input.focus();
    // Locking (a real save) reloads the page and shows its own flash from
    // the server -- Unlock never reloads, so without this it would be the
    // only one of the two that gives no feedback at all.
    kvShowFlash('success', <?= json_encode(I18n::t('keys_pool.js.unlocked_can_edit'), JSON_HEX_APOS | JSON_HEX_QUOT) ?>);
  }

  // This picker is a quick-fill convenience only, not a real link to the
  // users table -- it just copies a known user's name into the paired
  // free-text note input (claimed_by_note stays a plain string on the key,
  // unrelated to a real redemption's user_id).
  function kvPickClaimedByCreator(select) {
    const editor = select.closest('.kv-claimedby-editor');
    const input = editor.querySelector('.kv-cb-note');
    if (select.value) input.value = select.value;
  }

  // Fills in the shared #claimed-by-form (a sibling outside #pool-form,
  // since a <form> can't nest inside another) with which key and what
  // note, then submits that, causing the normal full-page POST reload
  // every other action here uses -- after which the field renders locked
  // (plain text) again if the note ended up non-empty, same check as the
  // first render.
  function kvSaveClaimedBy(editor) {
    document.getElementById('claimed-by-form-action').value = 'set_claimed_by';
    document.getElementById('claimed-by-key-id').value = editor.dataset.keyId;
    document.getElementById('claimed-by-note-value').value = editor.querySelector('.kv-cb-note').value;
    document.getElementById('claimed-by-form').submit();
  }

  // A real system redemption, unlike the manual note above, isn't just a
  // UI toggle to undo -- releasing it deletes the actual redemption
  // record and gives the creator's claim link that claim back against its
  // limit, so it confirms first (same pattern as bulk-setting Status).
  function kvReleaseRedemption(keyId) {
    if (!confirm(<?= json_encode(I18n::t('keys_pool.js.confirm_release_redemption'), JSON_HEX_APOS | JSON_HEX_QUOT) ?>)) {
      return;
    }
    document.getElementById('claimed-by-form-action').value = 'release_redemption';
    document.getElementById('claimed-by-key-id').value = keyId;
    document.getElementById('claimed-by-note-value').value = '';
    document.getElementById('claimed-by-form').submit();
  }

  function kvUpdateSelectedCount() {
    const count = document.querySelectorAll('.key-row-check:checked').length;
    document.getElementById('selected-key-count').textContent = kvSelectedTemplate.replace('%d', count);
  }

  // Only the rows the current filters leave visible -- otherwise "select
  // all" while filtered would silently also select rows you can't see.
  function kvToggleAllVisible(checked) {
    document.querySelectorAll('#pool-table tbody tr').forEach(tr => {
      if (tr.style.display === 'none') return;
      const cb = tr.querySelector('.key-row-check');
      if (cb) cb.checked = checked;
    });
    kvUpdateSelectedCount();
  }
  document.addEventListener('change', e => {
    if (e.target.classList.contains('key-row-check')) kvUpdateSelectedCount();
  });

  function kvExportSelected() {
    const ids = [...document.querySelectorAll('.key-row-check:checked')].map(c => c.value);
    if (ids.length === 0) {
      alert(<?= json_encode(I18n::t('keys_pool.js.select_key_first'), JSON_HEX_APOS | JSON_HEX_QUOT) ?>);
      return;
    }
    const poolForm = document.getElementById('pool-form');
    const formData = new FormData();
    formData.append('csrf_token', poolForm.querySelector('[name="csrf_token"]').value);
    formData.append('product_id', poolForm.querySelector('[name="product_id"]').value);
    formData.append('platform', poolForm.querySelector('[name="platform"]').value);
    ids.forEach(id => formData.append('key_ids[]', id));
    kvDoExport('keys_export.php', formData);
  }

  // Shared by the export flow (restored from sessionStorage after its
  // reload, below) and anything that needs to show a flash instantly with
  // no reload at all (Unlock, since it's purely client-side).
  function kvShowFlash(type, message) {
    const el = document.getElementById('export-flash');
    if (!el) return;
    const color = type === 'success' ? 'var(--signal-dim)' : 'var(--danger)';
    const textColor = type === 'success' ? 'var(--signal)' : 'var(--danger)';
    el.innerHTML = '<div class="panel flash-panel" style="border-color:' + color + ';"><span style="color:' + textColor + '; font-size:13px;"></span></div>';
    el.querySelector('span').textContent = message;
  }

  (function () {
    const raw = sessionStorage.getItem('kv-export-flash');
    if (!raw) return;
    sessionStorage.removeItem('kv-export-flash');
    const flash = JSON.parse(raw);
    kvShowFlash(flash.type, flash.message);
  })();

  // Restores the selection kvConfirmBulkSet() remembered right before the
  // submit that reloaded this page -- so applying a bulk-set doesn't
  // silently drop which keys were selected, e.g. to apply a second one
  // right after to the same keys.
  (function () {
    const raw = sessionStorage.getItem('kv-selected-keys');
    if (!raw) return;
    sessionStorage.removeItem('kv-selected-keys');
    const ids = JSON.parse(raw);
    if (!ids.length) return;
    const idSet = new Set(ids);
    document.querySelectorAll('.key-row-check').forEach(cb => {
      if (idSet.has(cb.value)) cb.checked = true;
    });
    kvUpdateSelectedCount();
  })();
</script>

<?php require __DIR__ . '/includes/layout_footer.php'; ?>
