{% extends "base.html" %}
{% block title %}Models — CoderAI{% endblock %}

{% block head %}
<style>
/* chips */
.chip{display:inline-flex;align-items:center;padding:.2rem .55rem;border-radius:20px;cursor:pointer;background:var(--raised);border:1px solid var(--border);font-size:11px;font-weight:500;user-select:none;white-space:nowrap;transition:background .1s,border-color .1s,color .1s}
.chip:hover{border-color:var(--border-2)}
.chip.on{background:var(--accent-s);border-color:rgba(99,102,241,.35);color:#A5B4FC}
.chip-row{display:flex;flex-wrap:wrap;gap:.3rem;align-items:center}
/* gguf 3-way toggle */
.tog-grp{display:inline-flex;border:1px solid var(--border);border-radius:6px;overflow:hidden}
.tog-btn{border:none!important;border-radius:0!important;background:var(--raised)!important;color:var(--text-2)!important;font-size:11px!important;padding:.25rem .65rem!important;cursor:pointer;font-weight:500;transition:all .1s}
.tog-btn:hover{color:var(--text)!important}
.tog-btn.on{background:var(--accent-s)!important;color:#A5B4FC!important}
.tog-btn+.tog-btn{border-left:1px solid var(--border)!important}
/* filter label */
.fl{font-size:10px;font-weight:700;text-transform:uppercase;letter-spacing:.06em;color:var(--text-2);white-space:nowrap}
/* info drawer */
#info-overlay{display:none;position:fixed;inset:0;background:rgba(0,0,0,.45);z-index:200}
#info-drawer{position:fixed;top:0;right:0;width:540px;max-width:96vw;height:100vh;background:var(--bg);border-left:1px solid var(--border);z-index:201;overflow-y:auto;transform:translateX(100%);transition:transform .22s cubic-bezier(.4,0,.2,1)}
#info-drawer.open{transform:translateX(0)}
#info-sticky{position:sticky;top:0;background:var(--bg);border-bottom:1px solid var(--border);z-index:1;padding:1rem 1.25rem;display:flex;align-items:center;gap:.75rem}
#info-title{font-weight:600;font-size:14px;flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
</style>
{% endblock %}

{% block content %}
<div class="page-header">
  <div>
    <h1>Models</h1>
    <p>Configure and download AI models</p>
  </div>
  <div class="header-actions">
    <button class="btn btn-secondary" onclick="openModal('upload-modal')">Upload GGUF</button>
    <button class="btn btn-primary" onclick="openModal('dl-modal')">Download model</button>
  </div>
</div>

<div class="tabs">
  <button class="tab active" onclick="switchTab('local', this)">Local models</button>
  <button class="tab" onclick="switchTab('search', this)">Find on HuggingFace</button>
</div>

<!-- active downloads strip (all tabs) -->
<div id="dl-strip" style="display:none;background:var(--raised);border:1px solid var(--border);border-radius:8px;margin-bottom:1rem;padding:.5rem .875rem">
  <div style="font-size:10px;font-weight:700;text-transform:uppercase;letter-spacing:.06em;color:var(--text-2);margin-bottom:.4rem">Active downloads</div>
  <div id="dl-strip-list"></div>
</div>

<!-- LOCAL -->
<div id="tab-local" class="tab-panel active">

  <!-- cache stats -->
  <div class="card">
    <div style="display:flex;align-items:center;justify-content:space-between;flex-wrap:wrap;gap:.5rem;margin-bottom:1rem">
      <div class="card-title" style="margin:0">Storage</div>
      <div style="display:flex;gap:.35rem;flex-wrap:wrap">
        <button class="btn btn-ghost btn-sm" onclick="refreshLocal()">↺ Refresh</button>
        <button class="btn btn-danger btn-sm" onclick="clearCacheConfirm('hf')">Clear HF</button>
        <button class="btn btn-danger btn-sm" onclick="clearCacheConfirm('gguf')">Clear GGUF</button>
        <button class="btn btn-danger btn-sm" onclick="clearCacheConfirm('all')">Clear all</button>
      </div>
    </div>
    <div style="display:grid;grid-template-columns:repeat(3,1fr);gap:.75rem" id="cache-stats-row">
      <div style="background:var(--bg);border:1px solid var(--border);border-radius:6px;padding:.75rem 1rem">
        <div class="fl">HuggingFace</div>
        <div id="stat-hf-size" style="font-size:1.2rem;font-weight:600;margin:.3rem 0">—</div>
        <div id="stat-hf-count" class="muted small">— models</div>
        <div id="stat-hf-free" class="muted small" style="margin-top:.25rem">— free</div>
      </div>
      <div style="background:var(--bg);border:1px solid var(--border);border-radius:6px;padding:.75rem 1rem">
        <div class="fl">GGUF cache</div>
        <div id="stat-gguf-size" style="font-size:1.2rem;font-weight:600;margin:.3rem 0">—</div>
        <div id="stat-gguf-count" class="muted small">— files</div>
        <div id="stat-gguf-free" class="muted small" style="margin-top:.25rem">— free</div>
      </div>
      <div style="background:var(--bg);border:1px solid var(--border);border-radius:6px;padding:.75rem 1rem">
        <div class="fl">Total used</div>
        <div id="stat-total-size" style="font-size:1.2rem;font-weight:600;margin:.3rem 0">—</div>
        <div class="muted small">on disk</div>
      </div>
    </div>
  </div>

  <!-- HF models -->
  <div class="card">
    <div class="card-title">HuggingFace models <span id="hf-model-badge" class="muted small"></span></div>
    <div id="hf-models-list"><span class="muted small">Loading…</span></div>
  </div>

  <!-- GGUF files -->
  <div class="card mb-0">
    <div class="card-title">GGUF files <span id="gguf-file-badge" class="muted small"></span></div>
    <div id="gguf-models-list"><span class="muted small">Loading…</span></div>
  </div>

  <div class="card mb-0" style="margin-top:1rem" id="ws-model-builder">
    <div class="card-title">Whisper-server simulated models</div>
    <p class="muted small" style="margin-top:0">Create local audio models backed by dedicated whisper-server subprocess configurations.</p>
    <div style="display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:.75rem">
      <input id="ws-model-id" class="form-input" placeholder="whisper-vulkan-base">
      <input id="ws-server-path" class="form-input" placeholder="/usr/local/bin/whisper-server">
      <select id="ws-model-source" class="form-input" onchange="toggleWhisperModelSource()">
        <option value="cached-gguf" selected>Downloaded GGUF</option>
        <option value="manual-path">Manual path</option>
      </select>
      <select id="ws-gguf-select" class="form-input">
        <option value="">Select downloaded GGUF</option>
      </select>
      <input id="ws-model-path" class="form-input" placeholder="Manual path: /models/ggml-base.bin" style="display:none">
      <input id="ws-port" class="form-input" type="number" value="8744" min="1" max="65535">
      <input id="ws-gpu-device" class="form-input" type="number" value="0" min="0">
      <select id="ws-load-mode" class="form-input">
        <option value="on-request">On request</option>
        <option value="load">Load</option>
      </select>
    </div>
    <div style="display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:.75rem;margin-top:.75rem">
      <input id="ws-used-vram" class="form-input" type="number" min="0" step="0.1" placeholder="Used VRAM (optional)">
      <input id="ws-alias" class="form-input" placeholder="Alias (optional, e.g. whisper)">
    </div>
    <div class="form-actions" style="margin-top:.75rem">
      <button class="btn btn-primary" id="ws-submit-btn" onclick="addWhisperServerModel()">Add model</button>
    </div>
  </div>
</div>

<!-- SEARCH -->
<div id="tab-search" class="tab-panel">
  <div class="card">
    <div class="card-title">Search HuggingFace</div>

    <!-- query row -->
    <div class="search-bar" style="margin-bottom:.75rem">
      <input type="text" id="search-q" class="form-input" placeholder="Search models (e.g. llama, mistral, qwen…)">
      <button class="btn btn-secondary" onclick="doSearch()">Search</button>
    </div>

    <!-- filter row 1: toggles + dropdowns -->
    <div style="display:flex;flex-wrap:wrap;gap:.625rem;align-items:center;margin-bottom:.625rem">
      <div style="display:flex;align-items:center;gap:.35rem">
        <span class="fl">Format</span>
        <div class="tog-grp">
          <button class="tog-btn on" data-val="gguf">GGUF</button>
          <button class="tog-btn" data-val="all">All</button>
          <button class="tog-btn" data-val="no-gguf">No GGUF</button>
        </div>
      </div>
      <div style="display:flex;align-items:center;gap:.35rem">
        <span class="fl">Type</span>
        <select id="filter-pipeline" class="form-input" style="padding:.25rem .4rem;font-size:12px">
          <option value="">All</option>
          <option value="text-generation">Text generation</option>
          <option value="text2text-generation">Text-to-text</option>
          <option value="text-to-image">Text-to-image</option>
          <option value="image-to-text">Image-to-text</option>
          <option value="automatic-speech-recognition">Speech recog.</option>
          <option value="text-to-speech">TTS</option>
          <option value="text-to-video">Text-to-video</option>
          <option value="image-to-video">Image-to-video</option>
          <option value="feature-extraction">Embeddings</option>
        </select>
      </div>
      <div style="display:flex;align-items:center;gap:.35rem">
        <span class="fl">Arch</span>
        <select id="filter-arch" class="form-input" style="padding:.25rem .4rem;font-size:12px">
          <option value="">Any</option>
          <option value="moe">MoE</option>
          <option value="lora">LoRA</option>
        </select>
      </div>
      <div style="display:flex;align-items:center;gap:.35rem">
        <span class="fl">Sort</span>
        <select id="filter-sort" class="form-input" style="padding:.25rem .4rem;font-size:12px">
          <option value="downloads">Most downloaded</option>
          <option value="likes">Most liked</option>
          <option value="lastModified">Recently updated</option>
        </select>
      </div>
    </div>

    <!-- filter row 2: size chips -->
    <div style="display:flex;align-items:flex-start;gap:.5rem;margin-bottom:.5rem">
      <span class="fl" style="padding-top:.25rem;min-width:32px">Size</span>
      <div class="chip-row" id="size-chips">
        <span class="chip" data-val="0.5b">0.5B</span>
        <span class="chip" data-val="1b">1B</span>
        <span class="chip" data-val="1.5b">1.5B</span>
        <span class="chip" data-val="3b">3B</span>
        <span class="chip" data-val="4b">4B</span>
        <span class="chip" data-val="7b">7B</span>
        <span class="chip" data-val="8b">8B</span>
        <span class="chip" data-val="9b">9B</span>
        <span class="chip" data-val="12b">12B</span>
        <span class="chip" data-val="13b">13B</span>
        <span class="chip" data-val="14b">14B</span>
        <span class="chip" data-val="22b">22B</span>
        <span class="chip" data-val="27b">27B</span>
        <span class="chip" data-val="32b">32B</span>
        <span class="chip" data-val="34b">34B</span>
        <span class="chip" data-val="40b">40B</span>
        <span class="chip" data-val="70b">70B</span>
        <span class="chip" data-val="72b">72B</span>
        <span class="chip" data-val="90b">90B</span>
        <span class="chip" data-val="123b">123B</span>
        <span class="chip" data-val="235b">235B</span>
        <span class="chip" data-val="671b">671B</span>
      </div>
    </div>

    <!-- filter row 3: capability chips -->
    <div style="display:flex;align-items:flex-start;gap:.5rem;margin-bottom:.625rem">
      <span class="fl" style="padding-top:.25rem;min-width:32px">Cap.</span>
      <div class="chip-row" id="cap-chips">
        <span class="chip" data-val="text_generation">Text</span>
        <span class="chip" data-val="image_generation">T2I</span>
        <span class="chip" data-val="image_to_text">I2T</span>
        <span class="chip" data-val="video_generation">T2V</span>
        <span class="chip" data-val="image_to_video">I2V</span>
        <span class="chip" data-val="audio_generation">T2A</span>
        <span class="chip" data-val="speech_to_text">STT</span>
        <span class="chip" data-val="text_to_speech">TTS</span>
        <span class="chip" data-val="embeddings">Embed</span>
        <span class="chip" data-val="function-calling">Tool calling</span>
        <span class="chip" data-val="vision">Vision</span>
        <span class="chip" data-val="reasoning">Reasoning</span>
        <span class="chip" data-val="code">Code</span>
        <span class="chip" data-val="multilingual">Multilingual</span>
        <span class="chip" data-val="roleplay">Roleplay</span>
        <span class="chip" data-val="math">Math</span>
      </div>
    </div>

    <!-- filter row 4: quant chips (file-level filter) -->
    <div style="display:flex;align-items:flex-start;gap:.5rem;margin-bottom:1rem">
      <span class="fl" style="padding-top:.25rem;min-width:32px">Quant</span>
      <div class="chip-row" id="quant-chips">
        <span class="chip" data-val="Q2_K">Q2_K</span>
        <span class="chip" data-val="Q3_K_M">Q3_K_M</span>
        <span class="chip" data-val="Q4_K_S">Q4_K_S</span>
        <span class="chip" data-val="Q4_K_M">Q4_K_M ★</span>
        <span class="chip" data-val="Q5_K_S">Q5_K_S</span>
        <span class="chip" data-val="Q5_K_M">Q5_K_M</span>
        <span class="chip" data-val="Q6_K">Q6_K</span>
        <span class="chip" data-val="Q8_0">Q8_0</span>
        <span class="chip" data-val="F16">F16</span>
        <span class="chip" data-val="IQ4_XS">IQ4_XS</span>
        <span class="chip" data-val="IQ3_XXS">IQ3_XXS</span>
        <span class="chip" data-val="IQ2_XXS">IQ2_XXS</span>
      </div>
    </div>

    <div id="search-results"><span class="muted small">Enter a query above to search</span></div>
  </div>
</div>


<!-- Download modal -->
<div id="dl-modal" class="modal">
  <div class="modal-box">
    <div class="modal-head">
      <span class="modal-title">Download model</span>
      <button class="modal-close" onclick="closeModal('dl-modal')">×</button>
    </div>
    <div class="modal-body">
      <div id="dl-form">
        <div class="form-row">
          <label class="form-label">HuggingFace repo ID or URL</label>
          <input type="text" id="dl-id" class="form-input" placeholder="e.g. bartowski/Llama-3.1-8B-Instruct-GGUF">
        </div>
        <!-- GGUF mode: specific file or pattern -->
        <div id="dl-pattern-row" class="form-row">
          <label class="form-label">File / pattern</label>
          <input type="text" id="dl-pattern" class="form-input" placeholder=".gguf">
          <span class="form-hint" id="dl-hint">Exact filename (e.g. <code>model-Q4_K_M.gguf</code>) or pattern (<code>.gguf</code>). Leave blank to download the first .gguf found.</span>
        </div>
        <!-- Snapshot mode: full repo via HF API -->
        <div id="dl-snapshot-note" class="alert alert-info" style="display:none">
          Will download the full repository using the HuggingFace snapshot API. This is the correct method for safetensors / non-GGUF models. Large repos may take a while.
        </div>
        <div class="form-actions">
          <button class="btn btn-primary" onclick="startDownload()">Download</button>
          <button class="btn btn-ghost" onclick="closeModal('dl-modal')">Close</button>
        </div>
      </div>
      <div id="dl-progress" style="display:none;padding:.25rem 0">
        <div id="dl-filename" style="font-size:13px;font-weight:500;margin-bottom:.5rem;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">Preparing…</div>
        <div class="progress" style="margin-top:0;margin-bottom:.35rem;height:5px;background:var(--border-2)"><div id="dl-bar" class="progress-fill" style="width:0%;height:100%"></div></div>
        <div style="display:flex;justify-content:space-between;font-size:11px;color:var(--text-2);margin-bottom:.75rem">
          <span id="dl-bytes">—</span>
          <span id="dl-speed">—</span>
          <span id="dl-eta">—</span>
          <span id="dl-pct">0%</span>
        </div>
        <div id="dl-log" style="display:none;background:var(--raised);border-radius:6px;padding:.4rem .6rem;font-size:11px;font-family:monospace;color:var(--text-2);max-height:72px;overflow-y:auto"></div>
      </div>
    </div>
  </div>
</div>

<!-- Upload modal -->
<div id="upload-modal" class="modal">
  <div class="modal-box">
    <div class="modal-head">
      <span class="modal-title">Upload GGUF model</span>
      <button class="modal-close" onclick="closeModal('upload-modal')">×</button>
    </div>
    <div class="modal-body">
      <div id="upload-form">
        <div class="form-row">
          <label class="form-label">Select GGUF file</label>
          <input type="file" id="upload-file" class="form-input" accept=".gguf">
        </div>
        <div class="form-actions">
          <button class="btn btn-primary" onclick="startUpload()">Upload</button>
          <button class="btn btn-ghost" onclick="closeModal('upload-modal')">Close</button>
        </div>
      </div>
      <div id="upload-progress" style="display:none;padding:.25rem 0">
        <div id="upload-filename" style="font-size:13px;font-weight:500;margin-bottom:.5rem">Uploading…</div>
        <div class="progress" style="margin-top:0;margin-bottom:.35rem;height:5px;background:var(--border-2)"><div id="upload-bar" class="progress-fill" style="width:0%;height:100%"></div></div>
        <div style="display:flex;justify-content:space-between;font-size:11px;color:var(--text-2)">
          <span id="upload-pct">0%</span>
          <span id="upload-status">—</span>
        </div>
      </div>
    </div>
  </div>
</div>

<!-- Whisper-server edit modal -->
<div id="ws-edit-modal" class="modal">
  <div class="modal-box" style="max-width:520px">
    <div class="modal-head">
      <span class="modal-title">Edit whisper-server model</span>
      <button class="modal-close" onclick="closeModal('ws-edit-modal')">×</button>
    </div>
    <div class="modal-body">
      <input type="hidden" id="wse-model-id">
      <div class="form-row">
        <label class="form-label">Model ID</label>
        <div id="wse-id-label" style="font-size:12px;font-family:monospace;color:var(--text-2);padding:.3rem 0"></div>
      </div>
      <div class="form-row">
        <label class="form-label">Alias <span class="muted small">(optional — shared by multiple instances for round-robin)</span></label>
        <input id="wse-alias" class="form-input" placeholder="e.g. whisper">
      </div>
      <div class="form-row">
        <label class="form-label">Server path</label>
        <input id="wse-server-path" class="form-input" placeholder="/usr/local/bin/whisper-server">
      </div>
      <div class="form-row">
        <label class="form-label">Model source</label>
        <select id="wse-model-source" class="form-input" onchange="toggleWseModelSource()">
          <option value="cached-gguf">Downloaded GGUF</option>
          <option value="manual-path">Manual path</option>
        </select>
      </div>
      <div class="form-row" id="wse-gguf-row">
        <label class="form-label">Downloaded GGUF</label>
        <select id="wse-gguf-select" class="form-input">
          <option value="">Select downloaded GGUF</option>
        </select>
      </div>
      <div class="form-row" id="wse-path-row" style="display:none">
        <label class="form-label">Model path</label>
        <input id="wse-model-path" class="form-input" placeholder="/path/to/ggml-model.bin">
      </div>
      <div style="display:grid;grid-template-columns:1fr 1fr 1fr;gap:.75rem">
        <div class="form-row" style="margin:0">
          <label class="form-label">Port</label>
          <input id="wse-port" class="form-input" type="number" min="1" max="65535">
        </div>
        <div class="form-row" style="margin:0">
          <label class="form-label">GPU device</label>
          <input id="wse-gpu-device" class="form-input" type="number" min="0">
        </div>
        <div class="form-row" style="margin:0">
          <label class="form-label">Load mode</label>
          <select id="wse-load-mode" class="form-input">
            <option value="on-request">On request</option>
            <option value="load">Load</option>
          </select>
        </div>
      </div>
      <div class="form-actions" style="margin-top:1rem">
        <button class="btn btn-primary" onclick="saveWhisperServerEdit()">Save</button>
        <button class="btn btn-ghost" onclick="closeModal('ws-edit-modal')">Cancel</button>
      </div>
    </div>
  </div>
</div>

<!-- Model configuration modal -->
<div id="cfg-modal" class="modal">
  <div class="modal-box" style="max-width:600px;max-height:92vh;overflow-y:auto">
    <div class="modal-head" style="position:sticky;top:0;background:var(--card);z-index:1;border-bottom:1px solid var(--border)">
      <span class="modal-title" id="cfg-modal-title">Configure model</span>
      <button class="modal-close" onclick="closeModal('cfg-modal')">×</button>
    </div>
    <div class="modal-body">
      <input type="hidden" id="cfg-path">
      <input type="hidden" id="cfg-orig-path">
      <input type="hidden" id="cfg-orig-type">

      <!-- identity -->
      <div class="form-row">
        <label class="form-label">Model ID / path</label>
        <div id="cfg-id-label" style="font-size:12px;font-family:monospace;color:var(--text-2);word-break:break-all;padding:.3rem 0"></div>
      </div>
      <div class="form-row" id="cfg-quant-row" style="display:none">
        <label class="form-label">Quantization</label>
        <select id="cfg-quant-select" class="form-input" onchange="onCfgQuantChange()"></select>
      </div>
      <div class="form-row">
        <label class="form-label" style="display:flex;align-items:center;gap:.5rem">Type
          <span id="cfg-type-autodet" style="font-size:11px;color:var(--text-3);font-weight:400"></span>
        </label>
        <div style="display:grid;grid-template-columns:1fr 1fr;gap:.3rem .75rem;margin-top:.35rem">
          <label style="display:flex;align-items:center;gap:.4rem;cursor:pointer;font-size:13px"><input type="checkbox" class="cfg-type-cb" value="text_models"> Text / LLM</label>
          <label style="display:flex;align-items:center;gap:.4rem;cursor:pointer;font-size:13px"><input type="checkbox" class="cfg-type-cb" value="vision_models"> Vision / VLM</label>
          <label style="display:flex;align-items:center;gap:.4rem;cursor:pointer;font-size:13px"><input type="checkbox" class="cfg-type-cb" value="image_models"> Image gen (T2I / I2I)</label>
          <label style="display:flex;align-items:center;gap:.4rem;cursor:pointer;font-size:13px"><input type="checkbox" class="cfg-type-cb" data-sub="t2v" value="video_models"> Video gen (T2V)</label>
          <label style="display:flex;align-items:center;gap:.4rem;cursor:pointer;font-size:13px"><input type="checkbox" class="cfg-type-cb" data-sub="i2v" value="video_models"> Image-to-Video (I2V)</label>
          <label style="display:flex;align-items:center;gap:.4rem;cursor:pointer;font-size:13px"><input type="checkbox" class="cfg-type-cb" data-sub="v2v" value="video_models"> Video-to-Video (V2V)</label>
          <label style="display:flex;align-items:center;gap:.4rem;cursor:pointer;font-size:13px"><input type="checkbox" class="cfg-type-cb" value="audio_models"> Audio transcription (STT)</label>
          <label style="display:flex;align-items:center;gap:.4rem;cursor:pointer;font-size:13px"><input type="checkbox" class="cfg-type-cb" value="tts_models"> Text-to-Speech (TTS)</label>
          <label style="display:flex;align-items:center;gap:.4rem;cursor:pointer;font-size:13px"><input type="checkbox" class="cfg-type-cb" value="audio_gen_models"> Audio generation</label>
          <label style="display:flex;align-items:center;gap:.4rem;cursor:pointer;font-size:13px"><input type="checkbox" class="cfg-type-cb" value="embedding_models"> Embeddings</label>
        </div>
      </div>
      <div class="form-row">
        <label class="form-label">Alias <span class="muted">(optional)</span></label>
        <input type="text" id="cfg-alias" class="form-input" placeholder="Friendly name">
      </div>

      <!-- backend -->
      <div class="card-title" style="margin-top:1.25rem">Backend</div>
      <div style="display:grid;grid-template-columns:1fr 1fr;gap:.75rem">
        <div class="form-row" style="margin:0">
          <label class="form-label">Compute backend</label>
          <select id="cfg-backend" class="form-input">
            <option value="auto">Auto-detect</option>
            <option value="nvidia">NVIDIA (CUDA)</option>
            <option value="vulkan">Vulkan</option>
            <option value="opencl">OpenCL</option>
            <option value="cpu">CPU only</option>
          </select>
        </div>
        <div class="form-row" style="margin:0">
          <label class="form-label">Load mode</label>
          <select id="cfg-load-mode" class="form-input" onchange="_updatePreloadAllVisibility()">
            <option value="load">Load (pre-load in VRAM)</option>
            <option value="on-request">On-request (load when needed)</option>
          </select>
        </div>
      </div>
      <div style="display:grid;grid-template-columns:1fr 1fr;gap:.75rem;margin-top:.75rem">
        <div class="form-row" style="margin:0">
          <label class="form-label">Used VRAM <span class="muted">(GB)</span></label>
          <input type="number" id="cfg-used-vram" class="form-input" min="0" step="0.1" placeholder="auto-estimate">
          <span class="form-hint" id="cfg-used-vram-hint" style="font-size:11px;color:var(--text-3)"></span>
        </div>
        <div class="form-row" style="margin:0">
          <label class="form-label">Max instances
            <span class="muted" title="How many copies of this model can be loaded simultaneously in VRAM to serve parallel requests. Requires enough free VRAM for each additional copy."> (?)</span>
          </label>
          <input type="number" id="cfg-max-instances" class="form-input" min="1" step="1" value="1" oninput="_updatePreloadAllVisibility()">
          <span style="font-size:11px;color:var(--text-3)">Parallel copies in VRAM</span>
        </div>
      </div>
      <div id="cfg-preload-all-row" style="display:none;margin-top:.5rem">
        <label style="display:flex;align-items:center;gap:.5rem;font-size:13px;cursor:pointer">
          <input type="checkbox" id="cfg-preload-all-instances">
          Load all instances at boot
          <span class="muted" style="font-size:11px" title="Pre-load all N copies of this model into VRAM at server startup instead of loading each copy on demand.">(load all ${max_instances} copies on startup)</span>
        </label>
      </div>

      <!-- inference -->
      <div class="card-title" style="margin-top:1.25rem">Inference</div>
      <div style="display:grid;grid-template-columns:1fr 1fr;gap:.75rem">
        <div class="form-row" style="margin:0">
          <label class="form-label">GPU layers <span class="muted">(-1 = all)</span></label>
          <input type="number" id="cfg-gpu-layers" class="form-input" min="-1" value="-1">
        </div>
        <div class="form-row" style="margin:0">
          <label class="form-label">Context size</label>
          <input type="number" id="cfg-n-ctx" class="form-input" min="128" step="128" value="2048">
        </div>
        <div class="form-row" style="margin:0">
          <label class="form-label">Max GPU % <span class="muted">(optional)</span></label>
          <input type="number" id="cfg-max-gpu" class="form-input" min="1" max="100" placeholder="e.g. 90">
        </div>
        <div class="form-row" style="margin:0">
          <label class="form-label">Manual RAM GB <span class="muted">(optional)</span></label>
          <input type="number" id="cfg-ram-gb" class="form-input" min="0" step="0.5" placeholder="auto">
        </div>
      </div>
      <div style="display:flex;gap:1.5rem;flex-wrap:wrap;margin-top:.75rem">
        <label style="display:flex;align-items:center;gap:.5rem;cursor:pointer;font-size:13px"><input type="checkbox" id="cfg-4bit"> 4-bit quantization</label>
        <label style="display:flex;align-items:center;gap:.5rem;cursor:pointer;font-size:13px"><input type="checkbox" id="cfg-8bit"> 8-bit quantization</label>
        <label style="display:flex;align-items:center;gap:.5rem;cursor:pointer;font-size:13px"><input type="checkbox" id="cfg-flash"> Flash Attention 2</label>
        <label style="display:flex;align-items:center;gap:.5rem;cursor:pointer;font-size:13px"><input type="checkbox" id="cfg-noram"> No RAM fallback</label>
      </div>

      <!-- offload -->
      <div class="card-title" style="margin-top:1.25rem">Offload</div>
      <div style="display:grid;grid-template-columns:1fr 1fr;gap:.75rem">
        <div class="form-row" style="margin:0">
          <label class="form-label">Strategy</label>
          <select id="cfg-offload-strategy" class="form-input">
            <option value="auto">Auto</option>
            <option value="cpu">CPU RAM</option>
            <option value="disk">Disk</option>
            <option value="none">None</option>
          </select>
        </div>
        <div class="form-row" style="margin:0">
          <label class="form-label">Offload directory</label>
          <input type="text" id="cfg-offload-dir" class="form-input" placeholder="./offload">
        </div>
      </div>

      <!-- generation -->
      <div class="card-title" style="margin-top:1.25rem">Generation</div>
      <div class="form-row">
        <label class="form-label">System prompt <span class="muted">(optional)</span></label>
        <textarea id="cfg-sysprompt" class="form-input" rows="3"
          placeholder="You are a helpful assistant…"
          style="resize:vertical;font-family:var(--font-mono,monospace);font-size:12px;line-height:1.5"></textarea>
      </div>
      <div style="display:grid;grid-template-columns:1fr 1fr;gap:.75rem">
        <div class="form-row" style="margin:0">
          <label class="form-label">Chat template parser</label>
          <select id="cfg-parser" class="form-input">
            <option value="auto">Auto-detect</option>
            <option value="llama">Llama</option>
            <option value="mistral">Mistral</option>
            <option value="chatml">ChatML</option>
            <option value="phi">Phi</option>
            <option value="gemma">Gemma</option>
            <option value="qwen">Qwen</option>
          </select>
        </div>
      </div>
      <div style="display:flex;gap:1.5rem;flex-wrap:wrap;margin-top:.75rem">
        <label style="display:flex;align-items:center;gap:.5rem;cursor:pointer;font-size:13px"><input type="checkbox" id="cfg-tools"> Tools closer prompt</label>
        <label style="display:flex;align-items:center;gap:.5rem;cursor:pointer;font-size:13px"><input type="checkbox" id="cfg-grammar"> Grammar-guided generation</label>
      </div>

      <div class="form-actions" style="margin-top:1.5rem">
        <button class="btn btn-primary" onclick="saveModelConfig()">Save</button>
        <button class="btn btn-ghost" onclick="closeModal('cfg-modal')">Cancel</button>
      </div>
    </div>
  </div>
</div>

<!-- Model info drawer -->
<div id="info-overlay" onclick="closeInfo()"></div>
<div id="info-drawer">
  <div id="info-sticky">
    <div id="info-title">—</div>
    <button class="btn btn-ghost btn-sm" onclick="closeInfo()">✕</button>
  </div>
  <div id="info-body" style="padding:1.25rem 1.5rem"></div>
</div>
{% endblock %}

{% block scripts %}
<script>
/* ── helpers ─────────────────────────────────────────── */
function esc(s){return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;')}
function fmtNum(n){if(!n)return'0';return n>=1e6?(n/1e6).toFixed(1)+'M':n>=1000?(n/1000).toFixed(1)+'k':String(n)}
function fmtGB(gb){if(!gb)return'—';return gb>=1?gb.toFixed(1)+' GB':(gb*1024).toFixed(0)+' MB'}
function fmtDate(s){try{return new Date(s).toLocaleDateString(undefined,{year:'numeric',month:'short',day:'numeric'})}catch{return s}}

function fmtCapabilities(caps){
  if(!caps||!caps.length)return'';
  const labels={
    text_generation:'Text',image_generation:'T2I',image_to_text:'I2T',
    video_generation:'T2V',image_to_video:'I2V',audio_generation:'T2A',
    speech_to_text:'STT',text_to_speech:'TTS',embeddings:'Embed',
    image_to_image:'I2I',video_to_video:'V2V',audio_to_audio:'A2A',
    inpainting:'Inpaint',controlnet:'ControlNet',depth_estimation:'Depth',
    image_segmentation:'Segment',image_upscaling:'Upscale',face_restoration:'Face',
    object_detection:'Detect',video_interpolation:'Interp',video_upscaling:'V-Upscale',
    lip_sync:'Lip-sync',subtitle_generation:'Subs',video_dubbing:'Dub'
  };
  return caps.slice(0,5).map(c=>`<span class="badge badge-user" style="font-size:10px;padding:.15rem .35rem">${esc(labels[c]||c)}</span>`).join(' ');
}

/* ── GGUF grouping helpers ───────────────────────────── */
const _QUANT_RE = /[_\-\.](Q\d[_A-Z0-9]*|IQ\d[_A-Z0-9]*|F16|F32|BF16)(?=\.gguf$)/i;
function _ggufQuant(filename){
  const m = filename.match(_QUANT_RE);
  return m ? m[1].toUpperCase() : null;
}
function _ggufBaseName(filename){
  // Strip quant suffix and .gguf extension to get the model base name
  return filename.replace(_QUANT_RE,'').replace(/\.gguf$/i,'');
}
function _switchGgufQuant(idx, newPath){
  const m = _localModels[idx];
  if(!m || !m.ggufGroup) return;
  const f = m.ggufGroup.find(x=>x.path===newPath);
  if(!f) return;
  m.label = f.filename;
  m.path = f.path;
  m.size_gb = f.size_gb||0;
  m.settings = f.settings||{};
  m.in_config = f.in_config;
  // Re-render quant badges in the row
  const row = document.getElementById('gguf-row-'+idx);
  if(!row) return;
  const base = _ggufBaseName(f.filename);
  const quantBadges = m.ggufGroup.length > 1
    ? m.ggufGroup.map(gf=>{
        const q = _ggufQuant(gf.filename);
        const active = gf.path === newPath;
        return `<span class="badge ${active?'badge-ok':'badge-user'}" style="font-size:10px;padding:.1rem .3rem;cursor:pointer" title="${esc(gf.filename)}" onclick="_switchGgufQuant(${idx},${JSON.stringify(gf.path)})">${esc(q||gf.filename)}</span>`;
      }).join(' ')
    : '';
  const nameCell = row.querySelector('td:first-child');
  if(nameCell) nameCell.innerHTML = `${esc(base)}<br>${quantBadges}`;
}

/* ── tab / modal ─────────────────────────────────────── */
function switchTab(name,btn){
  document.querySelectorAll('.tab-panel').forEach(p=>p.classList.remove('active'));
  document.querySelectorAll('.tab').forEach(b=>b.classList.remove('active'));
  document.getElementById('tab-'+name).classList.add('active');
  btn.classList.add('active');
}
function openModal(id){document.getElementById(id).classList.add('show')}
function closeModal(id){document.getElementById(id).classList.remove('show')}

/* ── Global settings ─────────────────────────────────── */
let _defaultOffloadDir = './offload';

async function loadGlobalSettings(){
  try{
    const r = await fetch('/admin/api/settings');
    if(r.ok){
      const d = await r.json();
      _defaultOffloadDir = d.offload?.directory || './offload';
    }
  }catch{}
}

/* ── GGUF format toggle ──────────────────────────────── */
let _ggufMode = 'gguf';
document.querySelectorAll('.tog-btn').forEach(btn=>{
  btn.addEventListener('click',()=>{
    document.querySelectorAll('.tog-btn').forEach(b=>b.classList.remove('on'));
    btn.classList.add('on');
    _ggufMode = btn.dataset.val;
  });
});

/* ── chip toggles ────────────────────────────────────── */
document.querySelectorAll('.chip').forEach(c=>{
  c.addEventListener('click',()=>c.classList.toggle('on'));
});
function getChips(id){return[...document.querySelectorAll('#'+id+' .chip.on')].map(c=>c.dataset.val)}

/* ── search ──────────────────────────────────────────── */
let _results   = [];
let _filesCache = {};
let _activeQuants = new Set();

function estimateModelSize(modelId){
  const id = modelId.toLowerCase();
  // Extract parameter count (e.g., 7b, 13b, 70b)
  const match = id.match(/(\d+\.?\d*)b/);
  if(!match) return 8; // default guess
  const params = parseFloat(match[1]);
  // Rough estimate: Q4 ≈ 0.5GB per B params, Q8 ≈ 1GB per B, FP16 ≈ 2GB per B
  if(id.includes('q4') || id.includes('4bit')) return params * 0.5;
  if(id.includes('q8') || id.includes('8bit')) return params * 1.0;
  return params * 2; // assume FP16
}

document.getElementById('search-q').addEventListener('keydown',e=>{if(e.key==='Enter')doSearch()});

async function doSearch(){
  const q   = document.getElementById('search-q').value.trim();
  const out = document.getElementById('search-results');
  const pipeline = document.getElementById('filter-pipeline').value;
  const arch     = document.getElementById('filter-arch').value;
  const sort     = document.getElementById('filter-sort').value;
  const sizes    = getChips('size-chips').join(',');
  _activeQuants  = new Set(getChips('quant-chips').map(v=>v.toUpperCase().split(' ')[0])); // strip ★

  // Get selected capability filters (from our custom chips)
  const selectedCaps = getChips('cap-chips');
  const capFilters = selectedCaps.filter(c=>!['function-calling','vision','reasoning','code','multilingual','roleplay','math'].includes(c));

  _filesCache = {};
  _results    = [];
  out.innerHTML = '<span class="muted small">Searching HuggingFace…</span>';

  const params = new URLSearchParams({q:q||' ', gguf_mode:_ggufMode, sort});
  if(pipeline) params.append('pipeline_tag', pipeline);
  if(sizes)    params.append('sizes', sizes);
  if(arch)     params.append('arch', arch);
  const caps = getChips('cap-chips');
  if(caps.length) params.append('capabilities', caps.join(','));

  try{
    const r = await fetch('/admin/api/hf-search?'+params);
    if(!r.ok){const e=await r.json();throw new Error(e.detail||r.statusText)}
    _results = await r.json();
    
    // Client-side filter by detected capabilities if any custom caps selected
    if(capFilters.length > 0){
      _results = _results.filter(m=>{
        const modelCaps = m.capabilities || [];
        return capFilters.some(cf=>modelCaps.includes(cf));
      });
    }
    
    if(!_results.length){out.innerHTML='<span class="muted small">No results. Try different keywords or fewer filters.</span>';return}
    
    const vramAvail = _results[0]?.vram_available;
    
    out.innerHTML = _results.map((m,i)=>{
      let vramDot = '';
      if(vramAvail){
        const estSize = estimateModelSize(m.id);
        const color = estSize <= vramAvail*0.8 ? '#10b981' : estSize <= vramAvail*0.95 ? '#f59e0b' : '#ef4444';
        vramDot = `<span style="display:inline-block;width:8px;height:8px;border-radius:50%;background:${color};margin-right:.35rem" title="Est. ${estSize}GB / ${vramAvail}GB available"></span>`;
      }
      const capBadges = fmtCapabilities(m.capabilities||[]);
      return `
      <div style="padding:.75rem 0;border-bottom:1px solid var(--border)">
        <div style="display:flex;align-items:flex-start;justify-content:space-between;gap:.5rem">
          <div style="min-width:0;flex:1">
            <div style="font-weight:500;font-size:13px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;display:flex;align-items:center"
                 title="${esc(m.id)}">${vramDot}${esc(m.id)}</div>
            <div style="font-size:11px;color:var(--text-3);margin-top:.25rem;display:flex;align-items:center;gap:.5rem;flex-wrap:wrap">
              ${m.pipeline_tag?`<span class="badge badge-user">${esc(m.pipeline_tag)}</span>`:''}
              ${capBadges}
              <span>↓ ${fmtNum(m.downloads)}</span>
              <span>♥ ${fmtNum(m.likes)}</span>
            </div>
          </div>
          <div style="display:flex;gap:.3rem;flex-shrink:0">
            <button class="btn btn-ghost btn-sm" onclick="openInfo(${i})">Info</button>
            <button class="btn btn-ghost btn-sm" onclick="toggleFiles(${i})">▾ Files</button>
            <button class="btn btn-secondary btn-sm" onclick="dlFromResult(${i})">Download</button>
          </div>
        </div>
        <div id="fp-${i}" style="display:none;margin-top:.625rem;padding:.5rem .625rem;background:var(--raised);border-radius:6px">
          <span class="muted small">Loading…</span>
        </div>
      </div>`;
    }).join('');
  }catch(e){
    out.innerHTML='<span class="muted small">Error: '+esc(e.message)+'</span>';
  }
}

function dlFromResult(i){
  const m = _results[i];
  if(looksLikeGguf(m.id, '')){
    const selQuants = [..._activeQuants];
    const pattern = selQuants.length===1 ? selQuants[0]+'.gguf' : '.gguf';
    openDownloadFor(m.id, pattern);
  } else {
    openDownloadFor(m.id, '');  // triggers snapshot download
  }
}

/* ── files panel ─────────────────────────────────────── */
async function toggleFiles(i){
  const panel = document.getElementById('fp-'+i);
  if(panel.style.display!=='none'){panel.style.display='none';return}
  panel.style.display='block';
  const modelId = _results[i].id;
  if(_filesCache[modelId]){renderFiles(panel,modelId,_filesCache[modelId]);return}
  panel.innerHTML='<span class="muted small">Fetching file list…</span>';
  try{
    const r = await fetch('/admin/api/hf-model-files?model_id='+encodeURIComponent(modelId));
    if(!r.ok)throw new Error((await r.json()).detail||r.statusText);
    const files = await r.json();
    _filesCache[modelId]=files;
    renderFiles(panel,modelId,files);
  }catch(e){panel.innerHTML='<span class="muted small">Error: '+esc(e.message)+'</span>'}
}

function renderFiles(panel,modelId,files){
  const shown = _activeQuants.size>0
    ? files.filter(f=>{
        const q=(f.quant||'').toUpperCase();
        return _activeQuants.has(q)||[..._activeQuants].some(aq=>f.name.toUpperCase().includes(aq));
      })
    : files;
  if(!shown.length){
    panel.innerHTML=`<span class="muted small">${_activeQuants.size?'No matching quant files — deselect quant chips to see all.':'No GGUF files found.'}</span>`;
    return;
  }
  panel.innerHTML=
    '<table style="width:100%;border-collapse:collapse;font-size:12px">'+
    '<thead><tr style="color:var(--text-3);font-size:10px;text-transform:uppercase;letter-spacing:.04em">'+
    '<th style="text-align:left;padding:.2rem .25rem">File</th>'+
    '<th style="text-align:center;padding:.2rem .25rem">Quant</th>'+
    '<th style="text-align:right;padding:.2rem .25rem">Size</th>'+
    '<th style="text-align:right;padding:.2rem .25rem">≈ VRAM</th>'+
    '<th></th></tr></thead><tbody>'+
    shown.map(f=>
      '<tr style="border-top:1px solid var(--border)">'+
      `<td style="padding:.3rem .25rem;font-family:monospace;max-width:280px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap" title="${esc(f.name)}">${esc(f.name)}</td>`+
      `<td style="text-align:center;padding:.3rem .25rem">${f.quant?'<span class="badge badge-admin">'+esc(f.quant)+'</span>':''}</td>`+
      `<td style="text-align:right;padding:.3rem .25rem;white-space:nowrap;color:var(--text-2)">${fmtGB(f.size_gb)}</td>`+
      `<td style="text-align:right;padding:.3rem .25rem;white-space:nowrap;color:var(--text-3)">${f.vram_gb?'~'+fmtGB(f.vram_gb):''}</td>`+
      `<td style="padding:.3rem .25rem;text-align:right"><button class="btn btn-secondary btn-sm" onclick="openDownloadFor('${esc(modelId)}','${esc(f.name)}')">↓</button></td>`+
      '</tr>'
    ).join('')+
    '</tbody></table>';
}

/* ── info drawer ─────────────────────────────────────── */
function openInfo(i){
  const m = _results[i];
  document.getElementById('info-title').textContent = m.id;
  document.getElementById('info-body').innerHTML = '<span class="muted small">Loading…</span>';
  document.getElementById('info-overlay').style.display='block';
  document.getElementById('info-drawer').classList.add('open');
  document.body.style.overflow='hidden';
  loadInfo(m.id);
}
function closeInfo(){
  document.getElementById('info-overlay').style.display='none';
  document.getElementById('info-drawer').classList.remove('open');
  document.body.style.overflow='';
}

async function loadInfo(modelId){
  try{
    const r = await fetch('/admin/api/hf-model-info?model_id='+encodeURIComponent(modelId));
    if(!r.ok)throw new Error((await r.json()).detail||r.statusText);
    renderInfo(await r.json());
  }catch(e){
    document.getElementById('info-body').innerHTML='<span class="muted small">Error: '+esc(e.message)+'</span>';
  }
}

function renderInfo(d){
  const escId = esc(d.id);
  let html=`
    <div style="display:flex;gap:.4rem;flex-wrap:wrap;margin-bottom:1.25rem">
      <a href="https://huggingface.co/${escId}" target="_blank" class="btn btn-ghost btn-sm">HuggingFace ↗</a>
      <button class="btn btn-secondary btn-sm" onclick="openDownloadFor('${escId}','${d.gguf_files&&d.gguf_files.length?'.gguf':''}')">Download repo</button>
      ${d.gated?'<span class="badge badge-admin" style="align-self:center">Gated</span>':''}
      ${d.private?'<span class="badge badge-user" style="align-self:center">Private</span>':''}
    </div>
    <div style="display:grid;grid-template-columns:1fr 1fr;gap:.625rem 1.25rem;font-size:13px;margin-bottom:1.25rem">
      ${d.pipeline_tag?`<div><div class="fl" style="margin-bottom:.2rem">Type</div>${esc(d.pipeline_tag)}</div>`:''}
      ${d.params_label?`<div><div class="fl" style="margin-bottom:.2rem">Parameters</div>${esc(d.params_label)}</div>`:''}
      <div><div class="fl" style="margin-bottom:.2rem">Downloads</div>↓ ${fmtNum(d.downloads)}</div>
      <div><div class="fl" style="margin-bottom:.2rem">Likes</div>♥ ${fmtNum(d.likes)}</div>
      ${d.license?`<div><div class="fl" style="margin-bottom:.2rem">License</div>${esc(d.license)}</div>`:''}
      ${d.last_modified?`<div><div class="fl" style="margin-bottom:.2rem">Updated</div>${fmtDate(d.last_modified)}</div>`:''}
      ${d.author?`<div><div class="fl" style="margin-bottom:.2rem">Author</div>${esc(d.author)}</div>`:''}
      ${d.file_count?`<div><div class="fl" style="margin-bottom:.2rem">Files in repo</div>${d.file_count}</div>`:''}
      ${d.base_model?`<div style="grid-column:span 2"><div class="fl" style="margin-bottom:.2rem">Base model</div><code style="font-size:11px">${esc(d.base_model)}</code></div>`:''}
      ${d.language&&d.language.length?`<div style="grid-column:span 2"><div class="fl" style="margin-bottom:.2rem">Languages</div>${d.language.slice(0,8).map(esc).join(', ')}</div>`:''}
    </div>`;

  const relevantTags = (d.tags||[]).filter(t=>!['transformers','safetensors','gguf','pytorch','jax'].includes(t));
  if(relevantTags.length){
    html+=`<div style="margin-bottom:1.25rem">
      <div class="fl" style="margin-bottom:.4rem">Tags</div>
      ${relevantTags.slice(0,30).map(t=>`<span class="badge badge-user" style="margin:.1rem">${esc(t)}</span>`).join('')}
    </div>`;
  }

  if(d.gguf_files&&d.gguf_files.length){
    html+=`<div class="card-title">GGUF files (${d.gguf_files.length})</div>
    <table style="width:100%;border-collapse:collapse;font-size:12px">
      <thead><tr style="color:var(--text-3);font-size:10px;text-transform:uppercase;letter-spacing:.04em">
        <th style="text-align:left;padding:.25rem .25rem">File</th>
        <th style="text-align:center;padding:.25rem .25rem">Quant</th>
        <th style="text-align:right;padding:.25rem .25rem">Size</th>
        <th style="text-align:right;padding:.25rem .25rem">≈ VRAM</th>
        <th></th>
      </tr></thead><tbody>
      ${d.gguf_files.map(f=>`
        <tr style="border-top:1px solid var(--border)">
          <td style="padding:.35rem .25rem;font-family:monospace;font-size:11px;max-width:220px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap" title="${esc(f.name)}">${esc(f.name)}</td>
          <td style="text-align:center;padding:.35rem .25rem">${f.quant?`<span class="badge badge-admin">${esc(f.quant)}</span>`:''}</td>
          <td style="text-align:right;padding:.35rem .25rem;white-space:nowrap;color:var(--text-2)">${fmtGB(f.size_gb)}</td>
          <td style="text-align:right;padding:.35rem .25rem;white-space:nowrap;color:var(--text-3)">${f.vram_gb?'~'+fmtGB(f.vram_gb):''}</td>
          <td style="padding:.35rem .25rem;text-align:right"><button class="btn btn-secondary btn-sm" onclick="openDownloadFor('${escId}','${esc(f.name)}')">↓</button></td>
        </tr>`).join('')}
      </tbody>
    </table>`;
  } else {
    html+=`<div class="muted small">No GGUF files in this repo.</div>`;
  }

  document.getElementById('info-title').textContent = d.id;
  document.getElementById('info-body').innerHTML = html;
}

/* ── download modal ──────────────────────────────────── */
function looksLikeGguf(modelId, filePattern){
  if(filePattern && filePattern.toLowerCase().includes('.gguf')) return true;
  if(_ggufMode==='gguf') return true;
  if(modelId.toLowerCase().includes('gguf')) return true;
  return false;
}

function openDownloadFor(modelId, filePattern){
  document.getElementById('dl-id').value = modelId;
  const isGguf = looksLikeGguf(modelId, filePattern);
  if(isGguf){
    document.getElementById('dl-pattern-row').style.display = 'block';
    document.getElementById('dl-snapshot-note').style.display = 'none';
    document.getElementById('dl-pattern').value = filePattern || '.gguf';
  } else {
    document.getElementById('dl-pattern-row').style.display = 'none';
    document.getElementById('dl-snapshot-note').style.display = 'flex';
    document.getElementById('dl-pattern').value = '';
  }
  openModal('dl-modal');
}

/* progress helpers */
function fmtBytes(n){if(!n&&n!==0)return'—';if(n>=1e9)return(n/1e9).toFixed(2)+' GB';if(n>=1e6)return(n/1e6).toFixed(1)+' MB';if(n>=1e3)return(n/1e3).toFixed(0)+' KB';return n+' B'}
function fmtRate(bps){return bps?(fmtBytes(bps)+'/s'):''}
function fmtEta(s){if(s===null||s===undefined)return'';s=Math.round(s);if(s<60)return s+'s left';const m=Math.floor(s/60),ss=s%60;if(m<60)return m+':'+String(ss).padStart(2,'0')+' left';return Math.floor(m/60)+'h '+(m%60)+'m left'}

let _dlEs = null;
let _dlDone = false;

function _dlReset(){
  const bar = document.getElementById('dl-bar');
  bar.style.transition = 'none';
  bar.style.width = '0%';
  document.getElementById('dl-filename').textContent='Preparing…';
  document.getElementById('dl-bytes').textContent='—';
  document.getElementById('dl-speed').textContent='—';
  document.getElementById('dl-eta').textContent='—';
  document.getElementById('dl-pct').textContent='0%';
  document.getElementById('dl-log').style.display='none';
  document.getElementById('dl-log').innerHTML='';
  requestAnimationFrame(()=>{ bar.style.transition=''; });
}

function _dlLog(msg){
  const el=document.getElementById('dl-log');
  el.style.display='block';
  const d=document.createElement('div');
  d.textContent=msg;
  el.appendChild(d);
  el.scrollTop=el.scrollHeight;
}

function showDownloadError(msg){
  if(_dlEs){_dlEs.close();_dlEs=null}
  document.getElementById('dl-form').style.display='block';
  document.getElementById('dl-progress').style.display='none';
  alert('Download error: '+msg);
}

function handleProgressEvent(evt){
  if(evt.type==='start'){
    document.getElementById('dl-filename').textContent=evt.filename||'Downloading…';
    document.getElementById('dl-bytes').textContent='0 / '+fmtBytes(evt.total);
    document.getElementById('dl-bar').style.width='0%';
  }else if(evt.type==='progress'){
    const pct=evt.percent||0;
    document.getElementById('dl-bar').style.width=pct+'%';
    document.getElementById('dl-pct').textContent=pct.toFixed(1)+'%';
    document.getElementById('dl-bytes').textContent=fmtBytes(evt.downloaded)+' / '+fmtBytes(evt.total);
    document.getElementById('dl-speed').textContent=fmtRate(evt.rate);
    document.getElementById('dl-eta').textContent=fmtEta(evt.eta);
    if(evt.filename) document.getElementById('dl-filename').textContent=evt.filename;
  }else if(evt.type==='info'){
    _dlLog(evt.message);
  }else if(evt.type==='done'){
    _dlDone=true;
    if(_dlEs){_dlEs.close();_dlEs=null}
    document.getElementById('dl-bar').style.width='100%';
    document.getElementById('dl-pct').textContent='100%';
    document.getElementById('dl-filename').textContent='Download complete!';
    refreshLocal();
    setTimeout(()=>{
      closeModal('dl-modal');
      document.getElementById('dl-form').style.display='block';
      document.getElementById('dl-progress').style.display='none';
      _dlReset();
    },1800);
  }else if(evt.type==='error'){
    _dlDone=true;
    showDownloadError(evt.message);
  }
  // keepalive: ignore
}

async function startDownload(){
  const id=document.getElementById('dl-id').value.trim();
  if(!id){document.getElementById('dl-id').focus();return}
  _dlDone=false;
  _dlReset();
  document.getElementById('dl-form').style.display='none';
  document.getElementById('dl-progress').style.display='block';
  try{
    const r=await fetch('/admin/api/model-download',{
      method:'POST',headers:{'Content-Type':'application/json'},
      body:JSON.stringify({model_id:id,file_pattern:document.getElementById('dl-pattern').value||null})
    });
    if(!r.ok){const e=await r.json();showDownloadError(e.detail||'Request failed');return}
    const {session_id}=await r.json();
    _dlEs=new EventSource('/admin/api/download-stream/'+session_id);
    _dlEs.onmessage=function(e){
      try{handleProgressEvent(JSON.parse(e.data))}catch{}
    };
    _dlEs.onerror=function(){
      if(_dlDone) return;
      if(_dlEs&&_dlEs.readyState===EventSource.CLOSED) return;
      showDownloadError('Connection to download stream lost');
    };
  }catch(e){showDownloadError(e.message)}
}

/* ── active downloads strip ──────────────────────────── */
let _pollTimer = null;

async function pollDownloads(){
  try{
    const r = await fetch('/admin/api/downloads');
    if(!r.ok) return;
    const all = await r.json();
    const active = all.filter(d=>d.status!=='done'&&d.status!=='error');
    const strip = document.getElementById('dl-strip');
    const list  = document.getElementById('dl-strip-list');
    if(!active.length){ strip.style.display='none'; return; }
    strip.style.display='block';
    list.innerHTML = active.map(d=>{
      const pct = d.percent||0;
      const name = d.filename||d.model_id||'';
      const spd  = d.rate?fmtRate(d.rate):'';
      const eta  = d.eta!=null?fmtEta(d.eta):'';
      return `<div style="display:flex;align-items:center;gap:.75rem;padding:.2rem 0">
        <div style="flex:1;min-width:0">
          <div style="font-size:12px;font-weight:500;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">${esc(d.model_id)}</div>
          <div style="font-size:11px;color:var(--text-2);overflow:hidden;text-overflow:ellipsis;white-space:nowrap">${esc(name)}</div>
          <div class="progress" style="margin-top:.3rem;margin-bottom:0;height:3px"><div class="progress-fill" style="width:${pct}%"></div></div>
        </div>
        <div style="font-size:11px;color:var(--text-2);text-align:right;white-space:nowrap;flex-shrink:0">
          <div>${pct.toFixed(1)}%</div>
          ${spd?`<div>${esc(spd)}</div>`:''}
          ${eta?`<div class="muted">${esc(eta)}</div>`:''}
        </div>
      </div>`;
    }).join('<div style="border-top:1px solid var(--border);margin:.3rem 0"></div>');
  }catch{}
}

function startPolling(){
  if(_pollTimer) return;
  _pollTimer = setInterval(pollDownloads, 2000);
  pollDownloads();
}

startPolling();

/* ── cache stats & local models ──────────────────────── */
async function loadCacheStats(){
  try{
    const r = await fetch('/admin/api/cache-stats');
    if(!r.ok) return;
    const s = await r.json();
    document.getElementById('stat-hf-size').textContent  = fmtBytes(s.hf_bytes);
    document.getElementById('stat-hf-count').textContent = s.hf_models+' model'+(s.hf_models!==1?'s':'');
    document.getElementById('stat-hf-free').textContent = s.hf_disk_free_bytes!=null ? fmtBytes(s.hf_disk_free_bytes)+' free' : '—';
    document.getElementById('stat-gguf-size').textContent = fmtBytes(s.gguf_bytes);
    document.getElementById('stat-gguf-count').textContent = s.gguf_files+' file'+(s.gguf_files!==1?'s':'');
    document.getElementById('stat-gguf-free').textContent = s.gguf_disk_free_bytes!=null ? fmtBytes(s.gguf_disk_free_bytes)+' free' : '—';
    document.getElementById('stat-total-size').textContent = fmtBytes((s.hf_bytes||0)+(s.gguf_bytes||0));
  }catch{}
}

let _localModels = [];
let _ggufFiles = [];

function _renderWhisperServerRows(models){
  if(!models.length) return '';
  const rows = models.map(m=>{
    const idx = _localModels.length;
    _localModels.push({
      label:m.id,
      path:m.id,
      cacheType:'whisper-server',
      size_gb:0,
      defaultType:'audio_models',
      settings:{
        backend: m.backend || 'whisper-server',
        load_mode: m.load_mode || 'on-request',
        model_type: 'audio_models',
        model_path: m.model_path || '',
        server_path: m.server_path || '',
        alias: m.alias || '',
        port: m.port ?? 8744,
        gpu_device: m.gpu_device ?? 0,
      },
      in_config:true,
      capabilities:m.capabilities || ['speech_to_text']
    });
    const loaded = _loadedKeys.has(`audio:${m.id}`) || _loadedKeys.has(m.id);
    return `<tr style="border-top:1px solid var(--border)">
      <td style="padding:.4rem .25rem;font-family:monospace;font-size:12px">${esc(m.id)}${m.alias?`<br><span style="color:var(--text-2);font-size:10px">alias: ${esc(m.alias)}</span>`:''}</td>
      <td style="padding:.4rem .25rem;font-size:11px;color:var(--text-2);max-width:160px;overflow:hidden;text-overflow:ellipsis;display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;line-height:1.25;max-height:2.5em" title="${esc(m.model_path || "—")}">${esc(m.model_path || "—")}</td>
      <td style="padding:.4rem .25rem;font-size:11px;color:var(--text-2)">${m.port ?? '—'} / GPU ${m.gpu_device ?? 0}</td>
      <td style="padding:.4rem .25rem;font-size:11px;color:var(--text-2)">${esc(m.load_mode || 'on-request')}</td>
      <td style="padding:.4rem .25rem;text-align:center">${loaded?'<span class="badge badge-ok">loaded</span>':'<span class="muted small">idle</span>'}</td>
      <td style="padding:.4rem .25rem;text-align:right;white-space:nowrap">
        ${loaded
          ?`<button class="btn btn-ghost btn-sm" onclick="unloadModel(${idx})">Unload</button>`
          :`<button class="btn btn-primary btn-sm" onclick="loadModel(${idx})">Load now</button>`}
        <button class="btn btn-secondary btn-sm" onclick="openCfgModal(${idx})">Edit</button>
        <button class="btn btn-ghost btn-sm" onclick="disableModel(${idx})">Remove</button>
      </td>
    </tr>`;
  });
  return '<div class="card" style="margin-top:1rem" id="ws-rendered-card">'+
    '<div class="card-title">Configured whisper-server models</div>'+
    '<table style="width:100%;border-collapse:collapse;font-size:13px">'+
    '<thead><tr style="color:var(--text-2);font-size:10px;text-transform:uppercase;letter-spacing:.05em">'+
    '<th style="text-align:left;padding:.3rem .25rem;font-weight:700">Model / Alias</th>'+
    '<th style="text-align:left;padding:.3rem .25rem;font-weight:700">Model path</th>'+
    '<th style="text-align:left;padding:.3rem .25rem;font-weight:700">Port / GPU</th>'+
    '<th style="text-align:left;padding:.3rem .25rem;font-weight:700">Load mode</th>'+
    '<th style="text-align:center;padding:.3rem .25rem;font-weight:700">Status</th>'+
    '<th></th></tr></thead><tbody>'+rows.join('')+'</tbody></table></div>';
}

function refreshWhisperGgufOptions(){
  const ggufSelect = document.getElementById('ws-gguf-select');
  if(!ggufSelect) return;
  const selectedValue = ggufSelect.value;
  ggufSelect.innerHTML = '<option value="">Select downloaded GGUF</option>'+
    _ggufFiles.map(f=>`<option value="${esc(f.path)}">${esc(f.filename)}</option>`).join('');
  if(selectedValue && _ggufFiles.some(f => f.path === selectedValue)){
    ggufSelect.value = selectedValue;
  }
}

function prefillWhisperServerFromGguf(path){
  const sourceSelect = document.getElementById('ws-model-source');
  const ggufSelect = document.getElementById('ws-gguf-select');
  const builder = document.getElementById('ws-model-builder');
  resetWhisperServerBuilderDefaults();
  if(sourceSelect){
    sourceSelect.value = 'cached-gguf';
  }
  toggleWhisperModelSource();
  refreshWhisperGgufOptions();
  if(ggufSelect){
    ggufSelect.value = path;
  }
  if(builder){
    builder.scrollIntoView({behavior:'smooth', block:'start'});
  }
}

function nextWhisperServerModelId(){
  const whisperModels = _localModels.filter(m => m.cacheType === 'whisper-server');
  const existingIds = new Set(whisperModels.map(m => m.id || ''));
  let suffix = 0;
  while(existingIds.has(`whisper${suffix}`)) suffix += 1;
  return `whisper${suffix}`;
}

function defaultWhisperServerPath(){
  return '/usr/local/bin/whisper-server';
}

function resetWhisperServerBuilderDefaults(){
  const modelIdInput = document.getElementById('ws-model-id');
  const serverPathInput = document.getElementById('ws-server-path');
  if(modelIdInput && !modelIdInput.value.trim()) modelIdInput.value = nextWhisperServerModelId();
  if(serverPathInput && !serverPathInput.value.trim()) serverPathInput.value = defaultWhisperServerPath();
}

async function loadCachedModels(){
  _localModels = [];
  const hfEl   = document.getElementById('hf-models-list');
  const ggufEl = document.getElementById('gguf-models-list');
  hfEl.innerHTML = ggufEl.innerHTML = '<span class="muted small">Loading…</span>';
  try{
    const r = await fetch('/admin/api/cached-models');
    if(!r.ok) throw new Error((await r.json()).detail||r.statusText);
    const d = await r.json();
    const whisperModels = (await fetch('/admin/api/models').then(r=>r.ok?r.json():[]))
      .filter(m => m.backend === 'whisper-server');

    // HF models
    const hf = d.hf||[];
    document.getElementById('hf-model-badge').textContent = hf.length ? `(${hf.length})` : '';
    if(!hf.length){
      hfEl.innerHTML = '<span class="muted small">No HuggingFace models cached.</span>';
    }else{
      const rows = hf.map(m=>{
        const idx = _localModels.length;
        _localModels.push({label:m.id, path:m.id, cacheType:'hf', size_gb:m.size_gb||0,
          defaultType:m.model_type||'text_models', settings:m.settings||{}, in_config:m.in_config,
          capabilities:m.capabilities||[]});
        const loaded = _loadedKeys.has(m.id) || [..._loadedKeys].some(k=>k.endsWith(':'+m.id)||k===m.id);
        const capBadges = fmtCapabilities(m.capabilities||[]);
        return `<tr style="border-top:1px solid var(--border)">
          <td style="padding:.4rem .25rem;font-family:monospace;font-size:12px;max-width:260px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap" title="${esc(m.id)}">${esc(m.id)}</td>
          <td style="text-align:right;padding:.4rem .25rem;white-space:nowrap;color:var(--text-2)">${fmtGB(m.size_gb)}</td>
          <td style="text-align:right;padding:.4rem .25rem;color:var(--text-2)">${m.file_count}</td>
          <td style="padding:.4rem .25rem;font-size:11px">${capBadges||'<span class="muted small">—</span>'}</td>
          <td style="text-align:center;padding:.4rem .25rem">${m.in_config?'<span class="badge badge-ok">enabled</span>':'<span class="muted small">—</span>'}</td>
          <td style="padding:.4rem .25rem;text-align:right;white-space:nowrap">
            ${m.in_config?(loaded
              ?`<button class="btn btn-ghost btn-sm" onclick="unloadModel(${idx})">Unload</button>`
              :`<button class="btn btn-primary btn-sm" onclick="loadModel(${idx})">Load now</button>`):''}
            <button class="btn btn-secondary btn-sm" onclick="openCfgModal(${idx})">${m.in_config?'Configure':'Add to CoderAI'}</button>
            ${m.in_config?`<button class="btn btn-ghost btn-sm" onclick="disableModel(${idx})">Remove</button>`:''}
            <button class="btn btn-danger btn-sm" onclick="deleteModelConfirm(${idx})">Delete</button>
          </td>
        </tr>`;
      });
      hfEl.innerHTML = '<table style="width:100%;border-collapse:collapse;font-size:13px">'+
        '<thead><tr style="color:var(--text-2);font-size:10px;text-transform:uppercase;letter-spacing:.05em">'+
        '<th style="text-align:left;padding:.3rem .25rem;font-weight:700">Model</th>'+
        '<th style="text-align:right;padding:.3rem .25rem;font-weight:700">Size</th>'+
        '<th style="text-align:right;padding:.3rem .25rem;font-weight:700">Files</th>'+
        '<th style="text-align:left;padding:.3rem .25rem;font-weight:700">Capabilities</th>'+
        '<th style="text-align:center;padding:.3rem .25rem;font-weight:700">Config</th>'+
        '<th></th></tr></thead><tbody>'+rows.join('')+'</tbody></table>';
    }

    // GGUF files
    const gguf = d.gguf||[];
    _ggufFiles = gguf;
    refreshWhisperGgufOptions();
    document.getElementById('gguf-file-badge').textContent = gguf.length ? `(${gguf.length})` : '';
    if(!gguf.length){
      ggufEl.innerHTML = '<span class="muted small">No GGUF files cached.</span>';
    }else{
      // Group by base model name (strip quant suffix)
      const groups = {};
      gguf.forEach(f=>{
        const base = _ggufBaseName(f.filename);
        if(!groups[base]) groups[base] = [];
        groups[base].push(f);
      });
      const rows = Object.entries(groups).map(([base, files])=>{
        // Use the configured file if any, else first
        const primary = files.find(f=>f.in_config) || files[0];
        const idx = _localModels.length;
        _localModels.push({label:primary.filename, path:primary.path, cacheType:'gguf',
          size_gb:primary.size_gb||0, defaultType:primary.model_type||'text_models',
          settings:primary.settings||{}, in_config:primary.in_config,
          capabilities:primary.capabilities||[], ggufGroup:files});
        const loaded = _loadedKeys.has(primary.path) || _loadedKeys.has(primary.filename) ||
          [..._loadedKeys].some(k=>k.endsWith(':'+primary.path)||k.endsWith(':'+primary.filename));
        const capBadges = fmtCapabilities(primary.capabilities||[]);
        const in_config = files.some(f=>f.in_config);
        // Quant badges
        const quantBadges = files.length > 1
          ? files.map(f=>{
              const q = _ggufQuant(f.filename);
              const active = f.path === primary.path;
              return `<span class="badge ${active?'badge-ok':'badge-user'}" style="font-size:10px;padding:.1rem .3rem;cursor:pointer" title="${esc(f.filename)}" onclick="_switchGgufQuant(${idx},${JSON.stringify(f.path)})">${esc(q||f.filename)}</span>`;
            }).join(' ')
          : '';
        return `<tr style="border-top:1px solid var(--border)" id="gguf-row-${idx}">
          <td style="padding:.4rem .25rem;font-family:monospace;font-size:11px;max-width:280px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap" title="${esc(primary.filename)}">${esc(base)}<br>${quantBadges}</td>
          <td style="text-align:right;padding:.4rem .25rem;white-space:nowrap;color:var(--text-2)">${fmtGB(primary.size_gb)}</td>
          <td style="padding:.4rem .25rem;font-size:11px">${capBadges||'<span class="muted small">—</span>'}</td>
          <td style="text-align:center;padding:.4rem .25rem">${in_config?'<span class="badge badge-ok">enabled</span>':'<span class="muted small">—</span>'}</td>
          <td style="padding:.4rem .25rem;text-align:right;white-space:nowrap">
            ${in_config?(loaded
              ?`<button class="btn btn-ghost btn-sm" onclick="unloadModel(${idx})">Unload</button>`
              :`<button class="btn btn-primary btn-sm" onclick="loadModel(${idx})">Load now</button>`):''}
            <button class="btn btn-secondary btn-sm" onclick="openCfgModal(${idx})">${in_config?'Configure':'Add to CoderAI'}</button>
            ${in_config?`<button class="btn btn-ghost btn-sm" onclick="disableModel(${idx})">Remove</button>`:''}
            <button class="btn btn-danger btn-sm" onclick="deleteModelConfirm(${idx})">Delete</button>
          </td>
        </tr>`;
      });
      ggufEl.innerHTML = '<table style="width:100%;border-collapse:collapse;font-size:13px">'+
        '<thead><tr style="color:var(--text-2);font-size:10px;text-transform:uppercase;letter-spacing:.05em">'+
        '<th style="text-align:left;padding:.3rem .25rem;font-weight:700">Model</th>'+
        '<th style="text-align:right;padding:.3rem .25rem;font-weight:700">Size</th>'+
        '<th style="text-align:left;padding:.3rem .25rem;font-weight:700">Capabilities</th>'+
        '<th style="text-align:center;padding:.3rem .25rem;font-weight:700">Config</th>'+
        '<th></th></tr></thead><tbody>'+rows.join('')+'</tbody></table>';
    }
    // Remove any previously rendered whisper-server card before inserting the new one
    document.querySelectorAll('#ws-rendered-card').forEach(el=>el.remove());
    const wsHtml = _renderWhisperServerRows(whisperModels);
    if(wsHtml) ggufEl.insertAdjacentHTML('afterend', wsHtml);
    resetWhisperServerBuilderDefaults();
  }catch(e){
    hfEl.innerHTML = ggufEl.innerHTML = `<span class="muted small">Error: ${esc(e.message)}</span>`;
  }
}

function toggleWhisperModelSource(){
  const sourceSelect = document.getElementById('ws-model-source');
  const source = sourceSelect ? sourceSelect.value : 'cached-gguf';
  const ggufSelect = document.getElementById('ws-gguf-select');
  const modelPath = document.getElementById('ws-model-path');
  if(!ggufSelect || !modelPath) return;
  const useCached = source === 'cached-gguf';
  ggufSelect.style.display = useCached ? '' : 'none';
  modelPath.style.display = useCached ? 'none' : '';
}

function getWhisperServerModelPath(){
  const modelSource = document.getElementById('ws-model-source').value;
  return (modelSource === 'cached-gguf'
    ? document.getElementById('ws-gguf-select').value
    : document.getElementById('ws-model-path').value.trim()) || null;
}

let _loadedKeys = new Set();

async function refreshLoadedStatus(){
  try{
    const r = await fetch('/admin/api/model-loaded-status');
    if(r.ok){ const d = await r.json(); _loadedKeys = new Set(d.loaded||[]); }
  }catch{}
}

async function refreshLocal(){
  await refreshLoadedStatus();
  loadCacheStats();
  loadCachedModels();
}

loadGlobalSettings();
refreshLocal();

async function clearCacheConfirm(type){
  const labels = {hf:'HuggingFace', gguf:'GGUF', all:'ALL'};
  if(!confirm(`Delete ${labels[type]} model cache? This cannot be undone.`)) return;
  try{
    const r = await fetch('/admin/api/cache?cache_type='+type, {method:'DELETE'});
    const d = await r.json();
    if(d.success){
      refreshLocal();
      alert(`Cache cleared. Freed ${fmtBytes(d.freed_bytes||0)}.`);
    }else alert('Error clearing cache');
  }catch(e){alert('Error: '+e.message)}
}

async function deleteModelConfirm(idx){
  const m = _localModels[idx];
  if(!confirm(`Delete "${m.label}" from local cache? This cannot be undone.`)) return;
  // Always send the full path so the backend can locate the file regardless of cache location
  const idForUrl = m.path;
  try{
    const r = await fetch('/admin/api/cached-models/'+encodeURIComponent(idForUrl)+'?cache_type='+m.cacheType, {method:'DELETE'});
    const d = await r.json();
    if(d.success) refreshLocal();
    else alert('Error: '+(d.detail||'Unknown'));
  }catch(e){alert('Error: '+e.message)}
}

/* ── type checkbox helpers ─────────────────────────────── */
function _capabilitiesToTypes(caps) {
  const categories = new Set(), subs = new Set();
  if (!caps || !caps.length) return {categories, subs};
  if (caps.includes('image_to_video'))  { categories.add('video_models'); subs.add('i2v'); }
  if (caps.includes('video_generation')){ categories.add('video_models'); subs.add('t2v'); }
  if (caps.includes('video_to_video'))  { categories.add('video_models'); subs.add('v2v'); }
  if (caps.includes('image_generation') || caps.includes('image_to_image') ||
      caps.includes('inpainting')       || caps.includes('controlnet')) categories.add('image_models');
  if (caps.includes('image_to_text') && caps.includes('text_generation')) {
    categories.add('vision_models');
  } else if (caps.includes('text_generation') &&
             !categories.has('video_models') && !categories.has('image_models')) {
    categories.add('text_models');
  }
  if (caps.includes('speech_to_text')) categories.add('audio_models');
  if (caps.includes('text_to_speech')) categories.add('tts_models');
  if (caps.includes('audio_generation')) categories.add('audio_gen_models');
  if (caps.includes('embeddings')) categories.add('embedding_models');
  return {categories, subs};
}

function _setTypeCheckboxes(categories, subs) {
  document.querySelectorAll('.cfg-type-cb').forEach(cb => {
    const sub = cb.dataset.sub;
    if (!categories.has(cb.value)) { cb.checked = false; return; }
    if (sub) {
      // Sub-typed checkbox (T2V / I2V / V2V): check only if this sub is in subs
      cb.checked = subs.has(sub);
    } else if (cb.value === 'video_models') {
      // Non-sub video checkbox: only relevant when subs is empty (legacy/no-sub)
      cb.checked = subs.size === 0;
    } else {
      cb.checked = true;
    }
  });
}

function _getCheckedTypes() {
  const checked = [...document.querySelectorAll('.cfg-type-cb:checked')];
  const categories = [...new Set(checked.map(cb => cb.value))];
  const subs = checked.filter(cb => cb.dataset.sub).map(cb => cb.dataset.sub);
  return {primaryType: categories[0] || 'text_models', model_types: categories, video_subtypes: subs};
}

function onCfgQuantChange(){
  const quantSelect = document.getElementById('cfg-quant-select');
  const idx = parseInt(quantSelect.dataset.idx);
  const newPath = quantSelect.value;
  _switchGgufQuant(idx, newPath);
  // Update hidden path field and label
  document.getElementById('cfg-path').value = newPath;
  const m = _localModels[idx];
  document.getElementById('cfg-id-label').textContent = m.label;
}

function toggleWseModelSource() {
  const source = document.getElementById('wse-model-source').value;
  document.getElementById('wse-gguf-row').style.display = source === 'cached-gguf' ? '' : 'none';
  document.getElementById('wse-path-row').style.display = source === 'manual-path' ? '' : 'none';
}

function _getWseModelPath() {
  const source = document.getElementById('wse-model-source').value;
  return source === 'cached-gguf'
    ? document.getElementById('wse-gguf-select').value
    : document.getElementById('wse-model-path').value.trim();
}

function _openWhisperServerEdit(m) {
  const s = m.settings || {};
  document.getElementById('wse-model-id').value = m.path;
  document.getElementById('wse-id-label').textContent = m.path;
  document.getElementById('wse-alias').value = s.alias || '';
  document.getElementById('wse-server-path').value = s.server_path || defaultWhisperServerPath();
  document.getElementById('wse-port').value = s.port ?? 8744;
  document.getElementById('wse-gpu-device').value = s.gpu_device ?? 0;
  document.getElementById('wse-load-mode').value = s.load_mode || 'on-request';

  // Populate the GGUF select with current options from the add-form's list
  const srcSelect = document.getElementById('ws-gguf-select');
  const dstSelect = document.getElementById('wse-gguf-select');
  dstSelect.innerHTML = srcSelect ? srcSelect.innerHTML : '<option value="">Select downloaded GGUF</option>';

  const modelPath = s.model_path || '';
  const inGguf = modelPath && _ggufFiles.some(f => f.path === modelPath);
  if (inGguf) {
    document.getElementById('wse-model-source').value = 'cached-gguf';
    dstSelect.value = modelPath;
  } else {
    document.getElementById('wse-model-source').value = 'manual-path';
    document.getElementById('wse-model-path').value = modelPath;
  }
  toggleWseModelSource();
  openModal('ws-edit-modal');
}

async function saveWhisperServerEdit() {
  const model_id = document.getElementById('wse-model-id').value;
  const model_source = document.getElementById('wse-model-source').value;
  const model_path = _getWseModelPath();
  if (!model_path) { alert('Model path is required'); return; }
  const payload = {
    model_id,
    backend: 'whisper-server',
    model_source,
    server_path: document.getElementById('wse-server-path').value.trim(),
    model_path,
    port: parseInt(document.getElementById('wse-port').value, 10) || 8744,
    gpu_device: parseInt(document.getElementById('wse-gpu-device').value, 10) || 0,
    load_mode: document.getElementById('wse-load-mode').value,
    alias: document.getElementById('wse-alias').value.trim() || null,
  };
  try {
    const r = await fetch('/admin/api/model-configure', {
      method: 'POST', headers: {'Content-Type': 'application/json'},
      body: JSON.stringify(payload)
    });
    const d = await r.json();
    if (!r.ok) throw new Error(d.detail || 'Failed to save');
    closeModal('ws-edit-modal');
    refreshLocal();
  } catch(e) { alert('Error: ' + e.message); }
}

function openCfgModal(idx){
  const m = _localModels[idx];
  if (m.cacheType === 'whisper-server') {
    _openWhisperServerEdit(m);
    return;
  }
  const s = m.settings || {};
  document.getElementById('cfg-modal-title').textContent = m.in_config ? 'Configure model' : 'Add to CoderAI';
  document.getElementById('cfg-id-label').textContent = m.label;
  document.getElementById('cfg-path').value = m.path;
  document.getElementById('cfg-orig-path').value = m.path;   // frozen at open time
  document.getElementById('cfg-orig-type').value = m.defaultType;

  // Quantization selector for grouped GGUF models
  const quantRow = document.getElementById('cfg-quant-row');
  const quantSelect = document.getElementById('cfg-quant-select');
  if(m.ggufGroup && m.ggufGroup.length > 1){
    quantSelect.innerHTML = m.ggufGroup.map(f=>{
      const q = _ggufQuant(f.filename) || f.filename;
      return `<option value="${esc(f.path)}" ${f.path===m.path?'selected':''}>${esc(q)} — ${fmtGB(f.size_gb)}</option>`;
    }).join('');
    quantRow.style.display = '';
    quantSelect.dataset.idx = idx;
  } else {
    quantRow.style.display = 'none';
    quantSelect.innerHTML = '';
  }

  // Determine type checkboxes: saved config > auto-detect from capabilities > defaultType
  const det = document.getElementById('cfg-type-autodet');
  if (s.model_types && s.model_types.length) {
    // Previously saved multi-type config
    const savedSubs = new Set(s.video_subtypes || []);
    _setTypeCheckboxes(new Set(s.model_types), savedSubs);
    det.textContent = '';
  } else if (s.model_type) {
    // Single saved type
    const normType = s.model_type === 'gguf_models' ? 'text_models' : s.model_type;
    let savedSubs = new Set(s.video_subtypes || []);
    // Legacy video_models with no sub-types: infer from capabilities or default T2V
    if (normType === 'video_models' && savedSubs.size === 0) {
      const caps = m.capabilities || [];
      if (caps.includes('image_to_video')) savedSubs.add('i2v');
      if (caps.includes('video_to_video')) savedSubs.add('v2v');
      if (caps.includes('video_generation') || savedSubs.size === 0) savedSubs.add('t2v');
    }
    _setTypeCheckboxes(new Set([normType]), savedSubs);
    det.textContent = '';
  } else {
    // Auto-detect from capabilities
    const caps = m.capabilities || [];
    const {categories, subs} = _capabilitiesToTypes(caps);
    if (categories.size > 0) {
      _setTypeCheckboxes(categories, subs);
      det.textContent = '(auto-detected)';
    } else {
      const rawType = m.defaultType === 'gguf_models' ? 'text_models' : (m.defaultType || 'text_models');
      let fallbackSubs = new Set();
      if (rawType === 'video_models') {
        // No capabilities matched; default to T2V for unclassified video models
        fallbackSubs.add('t2v');
      }
      _setTypeCheckboxes(new Set([rawType]), fallbackSubs);
      det.textContent = m.cacheType === 'gguf' ? '(auto-detected: GGUF text model)' : '';
    }
  }

  document.getElementById('cfg-alias').value = s.alias || '';
  document.getElementById('cfg-backend').value = s.backend || 'auto';
  document.getElementById('cfg-load-mode').value = s.load_mode || 'on-request';
  // Used VRAM
  const usedVram = s.used_vram_gb != null ? s.used_vram_gb : null;
  document.getElementById('cfg-used-vram').value = usedVram != null ? usedVram : '';
  // Show estimate hint (weights × 1.15 + KV cache ~0.5 MB per 1 K ctx)
  const nCtxForEst = s.n_ctx || 2048;
  const estVram = _estimateVram(m, nCtxForEst);
  document.getElementById('cfg-used-vram-hint').textContent = estVram ? `Estimated: ~${estVram.toFixed(1)} GB` : '';
  document.getElementById('cfg-gpu-layers').value = s.n_gpu_layers !== undefined ? s.n_gpu_layers : -1;
  document.getElementById('cfg-n-ctx').value = nCtxForEst;
  document.getElementById('cfg-max-instances').value = s.max_instances != null ? s.max_instances : 1;
  document.getElementById('cfg-preload-all-instances').checked = !!s.preload_all_instances;
  _updatePreloadAllVisibility();
  document.getElementById('cfg-max-gpu').value = s.max_gpu_percent != null ? s.max_gpu_percent : '';
  document.getElementById('cfg-ram-gb').value = s.manual_ram_gb != null ? s.manual_ram_gb : '';
  document.getElementById('cfg-4bit').checked = !!s.load_in_4bit;
  document.getElementById('cfg-8bit').checked = !!s.load_in_8bit;
  document.getElementById('cfg-flash').checked = !!s.flash_attention;
  document.getElementById('cfg-noram').checked = !!s.no_ram;
  document.getElementById('cfg-offload-strategy').value = s.offload_strategy || 'auto';
  document.getElementById('cfg-offload-dir').value = s.offload_dir || _defaultOffloadDir;
  document.getElementById('cfg-sysprompt').value = s.system_prompt || '';
  document.getElementById('cfg-parser').value = s.parser || 'auto';
  document.getElementById('cfg-tools').checked = !!s.tools_closer_prompt;
  document.getElementById('cfg-grammar').checked = !!s.grammar_guided;
  openModal('cfg-modal');
}

function _updatePreloadAllVisibility() {
  const loadMode = document.getElementById('cfg-load-mode').value;
  const maxInst = parseInt(document.getElementById('cfg-max-instances').value) || 1;
  const row = document.getElementById('cfg-preload-all-row');
  row.style.display = (loadMode === 'load' && maxInst > 1) ? '' : 'none';
}

function _estimateVram(m, nCtx) {
  // Mirror the server-side formula: weights × 1.15 + KV cache (0.5 MB per 1 K ctx)
  if (!m.size_gb) return null;
  const weights = m.size_gb * (m.cacheType === 'gguf' ? 1.0 : 1.1);
  const kvCacheGb = ((nCtx || 2048) / 1024) * 0.5 / 1024;
  return weights * 1.15 + kvCacheGb;
}

async function saveModelConfig(){
  const path = document.getElementById('cfg-path').value;
  const maxGpu = parseFloat(document.getElementById('cfg-max-gpu').value);
  const ramGb  = parseFloat(document.getElementById('cfg-ram-gb').value);
  const usedVram = parseFloat(document.getElementById('cfg-used-vram').value);
  const {primaryType, model_types, video_subtypes} = _getCheckedTypes();
  const origPath = document.getElementById('cfg-orig-path').value;
  const data = {
    path,
    orig_path:         (origPath && origPath !== path) ? origPath : undefined,
    model_type:        primaryType,
    model_types:       model_types,
    video_subtypes:    video_subtypes.length ? video_subtypes : undefined,
    alias:             document.getElementById('cfg-alias').value.trim() || null,
    backend:           document.getElementById('cfg-backend').value,
    load_mode:         document.getElementById('cfg-load-mode').value,
    used_vram_gb:      isNaN(usedVram) ? null : usedVram,
    max_instances:     parseInt(document.getElementById('cfg-max-instances').value) || 1,
    preload_all_instances: document.getElementById('cfg-preload-all-instances').checked,
    n_gpu_layers:      parseInt(document.getElementById('cfg-gpu-layers').value) || -1,
    n_ctx:             parseInt(document.getElementById('cfg-n-ctx').value) || 2048,
    max_gpu_percent:   isNaN(maxGpu) ? null : maxGpu,
    manual_ram_gb:     isNaN(ramGb) ? null : ramGb,
    load_in_4bit:      document.getElementById('cfg-4bit').checked,
    load_in_8bit:      document.getElementById('cfg-8bit').checked,
    flash_attention:   document.getElementById('cfg-flash').checked,
    no_ram:            document.getElementById('cfg-noram').checked,
    offload_strategy:  document.getElementById('cfg-offload-strategy').value,
    offload_dir:       document.getElementById('cfg-offload-dir').value.trim() || './offload',
    system_prompt:     document.getElementById('cfg-sysprompt').value.trim() || null,
    parser:            document.getElementById('cfg-parser').value,
    tools_closer_prompt: document.getElementById('cfg-tools').checked,
    grammar_guided:    document.getElementById('cfg-grammar').checked,
  };
  try{
    const r = await fetch('/admin/api/model-configure',{
      method:'POST', headers:{'Content-Type':'application/json'},
      body: JSON.stringify(data)
    });
    const d = await r.json();
    if(d.success){ closeModal('cfg-modal'); loadCachedModels(); }
    else alert('Error: '+(d.detail||'Unknown'));
  }catch(e){ alert('Error: '+e.message); }
}

async function addWhisperServerModel(){
  const usedVram = parseFloat(document.getElementById('ws-used-vram').value);
  const modelSource = document.getElementById('ws-model-source').value;
  const payload = {
    model_id: document.getElementById('ws-model-id').value.trim(),
    model_type: 'audio_models',
    backend: 'whisper-server',
    model_source: modelSource,
    server_path: document.getElementById('ws-server-path').value.trim(),
    model_path: getWhisperServerModelPath(),
    port: parseInt(document.getElementById('ws-port').value, 10) || 8744,
    gpu_device: parseInt(document.getElementById('ws-gpu-device').value, 10) || 0,
    load_mode: document.getElementById('ws-load-mode').value,
    used_vram_gb: Number.isNaN(usedVram) ? null : usedVram,
    alias: document.getElementById('ws-alias').value.trim() || null,
  };
  try{
    const r = await fetch('/admin/api/model-configure', {
      method:'POST',
      headers:{'Content-Type':'application/json'},
      body: JSON.stringify(payload)
    });
    const d = await r.json();
    if(!r.ok) throw new Error(d.detail || 'Failed to add whisper-server model');
    document.getElementById('ws-model-id').value = nextWhisperServerModelId();
    document.getElementById('ws-server-path').value = defaultWhisperServerPath();
    document.getElementById('ws-model-source').value = 'cached-gguf';
    document.getElementById('ws-gguf-select').value = d.model_path || '';
    document.getElementById('ws-model-path').value = '';
    document.getElementById('ws-port').value = '8744';
    document.getElementById('ws-gpu-device').value = '0';
    document.getElementById('ws-load-mode').value = 'on-request';
    document.getElementById('ws-used-vram').value = '';
    document.getElementById('ws-alias').value = '';
    document.getElementById('ws-submit-btn').textContent = 'Add model';
    toggleWhisperModelSource();
    refreshLocal();
  }catch(e){ alert('Error: '+e.message); }
}

toggleWhisperModelSource();

async function loadModel(idx){
  const m = _localModels[idx];
  // Find the button and show loading state
  const btn = document.querySelector(`button[onclick="loadModel(${idx})"]`);
  if(btn){ btn.disabled = true; btn.textContent = 'Loading…'; }
  try{
    const r = await fetch('/admin/api/model-load',{
      method:'POST', headers:{'Content-Type':'application/json'},
      body: JSON.stringify({path: m.path})
    });
    const d = await r.json();
    if(d.success) refreshLocal();
    else{ if(btn){ btn.disabled=false; btn.textContent='Load now'; } alert('Error: '+(d.detail||'Unknown')); }
  }catch(e){ if(btn){ btn.disabled=false; btn.textContent='Load now'; } alert('Error: '+e.message); }
}

async function unloadModel(idx){
  const m = _localModels[idx];
  try{
    const r = await fetch('/admin/api/model-unload',{
      method:'POST', headers:{'Content-Type':'application/json'},
      body: JSON.stringify({path: m.path})
    });
    const d = await r.json();
    if(d.success) refreshLocal();
    else alert('Error: '+(d.detail||'Unknown'));
  }catch(e){ alert('Error: '+e.message); }
}

async function disableModel(idx){
  const m = _localModels[idx];
  if(!confirm('Remove this model from CoderAI config? It will stay in the local cache.')) return;
  // Collect all paths that are marked in_config for this model (covers quant groups)
  const paths = m.ggufGroup
    ? m.ggufGroup.filter(f=>f.in_config).map(f=>f.path)
    : (m.in_config ? [m.path] : []);
  if(!paths.length) paths.push(m.path);  // fallback
  try{
    // Disable each configured path in the group
    for(const p of paths){
      await fetch('/admin/api/model-disable',{
        method:'POST', headers:{'Content-Type':'application/json'},
        body: JSON.stringify({path: p})
      });
    }
    loadCachedModels();
  }catch(e){ alert('Error: '+e.message); }
}

async function startUpload(){
  const fileInput = document.getElementById('upload-file');
  const file = fileInput.files[0];
  if(!file){ alert('Please select a file'); return; }
  if(!file.name.endsWith('.gguf')){ alert('Only .gguf files are supported'); return; }

  document.getElementById('upload-form').style.display='none';
  document.getElementById('upload-progress').style.display='block';
  document.getElementById('upload-filename').textContent = file.name;

  const chunkSize = 512 * 1024; // 512KB
  const totalChunks = Math.ceil(file.size / chunkSize);
  const uploadId = Date.now() + '_' + file.name;

  for(let i=0; i<totalChunks; i++){
    const start = i * chunkSize;
    const end = Math.min(start + chunkSize, file.size);
    const chunk = file.slice(start, end);

    const formData = new FormData();
    formData.append('chunk', chunk);
    formData.append('filename', file.name);
    formData.append('chunk_index', i);
    formData.append('total_chunks', totalChunks);
    formData.append('upload_id', uploadId);

    try{
      const r = await fetch('/admin/api/model-upload',{method:'POST', body:formData});
      const d = await r.json();
      if(!d.success){ alert('Upload failed: '+(d.detail||'Unknown')); return; }

      const pct = Math.round((i+1)/totalChunks*100);
      document.getElementById('upload-bar').style.width = pct+'%';
      document.getElementById('upload-pct').textContent = pct+'%';
      document.getElementById('upload-status').textContent = `Chunk ${i+1}/${totalChunks}`;

      if(d.complete){
        document.getElementById('upload-status').textContent = 'Complete!';
        setTimeout(()=>{
          closeModal('upload-modal');
          document.getElementById('upload-form').style.display='block';
          document.getElementById('upload-progress').style.display='none';
          fileInput.value='';
          refreshLocal();
        }, 1500);
        return;
      }
    }catch(e){ alert('Upload error: '+e.message); return; }
  }
}
</script>
{% endblock %}
