'use strict';
  let authReady = false;
  let isPublicMode = false;
  let publicShowNsfw = false;
  async function apiJSON(path, options = {}){
    const res = await fetch(path, {
      cache: 'no-store',
      credentials: 'same-origin',
      ...options,
      headers: {
        ...(options.body && !(options.body instanceof Blob) ? {'Content-Type':'application/json'} : {}),
        ...(options.headers || {})
      }
    });
    if(!res.ok){
      let message = res.statusText;
      try{ const js = await res.json(); message = js.error || message; }catch{}
      throw new Error(message || `HTTP ${res.status}`);
    }
    if(res.status === 204) return null;
    const text = await res.text();
    return text ? JSON.parse(text) : null;
  }
  async function ensureSession(){
    const shell = document.getElementById('loginShell');
    const status = document.getElementById('loginStatus');
    const form = document.getElementById('loginForm');
    async function check(){
      try{
        const session = await apiJSON('/api/session');
        authReady = !!session?.authenticated;
        isPublicMode = !!session?.publicMode;
        publicShowNsfw = !!session?.publicShowNsfw;
      }catch{ authReady = false; isPublicMode = false; publicShowNsfw = false; }
      if(shell) shell.classList.toggle('hidden', authReady || isPublicMode);
      return authReady || isPublicMode;
    }
    if(await check()) return true;
    if(status) status.textContent = '';
    if(form){
      form.onsubmit = async (e) => {
        e.preventDefault();
        const input = document.getElementById('adminPasswordInput');
        const button = document.getElementById('loginSubmitBtn');
        if(button) button.disabled = true;
        if(status) status.textContent = '验证中…';
        try{
          await apiJSON('/api/login', {method:'POST', body:JSON.stringify({password: input?.value || ''})});
          if(input) input.value = '';
          location.reload();
        }catch(err){
          if(status) status.textContent = '密码不正确，请重试';
        }finally{
          if(button) button.disabled = false;
        }
      };
    }
    return false;
  }
  async function changeAdminPassword(){
    const current = $('currentAdminPasswordInput')?.value || '';
    const next = $('newAdminPasswordInput')?.value || '';
    if(next.length < 10){ toast('New admin password must be at least 10 characters'); return; }
    try{
      await apiJSON('/api/admin/password', {method:'POST', body:JSON.stringify({currentPassword:current, newPassword:next})});
      $('currentAdminPasswordInput').value = '';
      $('newAdminPasswordInput').value = '';
      toast('Admin password changed');
    }catch(err){ toast('Password change failed: ' + err.message); }
  }
  async function logout(){
    try{ await apiJSON('/api/logout', {method:'POST'}); }catch{}
    location.reload();
  }
  function bgmApiBase(){ return (state.settings.bgmApiBase || '').trim().replace(/\/+$/,'') || 'https://api.bgm.tv'; }
  // Bangumi site base for opening subject/tag pages in the browser (mirror-friendly; not used server-side).
  function bgmSiteBase(){ return (state.settings.siteBase || '').trim().replace(/\/+$/,'') || 'https://bgm.tv'; }
  const TOKEN_URL = 'https://next.bgm.tv/demo/access-token';
  const SUBJECT_TYPES = [2,1,4,3,6];
  const COLLECTION_TYPES = [1,2,3,4,5];
  const STATUS = {0:'全部',1:'想看',2:'看过',3:'在看',4:'搁置',5:'抛弃',99:'安全箱'};
  const STATUS_BY_TYPE = {
    1:{1:'想读',2:'读过',3:'在读'},
    3:{1:'想听',2:'听过',3:'在听'},
    4:{1:'想玩',2:'玩过',3:'在玩'},
  };
  function statusLabelFor(ct, st){ return (STATUS_BY_TYPE[st]?.[ct]) || STATUS[ct] || '未知'; }
  const SUBJECT_LABEL = {1:'书籍',2:'动画',3:'音乐',4:'游戏',6:'三次元'};
  const BGM_TYPE_PATH = {1:'book',2:'anime',3:'music',4:'game',6:'real'};
  const DEFAULT_STATE = {schema:9, settings:{username:'', token:'', autoCacheCovers:false, autoSyncEnabled:false, autoSyncCron:'0 2 * * *', autoSyncMode:'recent', autoSyncRangeMode:'fixed', autoSyncRangeValue:3, autoSyncRangeUnit:'month', autoSyncLastRunKey:'', theme:'dark', themeMode:'dark', recentMonths:3, fontFamily:'system', cardSize:'standard', pageSize:120, commentLines:0, motion:'full', borderWidth:1, accentColor:'pink', bgmApiBase:'', imageProxy:'', siteBase:''}, collections:{}, history:[], runs:[]};
  let state = structuredClone(DEFAULT_STATE);
  let filters = {status:0, tag:'全部', type:0, q:'', sort:'updated_desc', nsfw:'all', hasComment:false};
  let runtimeToken = '';
  let activeDrawerId = null;
  let syncInProgress = false;
  let renderLimit = 0;

  const $ = (id) => document.getElementById(id);
  const nowISO = () => new Date().toISOString();
  const sleep = (ms) => new Promise(r => setTimeout(r, ms));
  const esc = (s) => String(s ?? '').replace(/[&<>"']/g, m => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#039;'}[m]));
  const safeName = (s) => String(s || 'bangumi_vault').replace(/[\\/:*?"<>|]+/g,'_').slice(0,80);
  function toast(msg){ const t=$('toast'); t.textContent=msg; t.classList.add('show'); clearTimeout(t._tm); t._tm=setTimeout(()=>t.classList.remove('show'),3200); }
  function log(id,msg){ const el=$(id); if(!el) return; el.textContent += `[${new Date().toLocaleTimeString()}] ${msg}\n`; el.scrollTop = el.scrollHeight; }
  function setProgress(p){ const pct=Math.max(0,Math.min(100,p)); $('syncBar').style.width = pct + '%'; const mf=$('syncMiniFill'); if(mf) mf.style.width = pct + '%'; const ml=$('syncMiniLabel'); if(ml) ml.textContent = `同步中 ${Math.round(pct)}%`; }
  function showSyncMini(){ const el=$('syncMiniBar'); if(el && syncInProgress){ el.classList.remove('smi-done'); el.classList.add('show'); } }
  function hideSyncMini(){ const el=$('syncMiniBar'); if(el) el.classList.remove('show'); }


  function clampNum(v, min, max, fallback){
    const n = Number(v);
    return Number.isFinite(n) ? Math.max(min, Math.min(max, n)) : fallback;
  }
  function pageSize(){ return clampNum(state.settings.pageSize, 40, 360, 120); }
  function resetRenderLimit(){ renderLimit = pageSize(); }
  function effectiveTheme(){
    const mode = state.settings.themeMode || state.settings.theme || 'dark';
    if(mode === 'system') return window.matchMedia?.('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
    return mode === 'light' ? 'light' : 'dark';
  }
  // Theme toggle icons (stroke = currentColor, matching .tag-search-toggle). Dark active → sun (click to lighten); light → moon.
  const THEME_ICON_SUN = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="4.2"/><path d="M12 2.5v2.2M12 19.3v2.2M4.5 4.5l1.6 1.6M17.9 17.9l1.6 1.6M2.5 12h2.2M19.3 12h2.2M4.5 19.5l1.6-1.6M17.9 6.1l1.6-1.6"/></svg>';
  const THEME_ICON_MOON = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M21 12.8A9 9 0 1 1 11.2 3a7 7 0 0 0 9.8 9.8z"/></svg>';
  function applyAppearance(animate=false, ev=null){
    const update = () => {
      const root = document.documentElement;
      const theme = effectiveTheme();
      root.dataset.theme = theme;
      root.dataset.themeMode = state.settings.themeMode || state.settings.theme || 'dark';
      root.dataset.font = state.settings.fontFamily || 'system';
      root.dataset.cardSize = state.settings.cardSize || 'standard';
      root.dataset.motion = state.settings.motion || 'full';
      root.dataset.accent = state.settings.accentColor || 'pink';
      root.style.setProperty('--comment-lines', String(clampNum(state.settings.commentLines, 0, 6, 0)));
      root.style.setProperty('--ui-border-width', clampNum(state.settings.borderWidth, 0, 4, 1) + 'px');
      root.dataset.borderZero = clampNum(state.settings.borderWidth, 0, 4, 1) === 0 ? 'true' : 'false';
      const themeIcon = theme === 'dark' ? THEME_ICON_SUN : THEME_ICON_MOON;
      document.querySelectorAll('.theme-toggle').forEach(b => b.innerHTML = themeIcon);
    };
    if(!animate || !document.startViewTransition){ update(); return; }
    const x = ev?.clientX ?? (window.innerWidth - 96);
    const y = ev?.clientY ?? 38;
    const endRadius = Math.hypot(Math.max(x, window.innerWidth - x), Math.max(y, window.innerHeight - y));
    const transition = document.startViewTransition(update);
    transition.ready.then(() => {
      document.documentElement.animate({clipPath:[`circle(0px at ${x}px ${y}px)`,`circle(${endRadius}px at ${x}px ${y}px)`], filter:['blur(18px)','blur(0px)']}, {duration:520, easing:'cubic-bezier(.22,1,.36,1)', pseudoElement:'::view-transition-new(root)'});
      document.documentElement.animate({filter:['blur(0px)','blur(18px)']}, {duration:520, easing:'cubic-bezier(.22,1,.36,1)', pseudoElement:'::view-transition-old(root)'});
    }).catch(()=>{});
  }
  async function setThemeMode(mode, ev){
    state.settings.themeMode = mode;
    state.settings.theme = mode === 'system' ? effectiveTheme() : mode;
    applyAppearance(true, ev);
    refreshSegmentedControls();
    await saveSettings();
  }
  async function toggleTheme(ev){
    const next = effectiveTheme() === 'dark' ? 'light' : 'dark';
    await setThemeMode(next, ev);
  }
  function refreshSegmentedControls(){
    document.querySelectorAll('[data-segment]').forEach(group => {
      const key = group.dataset.segment;
      const value = String(state.settings[key] ?? DEFAULT_STATE.settings[key] ?? '');
      group.querySelectorAll('button[data-value]').forEach(btn => btn.classList.toggle('active', String(btn.dataset.value) === value));
    });
    if($('pageSizeInput')) $('pageSizeInput').value = pageSize();
    if($('commentLinesInput')) $('commentLinesInput').value = clampNum(state.settings.commentLines, 0, 6, 0);
    if($('borderWidthInput')) $('borderWidthInput').value = clampNum(state.settings.borderWidth, 0, 4, 1);
  }
  const APPEARANCE_SEGMENTS = ['themeMode','fontFamily','cardSize','motion','accentColor'];
  // Appearance preview snapshot: admin previews on click and we revert on close-without-save.
  let appearanceSnapshot = null;
  const APPEARANCE_KEYS = ['themeMode','theme','fontFamily','cardSize','motion','accentColor','pageSize','commentLines','borderWidth'];
  function snapshotAppearance(){ appearanceSnapshot = {}; for(const k of APPEARANCE_KEYS) appearanceSnapshot[k] = state.settings[k]; }
  function restoreAppearance(){
    if(!appearanceSnapshot) return;
    let changed = false;
    for(const k of APPEARANCE_KEYS){ if(state.settings[k] !== appearanceSnapshot[k]){ state.settings[k] = appearanceSnapshot[k]; changed = true; } }
    if(changed){ applyAppearance(false); refreshSegmentedControls(); renderCards(); }
  }
  function bindSegmentedControls(){
    document.querySelectorAll('[data-segment]').forEach(group => {
      const key = group.dataset.segment;
      group.querySelectorAll('button[data-value]').forEach(btn => {
        btn.onclick = async (e) => {
          const value = btn.dataset.value;
          state.settings[key] = value;
          if(key === 'themeMode'){
            state.settings.theme = value === 'system' ? effectiveTheme() : value;
            applyAppearance(true, e);
          } else {
            applyAppearance(false);
          }
          refreshSegmentedControls();
          if(APPEARANCE_SEGMENTS.includes(key)){
            // Admin: click is preview-only (persisted via 保存设置). Guest: no save button, so persist locally now.
            if(!authReady) await saveSettings();
            if(key === 'cardSize' || key === 'motion') renderCards();
          }
          if(key === 'autoSyncMode' || key === 'autoSyncEnabled') updateAutoSyncHelp();
          if(key === 'autoSyncEnabled' && typeof refreshAutoSyncCollapse === 'function') refreshAutoSyncCollapse();
        };
      });
    });
  }

  function openDB(){
    return new Promise((resolve,reject)=>{
      const req = indexedDB.open('BangumiVaultPosterWall', 1);
      req.onupgradeneeded = () => { const db=req.result; if(!db.objectStoreNames.contains('kv')) db.createObjectStore('kv', {keyPath:'key'}); };
      req.onsuccess = () => resolve(req.result); req.onerror = () => reject(req.error);
    });
  }
  async function idbGet(key){ const db=await openDB(); return new Promise((resolve,reject)=>{ const tx=db.transaction('kv','readonly'); const req=tx.objectStore('kv').get(key); req.onsuccess=()=>resolve(req.result?.value); req.onerror=()=>reject(req.error); }); }
  async function idbSet(key,value){ const db=await openDB(); return new Promise((resolve,reject)=>{ const tx=db.transaction('kv','readwrite'); tx.objectStore('kv').put({key,value}); tx.oncomplete=()=>resolve(); tx.onerror=()=>reject(tx.error); }); }
  async function idbDelete(key){ const db=await openDB(); return new Promise((resolve,reject)=>{ const tx=db.transaction('kv','readwrite'); tx.objectStore('kv').delete(key); tx.oncomplete=()=>resolve(); tx.onerror=()=>reject(tx.error); }); }
  const LOCAL_API = (location.protocol === 'http:' || location.protocol === 'https:') ? location.origin : '';
  let localApiKnown = null;
  async function localApiAvailable(){
    if(!LOCAL_API) return false;
    if(localApiKnown !== null) return localApiKnown;
    try{ const r = await fetch('/api/ping', {cache:'no-store'}); localApiKnown = !!(r.ok); }
    catch{ localApiKnown = false; }
    return localApiKnown;
  }
  async function localGetJSON(path){ return await apiJSON(path); }
  async function localPostJSON(path, obj){ return await apiJSON(path, {method:'POST', body:JSON.stringify(obj)}); }
  // History is no longer bundled into /api/state (kept the payload huge); fetch it on demand.
  async function fetchSubjectHistory(id){
    if(window.BANGUMI_VAULT_OFFLINE_STATE) return (state.history||[]).filter(h=>String(h.subject_id)===String(id));
    try{ const r = await apiJSON('/api/history?subject_id='+encodeURIComponent(id)); return r?.history || []; }catch{ return []; }
  }
  // Subject infobox (game type/engine/developer…) is stripped from /api/state. Kept in a separate
  // in-memory cache (NOT in `state`, so saveState never posts it back and re-bloats app_state.json).
  let infoboxCache = {};
  let infoboxPrefetch = null;
  async function fetchSubjectInfobox(id){
    if(window.BANGUMI_VAULT_OFFLINE_STATE){ const local = state.collections?.[id]?.infobox; return Array.isArray(local) ? local : []; }
    if(Array.isArray(infoboxCache[id])) return infoboxCache[id];
    // Warm-up window: wait for the in-flight bulk prefetch instead of firing another slow per-subject query.
    if(infoboxPrefetch){ try{ await infoboxPrefetch; }catch{} if(Array.isArray(infoboxCache[id])) return infoboxCache[id]; }
    try{ const r = await apiJSON('/api/subject-meta?subject_id='+encodeURIComponent(id)); const ib = r?.infobox || []; infoboxCache[id] = ib; return ib; }catch{ return []; }
  }
  // Background-prefetch every infobox after load so opening any drawer shows 条目资料 instantly.
  function prefetchInfoboxes(){
    if(window.BANGUMI_VAULT_OFFLINE_STATE) return;
    infoboxPrefetch = (async () => {
      try{
        const r = await apiJSON('/api/infoboxes');
        const map = (r && r.infoboxes) || {};
        const next = {};
        for(const id in state.collections) next[id] = Array.isArray(map[id]) ? map[id] : [];
        infoboxCache = next;
      }catch{}
    })();
    return infoboxPrefetch;
  }
  // Pull the full history back into state (only needed before a backup export).
  async function loadFullHistory(){
    if(window.BANGUMI_VAULT_OFFLINE_STATE) return;
    if(Array.isArray(state.history) && state.history.length) return;
    try{ const r = await apiJSON('/api/history'); if(r?.history) state.history = r.history; }catch{}
  }
  // Appearance/display prefs a guest may keep locally (never affects the shared library).
  const GUEST_PREF_KEYS = ['theme','themeMode','fontFamily','cardSize','motion','borderWidth','pageSize','commentLines'];
  async function applyGuestPrefs(){
    if(authReady || !isPublicMode) return;
    try{
      const prefs = await idbGet('guestSettings');
      if(prefs) for(const k of GUEST_PREF_KEYS){ if(prefs[k] !== undefined) state.settings[k] = prefs[k]; }
    }catch{}
  }
  async function loadState(){
    try{
      if(window.BANGUMI_VAULT_OFFLINE_STATE){
        state = mergeState(window.BANGUMI_VAULT_OFFLINE_STATE);
      } else {
        if(await localApiAvailable()){
          const loaded = await localGetJSON('/api/state');
          if(loaded){ state = mergeState(loaded); await applyGuestPrefs(); }
        }
        // Server (PostgreSQL) is the single source of truth — purge any legacy full-state browser cache.
        idbDelete('state').catch(()=>{}); try{ localStorage.removeItem('BangumiVaultState'); }catch{}
      }
    }catch(e){ console.warn(e); }
    runtimeToken = state.settings.token || '';
    applyAppearance(false); resetRenderLimit();
    // Warm the infobox cache ASAP (fire-and-forget; network-bound so it won't block render). Re-runs after each sync's loadState.
    if(!window.BANGUMI_VAULT_OFFLINE_STATE) prefetchInfoboxes();
  }
  function mergeState(loaded){
    const settings = {...DEFAULT_STATE.settings, ...(loaded.settings||{})}; if(!settings.themeMode) settings.themeMode = settings.theme || 'dark'; return {schema:9, settings, collections:loaded.collections||{}, history:loaded.history||[], runs:loaded.runs||[]};
  }
  async function saveState(){
    if(window.BANGUMI_VAULT_OFFLINE_STATE) return;
    if(!authReady && isPublicMode){
      try{ await idbSet('guestSettings', structuredClone(state.settings)); }catch{}
      return;
    }
    if(await localApiAvailable()) await localPostJSON('/api/state', structuredClone(state));
  }
  // Save only the settings subtree; never touch collections (avoids clobbering server-side sync results).
  async function saveSettings(){
    if(window.BANGUMI_VAULT_OFFLINE_STATE) return;
    const settings = structuredClone(state.settings);
    if(!authReady && isPublicMode){
      try{ await idbSet('guestSettings', settings); }catch{}
      return;
    }
    if(await localApiAvailable()) await localPostJSON('/api/settings', {settings});
  }
  // Update one collection's status fields (safety box). Updates local state then patches the server row.
  async function setCollectionStatus(id, status, missingCount){
    const row = state.collections[String(id)];
    if(row){ row.status = status; if(missingCount !== undefined) row.missing_count = missingCount; }
    try{ await localPostJSON('/api/collections/status', {subject_id:String(id), status, missing_count:missingCount}); }
    catch(e){ toast('状态更新失败：' + e.message); }
  }

