<?php
// /elms/admin/courses/game_save.php
declare(strict_types=1);

if (session_status() === PHP_SESSION_NONE) session_start();

require_once __DIR__ . '/../../includes/db.php';

$mysqli = $mysqli ?? ($conn ?? null);
if (!$mysqli instanceof mysqli) {
  http_response_code(500);
  echo json_encode(['ok'=>false,'message'=>'Database connection not found']);
  exit;
}
$mysqli->set_charset('utf8mb4');

function gs_esc($s): string {
  return htmlspecialchars((string)$s, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8');
}
function gs_t_exists(mysqli $db, string $t): bool {
  $st = $db->prepare("SELECT 1 FROM INFORMATION_SCHEMA.TABLES WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME=? LIMIT 1");
  $st->bind_param('s', $t);
  $st->execute();
  $ok = (bool)$st->get_result()->fetch_row();
  $st->close();
  return $ok;
}
function gs_c_exists(mysqli $db, string $t, string $c): bool {
  $st = $db->prepare("SELECT 1 FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME=? AND COLUMN_NAME=? LIMIT 1");
  $st->bind_param('ss', $t, $c);
  $st->execute();
  $ok = (bool)$st->get_result()->fetch_row();
  $st->close();
  return $ok;
}
function gs_first_col(mysqli $db, string $t, array $cols): ?string {
  foreach ($cols as $c) if (gs_c_exists($db, $t, $c)) return $c;
  return null;
}
function gs_detect_content_table(mysqli $db): array {
  foreach (['lms_content','lms_contents','lms_module_contents','module_contents','lms_content_items'] as $T) {
    if (!gs_t_exists($db, $T)) continue;
    $mid   = gs_first_col($db, $T, ['module_id','lms_module_id','unit_id']);
    $title = gs_first_col($db, $T, ['title','name']);
    $url   = gs_first_col($db, $T, ['source_path_or_url','url','file_url','path','link']);
    $type  = gs_first_col($db, $T, ['type','content_type']);
    $desc  = gs_first_col($db, $T, ['description','details','desc']);
    $rel   = gs_first_col($db, $T, ['is_released','active','published']);
    if ($mid && $title && $url) return compact('T','mid','title','url','type','desc','rel');
  }
  return ['T'=>null];
}
function gs_slugify(string $s): string {
  $s = preg_replace('~[^\pL0-9]+~u', '-', $s);
  $s = trim((string)$s, '-');
  if (function_exists('iconv')) $s = (string)@iconv('UTF-8', 'ASCII//TRANSLIT', $s);
  $s = strtolower((string)$s);
  $s = preg_replace('~[^-a-z0-9]+~', '', $s);
  return $s ?: ('game-' . date('YmdHis'));
}
function gs_ensure_batch_module_id(mysqli $db, int $courseModuleId, int $courseId): int {
  /*
   * Do NOT auto insert into batch_modules here.
   * Game save should only save content/game item.
   * Academic module mapping must be managed from batch/module management pages.
   */
  return $courseModuleId;
}
  
function gs_build_runner_html(string $title, string $type, array $data): string {
  $safe = gs_esc($title);
  $json_b64 = base64_encode(json_encode(['type'=>$type,'data'=>$data], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES));

  return <<<HTML
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>{$safe} · EDEN CAMPUS</title>
<style>
*{box-sizing:border-box}
body{margin:0;font-family:Inter,Arial,sans-serif;background:#f3f7ef;color:#102015}
.shell{max-width:1080px;margin:18px auto;padding:0 14px}
.header{background:linear-gradient(135deg,#256b16,#72b51b);color:white;border-radius:22px;padding:18px 20px;display:flex;justify-content:space-between;align-items:center;gap:14px}
.header h1{margin:0;font-size:24px;font-weight:900}
.meta{display:flex;gap:10px;flex-wrap:wrap}
.pill{background:rgba(255,255,255,.18);border:1px solid rgba(255,255,255,.35);border-radius:999px;padding:8px 12px;font-weight:850}
.card{background:white;border-radius:22px;margin-top:16px;padding:18px;box-shadow:0 14px 35px rgba(15,23,42,.08);border:1px solid #e5e7eb}
.progress{height:10px;background:#e5e7eb;border-radius:999px;overflow:hidden;margin-bottom:16px}
.bar{height:100%;background:#2563eb;width:0%;transition:.25s}
.q{font-size:22px;font-weight:900;margin:0 0 14px}
.grid2{display:grid;grid-template-columns:1fr 1fr;gap:12px}
.choice,.chip,.item,.dropzone{border:2px solid #d9e7d0;background:#fbfff8;border-radius:16px;padding:14px;margin:9px 0;font-weight:850;cursor:pointer}
.choice.sel,.chip.sel{border-color:#2563eb;background:#dbeafe}
.ok{border-color:#16a34a!important;background:#dcfce7!important}
.bad{border-color:#ef4444!important;background:#fee2e2!important}
.btn{border:0;border-radius:14px;padding:12px 18px;font-weight:900;cursor:pointer}
.btn-primary{background:#2563eb;color:white}
.btn-soft{background:#ecfccb;color:#365314;border:1px solid #bef264}
.actions{display:flex;justify-content:space-between;gap:10px;align-items:center;margin-top:16px;flex-wrap:wrap}
.blank{border:2px solid #95c46b;border-radius:12px;padding:8px 10px;min-width:120px;font-weight:800}
.match-wrap{display:grid;grid-template-columns:1fr 1fr;gap:16px}
.drag-card{border:2px solid #bfdbfe;background:#eff6ff;border-radius:14px;padding:12px;margin:8px 0;font-weight:850;cursor:grab}
.dropzone{min-height:52px;background:#fff}
.dropzone.hover{border-color:#2563eb;background:#dbeafe}
.order-box .item{cursor:grab}
.ws{display:grid;gap:4px;justify-content:center}
.ws-cell{width:38px;height:38px;border:1px solid #94a3b8;border-radius:8px;display:grid;place-items:center;font-weight:900;cursor:pointer;background:#fff}
.ws-cell.sel{background:#bfdbfe}
.mem{display:grid;grid-template-columns:repeat(auto-fill,minmax(90px,1fr));gap:10px}
.mem-card{height:90px;border-radius:16px;background:#eef6ff;border:1px solid #bfdbfe;display:grid;place-items:center;font-weight:900;font-size:18px;cursor:pointer}
.mem-card.matched{background:#dcfce7}
.cw{display:grid;gap:4px;justify-content:center}
.cw input{width:42px;height:42px;text-align:center;font-size:22px;font-weight:900;text-transform:uppercase;border:2px solid #111827;border-radius:7px}
.clues{margin-top:15px;background:#f8fafc;border-radius:14px;padding:12px}
.overlay{position:fixed;inset:0;background:rgba(15,23,42,.72);display:none;align-items:center;justify-content:center;z-index:99}
.modal{background:white;border-radius:24px;padding:24px;max-width:440px;text-align:center}
.modal h2{margin-top:0}
@media(max-width:760px){.grid2,.match-wrap{grid-template-columns:1fr}.header{display:block}.meta{margin-top:12px}.q{font-size:19px}}
</style>
</head>
<body>
<div class="shell">
  <div class="header">
    <h1>{$safe}</h1>
    <div class="meta">
      <div class="pill">⏱ <span id="timer">∞</span></div>
      <div class="pill">Score <span id="score">0</span>/<span id="total">0</span></div>
    </div>
  </div>

  <div class="card">
    <div class="progress"><div class="bar" id="bar"></div></div>
    <div id="app"></div>

    <div class="actions">
      <button class="btn btn-soft" id="resetBtn">Try Again</button>
      <div>
        <button class="btn btn-primary" id="checkBtn">Check</button>
        <button class="btn btn-primary" id="nextBtn" style="display:none">Next</button>
      </div>
    </div>
    <p id="result" style="font-weight:900"></p>
  </div>
</div>

<div class="overlay" id="overlay">
  <div class="modal">
    <h2 id="modalTitle">Result</h2>
    <p id="modalText"></p>
    <button class="btn btn-primary" onclick="location.reload()">Try Again</button>
  </div>
</div>

<script>
var GD={type:'mcq',data:{}};try{GD=JSON.parse(atob("{$json_b64}"))||GD;}catch(e){}
const type=String(GD.type||'mcq').toLowerCase();
const data=GD.data||{};
const app=document.getElementById('app');
const scoreEl=document.getElementById('score');
const totalEl=document.getElementById('total');
const checkBtn=document.getElementById('checkBtn');
const nextBtn=document.getElementById('nextBtn');
const resetBtn=document.getElementById('resetBtn');
const timerEl=document.getElementById('timer');
const bar=document.getElementById('bar');

let score=0,total=0,index=0,locked=false,finished=false,start=Date.now();
let state={};

function esc(s){return String(s??'').replace(/[&<>"']/g,m=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[m]));}
function shuffle(a){return a.slice().sort(()=>Math.random()-.5);}
function setScore(){scoreEl.textContent=score;totalEl.textContent=total;}
function setProgress(p){bar.style.width=Math.max(0,Math.min(100,p))+'%';}

function saveAttempt(status){
  if(total<=0)return;
  const fd=new FormData();
  fd.append('game_url',location.pathname);
  fd.append('score',score);
  fd.append('total',total);
  fd.append('time_sec',Math.floor((Date.now()-start)/1000));
  fd.append('status',status);
  fetch('/elms/student/game_score_save.php',{method:'POST',body:fd}).catch(()=>{});
}

function finish(status='completed'){
  if(finished)return;
  finished=true;
  locked=true;
  checkBtn.disabled=true;
  nextBtn.style.display='none';
  saveAttempt(status);

  document.getElementById('overlay').style.display='flex';
  document.getElementById('modalTitle').textContent = status==='timeout' ? 'Time is over' : 'Game Completed';
  document.getElementById('modalText').textContent = status==='timeout'
    ? 'Time is over. Try again.'
    : 'Score: '+score+' / '+total;
}

function startTimer(){
  const duration=parseInt(data.duration||0,10)||0;
  if(duration<=0){timerEl.textContent='∞';return;}
  let t=duration;
  timerEl.textContent=t+'s';
  const iv=setInterval(()=>{
    if(finished){clearInterval(iv);return;}
    t--;
    timerEl.textContent=t+'s';
    if(t<=0){
      clearInterval(iv);
      autoSubmit();
    }
  },1000);
}

function autoSubmit(){
  if(finished)return;
  if(typeof window.currentAutoCheck === 'function') {
    window.currentAutoCheck();
  }
  finish('timeout');
}

function renderMCQ(){
  const qs=Array.isArray(data.questions)?data.questions:[];
  total=qs.length; setScore();

  function draw(){
    locked=false;
    checkBtn.disabled=false;
    checkBtn.style.display='inline-block';
    nextBtn.style.display='none';
    state={};
    const q=qs[index];
    if(!q){finish();return;}

    app.innerHTML =
      '<div class="q">Question '+(index+1)+' / '+qs.length+'<br>'+esc(q.q)+'</div>' +
      (q.img?'<img src="'+esc(q.img)+'" style="max-width:180px;border-radius:14px;margin-bottom:12px">':'') +
      '<div class="grid2">'+(q.options||[]).map((o,i)=>'<div class="choice" data-i="'+i+'">'+esc(o)+'</div>').join('')+'</div>';

    app.querySelectorAll('.choice').forEach(c=>{
      c.onclick=()=>{
        if(locked)return;
        app.querySelectorAll('.choice').forEach(x=>x.classList.remove('sel'));
        c.classList.add('sel');
        state.sel=parseInt(c.dataset.i,10);
      };
    });
    setProgress((index/Math.max(1,qs.length))*100);
  }

  function doCheck(){
    if(locked)return;
    const q=qs[index];
    const ans=parseInt(q.answer,10);
    app.querySelectorAll('.choice').forEach(c=>{
      const i=parseInt(c.dataset.i,10);
      if(i===ans)c.classList.add('ok');
      if(i===state.sel && i!==ans)c.classList.add('bad');
    });
    if(state.sel===ans)score++;
    setScore();
    locked=true;
    checkBtn.style.display='none';
    nextBtn.style.display='inline-block';
  }

  checkBtn.onclick=doCheck;
  window.currentAutoCheck=()=>{if(!locked)doCheck();};
  nextBtn.onclick=()=>{
    index++;
    if(index>=qs.length){setProgress(100);finish();}
    else draw();
  };
  draw();
}

function renderTF(){
  const qs=Array.isArray(data.questions)?data.questions:[];
  data.questions=qs.map(x=>({q:x.q,options:['True','False'],answer:x.answer?0:1}));
  renderMCQ();
}

function renderMatch(){
  const pairs=Array.isArray(data.pairs)?data.pairs:[];
  total=pairs.length; setScore();
  const rights=shuffle(pairs.map(p=>p.right));

  app.innerHTML =
    '<div class="q">Drag the correct answer to each item</div>'+
    '<div class="match-wrap">'+
      '<div>'+pairs.map((p,i)=>'<div class="chip">'+esc(p.left)+'</div><div class="dropzone" data-want="'+esc(p.right)+'">Drop answer here</div>').join('')+'</div>'+
      '<div id="dragPool">'+rights.map(r=>'<div class="drag-card" draggable="true" data-val="'+esc(r)+'">'+esc(r)+'</div>').join('')+'</div>'+
    '</div>';

  let dragging=null;
  app.querySelectorAll('.drag-card').forEach(card=>{
    card.addEventListener('dragstart',()=>dragging=card);
  });
  app.querySelectorAll('.dropzone').forEach(zone=>{
    zone.addEventListener('dragover',e=>{e.preventDefault();zone.classList.add('hover');});
    zone.addEventListener('dragleave',()=>zone.classList.remove('hover'));
    zone.addEventListener('drop',e=>{
      e.preventDefault();
      zone.classList.remove('hover');
      if(!dragging)return;
      if(zone.querySelector('.drag-card')){
        document.getElementById('dragPool').appendChild(zone.querySelector('.drag-card'));
      }
      zone.textContent='';
      zone.appendChild(dragging);
      dragging=null;
    });
  });

  function doCheck(){
    if(finished)return;
    score=0;
    app.querySelectorAll('.dropzone').forEach(z=>{
      const got=z.querySelector('.drag-card')?.dataset.val || '';
      if(got===z.dataset.want){score++;z.classList.add('ok');}
      else z.classList.add('bad');
    });
    setScore();
    setProgress(100);
  }

  checkBtn.onclick=()=>{doCheck();finish();};
  window.currentAutoCheck=doCheck;
}

function renderFill(){
  const text=String(data.text||'');
  total=(text.match(/\[\[/g)||[]).length; setScore();

  app.innerHTML='<div class="q">Fill the blanks</div><div style="line-height:2.4;font-size:18px">'+
    text.replace(/\[\[(.+?)\]\]/g,(_,a)=>'<input class="blank" data-ans="'+esc(a.trim())+'">')+
    '</div>';

  function doCheck(){
    score=0;
    app.querySelectorAll('.blank').forEach(i=>{
      if(i.value.trim().toLowerCase()===i.dataset.ans.toLowerCase()){score++;i.classList.add('ok');}
      else i.classList.add('bad');
    });
    setScore(); setProgress(100);
  }

  checkBtn.onclick=()=>{doCheck();finish();};
  window.currentAutoCheck=doCheck;
}

function renderWord(){
  const text=String(data.text||'');
  const answers=[];
  total=(text.match(/\[\[/g)||[]).length; setScore();

  const html=text.replace(/\[\[(.+?)\]\]/g,(_,a)=>{
    answers.push(a.trim());
    return '<span class="chip blankdrop" data-ans="'+esc(a.trim())+'">______</span>';
  });

  const bank=shuffle(Array.isArray(data.bank)&&data.bank.length?data.bank:answers);

  app.innerHTML='<div class="q">Choose words from bank</div><div style="line-height:2.6;font-size:18px">'+html+'</div><hr>'+
    bank.map(w=>'<span class="chip bank">'+esc(w)+'</span>').join('');

  let selected='';
  app.querySelectorAll('.bank').forEach(b=>b.onclick=()=>{
    selected=b.textContent;
    app.querySelectorAll('.bank').forEach(x=>x.classList.remove('sel'));
    b.classList.add('sel');
  });
  app.querySelectorAll('.blankdrop').forEach(b=>b.onclick=()=>{
    if(selected){b.textContent=selected;b.dataset.got=selected;}
  });

  function doCheck(){
    score=0;
    app.querySelectorAll('.blankdrop').forEach(b=>{
      if((b.dataset.got||'').toLowerCase()===b.dataset.ans.toLowerCase()){score++;b.classList.add('ok');}
      else b.classList.add('bad');
    });
    setScore(); setProgress(100);
  }

  checkBtn.onclick=()=>{doCheck();finish();};
  window.currentAutoCheck=doCheck;
}

function renderOrder(){
  const items=Array.isArray(data.items)?data.items:[];
  total=items.length; setScore();

  app.innerHTML='<div class="q">Drag items into correct order</div><div id="orderBox" class="order-box">'+
    shuffle(items).map(x=>'<div class="item" draggable="true">'+esc(x)+'</div>').join('')+
    '</div>';

  let drag=null,box=document.getElementById('orderBox');
  box.addEventListener('dragstart',e=>drag=e.target);
  box.addEventListener('dragover',e=>{
    e.preventDefault();
    const it=e.target.closest('.item');
    if(!it||!drag)return;
    const r=it.getBoundingClientRect();
    box.insertBefore(drag,(e.clientY-r.top)>r.height/2?it.nextSibling:it);
  });

  function doCheck(){
    score=0;
    [...box.children].forEach((n,i)=>{
      if(n.textContent.trim()===items[i]){score++;n.classList.add('ok');}
      else n.classList.add('bad');
    });
    setScore(); setProgress(100);
  }

  checkBtn.onclick=()=>{doCheck();finish();};
  window.currentAutoCheck=doCheck;
}

function renderMemory(){
  const pairs=Array.isArray(data.pairs)?data.pairs:[];
  total=pairs.length; setScore();
  const cards=shuffle(pairs.flatMap((p,i)=>[{id:i,t:p},{id:i,t:p}]));
  let open=[],matched={};

  app.innerHTML='<div class="q">Find matching cards</div><div class="mem">'+
    cards.map((c,i)=>'<div class="mem-card" data-i="'+i+'">?</div>').join('')+
    '</div>';

  checkBtn.style.display='none';

  app.querySelectorAll('.mem-card').forEach(card=>card.onclick=()=>{
    const i=parseInt(card.dataset.i,10);
    if(matched[i]||open.includes(i)||finished)return;
    card.textContent=cards[i].t;
    open.push(i);

    if(open.length===2){
      const [a,b]=open;
      if(cards[a].id===cards[b].id){
        matched[a]=matched[b]=true;
        score++; setScore();
        app.querySelectorAll('.mem-card')[a].classList.add('matched');
        app.querySelectorAll('.mem-card')[b].classList.add('matched');
        setProgress((score/Math.max(1,total))*100);
        if(score>=total)finish();
      }
      setTimeout(()=>{
        open=[];
        app.querySelectorAll('.mem-card').forEach((x,j)=>{if(!matched[j])x.textContent='?'});
      },700);
    }
  });

  window.currentAutoCheck=()=>{};
}

function renderWordSearch(){
  const words=Array.isArray(data.words)?data.words.map(w=>String(w).toUpperCase()):[];
  total=words.length; setScore();

  const size=Math.max(8,...words.map(w=>w.length));
  const grid=Array.from({length:size},()=>Array.from({length:size},()=>String.fromCharCode(65+Math.floor(Math.random()*26))));
  words.forEach((w,r)=>{if(r<size){for(let c=0;c<w.length&&c<size;c++)grid[r][c]=w[c];}});

  app.innerHTML='<div class="q">Find words: '+words.join(', ')+'</div><div class="ws" style="grid-template-columns:repeat('+size+',38px)">'+
    grid.flat().map(ch=>'<div class="ws-cell">'+ch+'</div>').join('')+
    '</div>';

  app.querySelectorAll('.ws-cell').forEach(c=>c.onclick=()=>c.classList.toggle('sel'));

  function doCheck(){
    score=0;
    const selected=[...app.querySelectorAll('.ws-cell.sel')].map(x=>x.textContent).join('');
    words.forEach(w=>{if(selected.includes(w))score++;});
    setScore(); setProgress(100);
  }

  checkBtn.onclick=()=>{doCheck();finish();};
  window.currentAutoCheck=doCheck;
}

function renderCrossword(){
  const entries=Array.isArray(data.entries)?data.entries:[];
  const words=entries.map(e=>String(e.word||'').toUpperCase()).filter(Boolean);
  total=words.join('').length; setScore();

  const longest=Math.max(5,...words.map(w=>w.length));
  app.innerHTML='<div class="q">Crossword</div><div class="cw" style="grid-template-columns:repeat('+longest+',42px)">'+
    words.map(w=>Array.from({length:longest},(_,ci)=>ci<w.length?'<input maxlength="1" data-a="'+esc(w[ci])+'">':'<span></span>').join('')).join('')+
    '</div><div class="clues"><b>Clues</b><ol>'+entries.map(e=>'<li>'+esc(e.clue)+'</li>').join('')+'</ol></div>';

  function doCheck(){
    score=0;
    app.querySelectorAll('.cw input').forEach(i=>{
      if(i.value.toUpperCase()===i.dataset.a){score++;i.classList.add('ok');}
      else i.classList.add('bad');
    });
    setScore(); setProgress(100);
  }

  checkBtn.onclick=()=>{doCheck();finish();};
  window.currentAutoCheck=doCheck;
}

const map={
  mcq:renderMCQ,
  truefalse:renderTF,
  match:renderMatch,
  fill:renderFill,
  word:renderWord,
  order:renderOrder,
  memory:renderMemory,
  wordsearch:renderWordSearch,
  crossword:renderCrossword
};

(map[type]||renderMCQ)();
resetBtn.onclick=()=>location.reload();
startTimer();
</script>
</body>
</html>
HTML;
}

$preview = isset($_POST['preview_only']) && $_POST['preview_only'] === '1';

$course_id = (int)($_POST['course_id'] ?? 0);
$module_id = (int)($_POST['module_id'] ?? 0);
$title = trim((string)($_POST['title'] ?? 'Untitled Game'));
$type = (string)($_POST['game_type'] ?? 'mcq');
$duration = (int)($_POST['duration'] ?? 0);
$payloadRaw = (string)($_POST['payload'] ?? '{}');

$allowed = ['fill','match','order','word','mcq','truefalse','draglabel','wordsearch','memory','crossword'];

$data = json_decode($payloadRaw, true);
if (!is_array($data)) $data = [];
$data['duration'] = max(0, $duration);

if (!in_array($type, $allowed, true)) $type = 'mcq';
if ($title === '') $title = 'Untitled Game';

$html = gs_build_runner_html($title, $type, $data);

if ($preview) {
  header('Content-Type: text/html; charset=utf-8');
  echo $html;
  exit;
}

header('Content-Type: application/json; charset=utf-8');

if ($course_id <= 0 || $module_id <= 0) {
  echo json_encode(['ok'=>false,'message'=>'Course and module are required']);
  exit;
}

$root = realpath(dirname(__DIR__, 2)); // /elms
$gameDir = $root . '/uploads/games';

if (!is_dir($gameDir) && !@mkdir($gameDir, 0775, true)) {
  echo json_encode(['ok'=>false,'message'=>'Cannot create uploads/games folder']);
  exit;
}
if (!is_writable($gameDir)) {
  echo json_encode(['ok'=>false,'message'=>'uploads/games folder is not writable']);
  exit;
}

$loadedSlug = trim((string)($_POST['loaded_slug'] ?? ''));
$overwrite = (string)($_POST['overwrite'] ?? '0') === '1';
$slug = ($overwrite && $loadedSlug !== '') ? $loadedSlug : gs_slugify($title);

$filePath = $gameDir . '/' . $slug . '.html';
if (file_put_contents($filePath, $html) === false) {
  echo json_encode(['ok'=>false,'message'=>'Failed to write game file']);
  exit;
}

$gameUrl = '/elms/uploads/games/' . basename($filePath);
$saveModuleId = gs_ensure_batch_module_id($mysqli, $module_id, $course_id);

$CT = gs_detect_content_table($mysqli);
if (!empty($CT['T'])) {
  $mid = $CT['mid'];
  $tt = $CT['title'];
  $uu = $CT['url'];
  $descCol = $CT['desc'] ?? null;
  $typeCol = $CT['type'] ?? null;
  $relCol = $CT['rel'] ?? null;

  $courseCol = null;
  foreach (['course_id','lms_course_id'] as $cc) {
    if (gs_c_exists($mysqli, $CT['T'], $cc)) {
      $courseCol = $cc;
      break;
    }
  }

  $sets = "`$tt`=?, `$uu`=?";
  $types = 'ss';
  $vals = [$title, $gameUrl];

  if ($descCol) { $sets .= ", `$descCol`=?"; $types .= 's'; $vals[] = ''; }
  if ($typeCol) { $sets .= ", `$typeCol`=?"; $types .= 's'; $vals[] = 'game'; }
  if ($relCol) { $sets .= ", `$relCol`=?"; $types .= 'i'; $vals[] = 1; }

  $sqlU = "UPDATE `{$CT['T']}` SET $sets WHERE `$mid`=? AND `$uu`=?";
  $stU = $mysqli->prepare($sqlU);
  if ($stU) {
    $typesU = $types . 'is';
    $valsU = array_merge($vals, [$saveModuleId, $gameUrl]);
    $stU->bind_param($typesU, ...$valsU);
    $stU->execute();
    $aff = $stU->affected_rows;
    $stU->close();
  } else {
    $aff = 0;
  }

  if (empty($aff)) {
    $cols = [$mid => $saveModuleId, $tt => $title, $uu => $gameUrl];

    if ($courseCol) $cols[$courseCol] = $course_id;
    if ($descCol) $cols[$descCol] = '';
    if ($typeCol) $cols[$typeCol] = 'game';
    if ($relCol) $cols[$relCol] = 1;

    $fields = implode(',', array_map(fn($k) => "`$k`", array_keys($cols)));
    $place = implode(',', array_fill(0, count($cols), '?'));
    $typesI = '';
    $valsI = [];
    foreach ($cols as $v) {
      $typesI .= is_int($v) ? 'i' : 's';
      $valsI[] = $v;
    }

    $st = $mysqli->prepare("INSERT INTO `{$CT['T']}` ($fields) VALUES ($place)");
    if ($st) {
      $st->bind_param($typesI, ...$valsI);
      $st->execute();
      $st->close();
    }
  }
}

echo json_encode(['ok'=>true,'url'=>$gameUrl]);
