/* PLaMo 翻訳ゲートウェイ
 *
 * llama-server（llama.cpp）の前に立って、ゲーム翻訳に必要な三つを足す。
 *
 *   1. プレースホルダ保護 — ${expr} や {name}、%s、<b> といった「訳してはいけない
 *      もの」を伏せ字にしてから渡し、戻ってきた訳文に差し戻す。Lowmoor の
 *      日本語化で踏んだ地雷（DOM のセレクタや JS の式が翻訳される）を、
 *      翻訳器の側で潰しておくための層。
 *   2. 用語集 — 訳語を固定したい語も同じ伏せ字の仕組みに乗せる。翻訳を挟まずに
 *      決め打ちの訳語へ差し戻すので、揺れが構造的に起きない。
 *   3. 翻訳メモリ — 原文をキーにした JSON。同じ文を二度モデルに通さない。
 *
 * 依存パッケージなし（Node 標準ライブラリのみ）。
 */

import http from 'node:http';
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';

const HERE = path.dirname(fileURLToPath(import.meta.url));
const ROOT = path.resolve(HERE, '..');

const CFG = {
  port: +(process.env.PLAMO_PORT || 8080),
  llama: process.env.PLAMO_LLAMA || 'http://127.0.0.1:8081',
  tmPath: process.env.PLAMO_TM || path.join(ROOT, 'data', 'tm.json'),
  glossaryPath: process.env.PLAMO_GLOSSARY || path.join(ROOT, 'data', 'glossary.json'),
  stylePath: process.env.PLAMO_STYLE || path.join(ROOT, 'data', 'style.json'),
  // 既定は 1。増やしてはいけない。
  //
  // PLaMo-2 は再帰状態を持つ（Mamba ハイブリッド）。llama-server のスロットを
  // 2 本使うと状態が混ざり、出力が崩れる。しかも遅い。実測（同じ 8 文）:
  //   並列1 → 6.3 秒 / 崩れ 0 件
  //   並列2 → 21.1 秒 / 崩れ 1 件（訳文が "en" だけになる）
  // 逐次のほうが速くて正しいので、既定を 1 にしてある。
  concurrency: +(process.env.PLAMO_CONCURRENCY || 1),
};

fs.mkdirSync(path.dirname(CFG.tmPath), { recursive: true });

/* ------------------------------------------------------------------ *
 * 保存: 翻訳メモリと用語集
 * ------------------------------------------------------------------ */
const loadJson = (p, dflt) => {
  try { return JSON.parse(fs.readFileSync(p, 'utf8')); } catch { return dflt; }
};
let TM = loadJson(CFG.tmPath, {});
let GLOSSARY = loadJson(CFG.glossaryPath, {});

let tmDirty = false;
const saveTM = () => {
  if (!tmDirty) return;
  fs.writeFileSync(CFG.tmPath, JSON.stringify(TM, null, 1));
  tmDirty = false;
};
setInterval(saveTM, 5000).unref();
process.on('exit', saveTM);

const saveGlossary = () =>
  fs.writeFileSync(CFG.glossaryPath, JSON.stringify(GLOSSARY, null, 1));

const tmKey = (text, src, tgt, profile) => `${src}>${tgt}|${profile}|${text}`;

/* ------------------------------------------------------------------ *
 * プレースホルダ保護
 *
 * 伏せ字は ⟦0⟧ の形。モデルが訳したり並べ替えたりしにくく、かつ日本語の
 * 文中にあっても壊れにくい記号を選んである。戻すときに一つでも欠けていたら
 * その訳は採用しない（原文をそのまま返す）。壊れた訳文を通すより、訳さずに
 * 気づける方がよい。
 * ------------------------------------------------------------------ */
const PROFILES = {
  // 汎用。HTML タグと一般的な書式指定子を守る。
  plain: [
    /<\/?[a-zA-Z][^>]*>/g,          // <b>, </span>, <br/>
    /%(?:\d+\$)?[sdifx]/g,          // %s, %d, %1$s
    /\\[nrt]/g,                     // 文字列中の \n
  ],
  // ゲームやソフトの文字列。JS のテンプレート式や {name} 形式も守る。
  game: [
    /\$\{[^}]*\}/g,                 // ${expr}
    /\{\{[^}]*\}\}/g,               // {{var}}
    /\{[A-Za-z_][\w]*\}/g,          // {name}
    /%(?:\d+\$)?[sdifx]/g,
    /\[[A-Z][A-Z0-9_]*\]/g,         // [PLAYER]
    /<\/?[a-zA-Z][^>]*>/g,
    /\\[nrt]/g,
  ],
  // 何も守らない。素の散文を訳すとき。
  raw: [],
};

/* 伏せ字の記号。
 * 数字を使うと（⟦4⟧）モデルが序数だと読んで「4番目の走者が…」のような
 * 訳を作ることがあった。番号ではなく英字にすると、置き場所の目印として
 * 素通ししてくれる。 */
const SENT = i => {
  let s = '', n = i;
  do { s = String.fromCharCode(65 + (n % 26)) + s; n = Math.floor(n / 26) - 1; } while (n >= 0);
  return `⟦${s}⟧`;
};
/* 戻すときは括弧の形を問わない。
 * モデルが ⟦E⟧ を ［E］ や 〈A〉 に打ち直して返すことがある（日本語の組版に
 * 引き寄せられるらしい）。厳密に ⟦⟧ だけを見ていると、正しく訳せているのに
 * 「プレースホルダを失った」と判定して原文に差し戻していた——長文の 12% が
 * これで捨てられていた。見慣れた括弧はどれも受け取る。 */
const SENT_RE = /[⟦［\[〈《【〔＜‹]([A-Z]{1,3})[⟧］\]〉》】〕＞›]/g;

/* 検出は広く、復元は狭く。
 *
 * 復元に使う括弧（上）は、日本語の地の文でまず出てこない形に絞ってある。
 * 「A」や (A) まで復元対象にすると、本当に「A」と書きたかった文を壊しかねない。
 *
 * 一方、残ってしまった伏せ字の検出（下）は鉤括弧や丸括弧も含めて広く見る。
 * 訳文に ＜A＞ や「A」が残るのは、英語のまま残るよりたちが悪い。
 * 引っかかったらその訳は採用せず、原文を返す。 */
const LEFTOVER_RE = /[⟦［\[〈《【〔＜‹「『（｛(]([A-Z]{1,3})[⟧］\]〉》】〕＞›」』）｝)]/g;

// 「実際に立てた符号」だけを残存とみなす。
//
// 括弧つきの大文字を何でも拾うと、（CON）や（INT）——能力値の略号——まで
// 伏せ字と読んでしまう。1.6.0 で説明文にこの書き方が増え、正しく訳せている文が
// 大量に突き返された（50件中47件）。符号は A, B, C… と順に振るので、
// 使った数より後ろの符号は本文中の括弧である。
const leftoverSentinel = (s, slotCount) => {
  if (!slotCount) return false;
  LEFTOVER_RE.lastIndex = 0;
  for (const m of s.matchAll(LEFTOVER_RE)) {
    const i = sentIndex(m[1]);
    if (i >= 0 && i < slotCount) return true;
  }
  return false;
};
const sentIndex = s => [...s].reduce((a, c) => a * 26 + (c.charCodeAt(0) - 64), 0) - 1;

/* 用語集を伏せ字にしてよいかどうか。
 *
 * 全部伏せると文脈が消える。実測では一文に八つも伏せ字が立って、モデルが
 * 意味を取れずに崩れた訳を返した。伏せてよいのは「訳語が一意に決まっていて、
 * かつ文の骨組みを担わない語」だけ——つまり固有名詞と、複数語の専門用語。
 * それ以外（gold, chronicle のような普通名詞）はモデルに訳させたほうが
 * 文として自然になる。UI から hard を立てれば強制できる。 */
const MASK_CAP = 6;   // 一文に立てる伏せ字の上限（長い語を優先）
const glossEntry = v => (typeof v === 'object' && v ? v : { ja: v });
const isHardTerm = (term, ent) =>
  ent.hard === true ||
  (ent.hard !== false && (/\s|-/.test(term) || /^[A-Z]/.test(term)));

function maskText(text, profile, glossary) {
  const slots = [];   // {kind:'term'|'code', value}
  let out = text;

  // 長い語から当てる（"Roll of Honour" が "Roll" に食われないように）
  const terms = Object.keys(glossary || {})
    .filter(t => t && isHardTerm(t, glossEntry(glossary[t])))
    .sort((a, b) => b.length - a.length);

  let masked = 0;
  for (const term of terms) {
    if (masked >= MASK_CAP) break;
    const ent = glossEntry(glossary[term]);
    const boundary = /^[\w][\w '’\-]*$/.test(term);
    const esc = term.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
    const re = new RegExp(boundary ? `\\b${esc}\\b` : esc, 'g');
    out = out.replace(re, m => {
      if (masked >= MASK_CAP) return m;
      masked++;
      const i = slots.length;
      slots.push({ kind: 'term', value: ent.ja });   // 戻すのは訳語そのもの
      return SENT(i);
    });
  }

  // コード側のプレースホルダ。こちらは原文をそのまま戻す。
  for (const re of PROFILES[profile] || PROFILES.plain) {
    out = out.replace(re, m => {
      const i = slots.length;
      slots.push({ kind: 'code', value: m });
      return SENT(i);
    });
  }
  return { masked: out, slots };
}

// 日本語は語の間に空白を置かない。伏せ字を戻したところに英文由来の空白が
// 残ると「ギルド の鐘」のように間延びするので、日本語と隣り合う空白だけ削る。
const JA_CH = 'ぁ-んァ-ヶ一-龥々〆ー、。「」『』（）：；！？・';
const tidySpaces = s => s
  .replace(new RegExp(`([${JA_CH}]) +`, 'g'), '$1')
  .replace(new RegExp(` +([${JA_CH}])`, 'g'), '$1');

/* 日本語に複数形はない。
 * 英語の複数形をそのまま片仮名にして「ゴブリンs」「バグベアs」と落ちてくることが
 * ある（元の文字列が goblins のときや、ゲーム側が名詞に s を足しているとき）。
 * 日本語の直後に裸で立っている s / es は意味を持たないので削る。
 * %s のような書式指定子は伏せ字になっているので、ここには来ない。 */
const dropPluralS = s => s
  .replace(new RegExp(`([${JA_CH}])(?:es|s)\\b`, 'g'), '$1')
  .replace(new RegExp(`([${JA_CH}])(?:es|s)(?=[${JA_CH}])`, 'g'), '$1');

function unmaskText(translated, slots) {
  if (!slots.length) return { text: translated, ok: true };
  const seen = new Set();
  let bad = false;

  // 一段目: 用語集の訳語だけ戻して、空白を整える。
  //   コード側（${...} や <b>）はまだ伏せたままにしておく。中の空白を
  //   潰してしまわないため。
  let text = translated.replace(SENT_RE, (m, n) => {
    const i = sentIndex(n);
    // 範囲外の [A] は、伏せ字ではなく本文中の括弧。触らずに残す。
    // 本当の取りこぼしは下の seen.size の照合で拾う。
    if (!(i >= 0 && i < slots.length)) return m;
    if (slots[i].kind !== 'term') return m;
    seen.add(i);
    return slots[i].value;
  });
  text = dropPluralS(tidySpaces(text));

  // 二段目: コード側をそのまま戻す。
  text = text.replace(SENT_RE, (m, n) => {
    const i = sentIndex(n);
    // 範囲外の [A] は、伏せ字ではなく本文中の括弧。触らずに残す。
    // 本当の取りこぼしは下の seen.size の照合で拾う。
    if (!(i >= 0 && i < slots.length)) return m;
    seen.add(i);
    return slots[i].value;
  });

  // 何が戻らなかったかを種類別に数える。コード側の欠落だけが致命傷。
  let lostCode = 0, lostTerm = 0;
  slots.forEach((s, i) => { if (!seen.has(i)) (s.kind === 'code' ? lostCode++ : lostTerm++); });
  if (lostCode || lostTerm) bad = true;
  return { text, ok: !bad, lostCode, lostTerm };
}

/* ------------------------------------------------------------------ *
 * PLaMo のプロンプト
 *
 * plamo-2-translate は対話用に調整されていない。決められたタグの形で
 * 与えることが前提で、指示文を書いても効かない。
 * ------------------------------------------------------------------ */
const LANG = { ja: 'Japanese', en: 'English' };
const langName = c => LANG[c] || c;

/* 文体の見本。
 *
 * plamo-2-translate は対話用に調整されていないので「常体で訳して」とは頼めない。
 * 代わりに、望む文体の対訳を一組だけ前に置く。同じ形式のまま、モデルはその
 * 口調に引っ張られる。
 *
 * 実測（素の設定で敬体になった20文で比較）:
 *   見本なし → 敬体 16 件
 *   見本あり → 敬体  3 件
 *
 * ゲームの地の文は常体で揃っているほうが読みやすいので、既定で常体の一組を
 * 入れてある。作品ごとに差し替えられる（画面の「文体」、または /api/style）。 */
let STYLE = loadJson(CFG.stylePath, {
  'en>ja': {
    src: 'A cold rain falls upon Mereward, and the waters run down into the darkness of the Maw.',
    tgt: '冷たい雨がメアワードに降り、その水は大口の闇へと流れ落ちていく。',
  },
});
const saveStyle = () => fs.writeFileSync(CFG.stylePath, JSON.stringify(STYLE, null, 1));

const buildPrompt = (text, src, tgt) => {
  const ex = STYLE[`${src}>${tgt}`];
  const head = '<|plamo:op|>dataset\ntranslation\n';
  const shot = (ex && ex.src && ex.tgt)
    ? `<|plamo:op|>input lang=${langName(src)}\n${ex.src}\n` +
      `<|plamo:op|>output lang=${langName(tgt)}\n${ex.tgt}\n`
    : '';
  return head + shot +
    `<|plamo:op|>input lang=${langName(src)}\n${text}\n` +
    `<|plamo:op|>output lang=${langName(tgt)}\n`;
};

const STOP = ['<|plamo:op|>', '<|plamo:bos|>', '<|plamo:eos|>'];

async function llamaComplete(prompt, opts = {}) {
  const body = {
    prompt,
    n_predict: opts.maxTokens ?? 1024,
    temperature: opts.temperature ?? 0,
    top_p: 1,
    repeat_penalty: 1.0,
    stop: STOP,
    // 必ず false のままにすること。
    // PLaMo-2 は Mamba ハイブリッドで再帰状態を持つ。llama.cpp のプロンプト
    // キャッシュはその状態を正しく巻き戻せず、前の文の影響が残って
    // まったく無関係な文章を生成することがある（実測: 同じ入力で三回とも
    // 「この問題では、与えられた文字列の…」という別物を返した。false に
    // すると正しく「階段はまっすぐ正確に下っている。」を返す）。
    // 翻訳では毎回プロンプトが変わるので、キャッシュの利得もほぼない。
    cache_prompt: false,
    stream: false,
  };
  const res = await fetch(CFG.llama + '/completion', {
    method: 'POST',
    headers: { 'content-type': 'application/json' },
    body: JSON.stringify(body),
  });
  if (!res.ok) throw new Error(`llama-server ${res.status}: ${(await res.text()).slice(0, 300)}`);
  const j = await res.json();
  return (j.content || '').trim();
}

/* 生成が崩れていないか。崩れ方には型がある:
 *   - 訳先の文字がまったく出てこない（"en" だけ返す、英語のまま返す）
 *   - 同じ断片を延々と繰り返す（"## 日本語訳" の無限反復）
 *   - 入力に比して異様に長い
 * 崩れの理由を返す。問題なければ null。 */
function degenerate(out, input, tgt) {
  const t = (out || '').trim();
  if (!t) return '空';
  const hasJA = /[ぁ-んァ-ヶ一-龥]/.test(t);
  if (tgt === 'ja' && !hasJA) return '日本語が出ていない';
  if (tgt === 'en' && !/[A-Za-z]{3}/.test(t)) return '英語が出ていない';
  if (/(.{4,40}?)\1{3,}/.test(t)) return '同じ断片の反復';
  if (t.length > Math.max(80, input.length * 5)) return '入力に対して長すぎる';
  return null;
}

/* ------------------------------------------------------------------ *
 * 翻訳一件
 * ------------------------------------------------------------------ */
async function translateOne(text, { src = 'en', tgt = 'ja', profile = 'game', glossary, useTM = true, temperature = 0, debug = false } = {}) {
  const original = String(text ?? '');
  if (!original.trim()) return { text: original, from: 'empty' };

  const gl = glossary || GLOSSARY;
  const key = tmKey(original, src, tgt, profile);
  if (useTM && TM[key] !== undefined) return { text: TM[key], from: 'tm' };

  const { masked, slots } = maskText(original, profile, gl);

  // 伏せ字だけになった＝訳すものが残っていない
  if (!/[A-Za-z぀-ヿ一-鿿]/.test(masked.replace(SENT_RE, ''))) {
    const { text: restored } = unmaskText(masked, slots);
    return { text: restored, from: 'no-content' };
  }

  const prompt = buildPrompt(masked, src, tgt);
  let cleaned = '', why = null;

  // 一度目が崩れたら、サンプリングを変えてもう一度だけ引く。
  // 生成が壊れるのは稀だが、数千件の一括翻訳では必ず何件か出る。黙って
  // 混ぜるより、検出して引き直し、それでも駄目なら原文を残す。
  for (let attempt = 0; attempt < 2; attempt++) {
    const raw = await llamaComplete(prompt, { temperature: attempt === 0 ? temperature : 0.4 });
    cleaned = raw.replace(/^<\|plamo:op\|>.*$/gm, '').trim();
    why = degenerate(cleaned, masked, tgt);
    if (!why) break;
  }
  if (why) {
    return { text: original, from: 'degenerate', warning: `生成が崩れました（${why}）。原文を返します`, raw: cleaned.slice(0, 200) };
  }

  const { text: finalText, ok, lostCode, lostTerm } = unmaskText(cleaned, slots);
  const dbg = debug ? { masked, slots, raw: cleaned, lostCode, lostTerm } : undefined;

  // 用語集の伏せ字が落ちても訳文自体は使える（その語の訳が固定されなかっただけ）。
  // 捨てなければならないのはコード側——${...} やタグを失った訳はゲームを壊す。
  if (lostCode) {
    return { text: original, from: 'placeholder-lost', warning: 'プレースホルダが復元できないため原文を返しました', raw: cleaned, debug: dbg };
  }

  if (leftoverSentinel(finalText, slots.length)) {
    return { text: original, from: 'sentinel-left', warning: '伏せ字が訳文に残ったため原文を返しました', raw: finalText, debug: dbg };
  }
  if (!ok) {
    if (useTM) { TM[key] = finalText; tmDirty = true; }
    return { text: finalText, from: 'model', note: `用語 ${lostTerm} 語は固定できませんでした`, debug: dbg };
  }
  if (useTM) { TM[key] = finalText; tmDirty = true; }
  return { text: finalText, from: 'model' };
}

/* 並列度を絞ったプール。GPU は一枚なので詰め込みすぎない。 */
async function mapPool(items, limit, fn) {
  const out = new Array(items.length);
  let next = 0;
  const workers = Array.from({ length: Math.max(1, Math.min(limit, items.length)) }, async () => {
    for (;;) {
      const i = next++;
      if (i >= items.length) return;
      try { out[i] = await fn(items[i], i); }
      catch (e) { out[i] = { text: String(items[i]), from: 'error', error: String(e.message || e) }; }
    }
  });
  await Promise.all(workers);
  return out;
}

/* ------------------------------------------------------------------ *
 * HTTP
 * ------------------------------------------------------------------ */
const MIME = { '.html': 'text/html; charset=utf-8', '.js': 'text/javascript; charset=utf-8', '.css': 'text/css; charset=utf-8', '.json': 'application/json; charset=utf-8', '.svg': 'image/svg+xml' };

const readBody = req => new Promise((res, rej) => {
  let b = '';
  req.on('data', c => { b += c; if (b.length > 64e6) rej(new Error('body too large')); });
  req.on('end', () => res(b));
  req.on('error', rej);
});
const json = (res, code, obj) => {
  const s = JSON.stringify(obj);
  res.writeHead(code, { 'content-type': 'application/json; charset=utf-8', 'content-length': Buffer.byteLength(s) });
  res.end(s);
};

const server = http.createServer(async (req, res) => {
  const url = new URL(req.url, 'http://127.0.0.1');
  const p = url.pathname;

  // ローカル専用。外から叩かれる想定はしていない。
  res.setHeader('access-control-allow-origin', '*');
  res.setHeader('access-control-allow-headers', 'content-type, authorization');
  if (req.method === 'OPTIONS') { res.writeHead(204); return res.end(); }

  try {
    /* --- 状態 --- */
    if (p === '/api/health') {
      let llama = null;
      try {
        const r = await fetch(CFG.llama + '/props', { signal: AbortSignal.timeout(3000) });
        llama = r.ok ? await r.json() : { error: r.status };
      } catch (e) { llama = { error: String(e.message || e) }; }
      return json(res, 200, {
        ok: !llama.error,
        llama: CFG.llama,
        model: llama && llama.model_path ? path.basename(llama.model_path) : null,
        contextSize: llama && llama.default_generation_settings ? llama.default_generation_settings.n_ctx : null,
        tmEntries: Object.keys(TM).length,
        glossaryTerms: Object.keys(GLOSSARY).length,
        profiles: Object.keys(PROFILES),
        detail: llama,
      });
    }

    /* --- 翻訳: 1件 --- */
    if (p === '/api/translate' && req.method === 'POST') {
      const b = JSON.parse(await readBody(req) || '{}');
      const r = await translateOne(b.text, b);
      return json(res, 200, r);
    }

    /* --- 翻訳: 一括 --- */
    if (p === '/api/translate/batch' && req.method === 'POST') {
      const b = JSON.parse(await readBody(req) || '{}');
      const items = Array.isArray(b.texts) ? b.texts : [];
      const t0 = Date.now();
      const out = await mapPool(items, b.concurrency || CFG.concurrency, t => translateOne(t, b));
      saveTM();
      return json(res, 200, {
        results: out,
        stats: {
          count: out.length,
          fromModel: out.filter(r => r.from === 'model').length,
          fromTM: out.filter(r => r.from === 'tm').length,
          warned: out.filter(r => r.warning || r.error).length,
          ms: Date.now() - t0,
        },
      });
    }

    /* --- 用語集 --- */
    if (p === '/api/glossary') {
      if (req.method === 'GET') return json(res, 200, GLOSSARY);
      if (req.method === 'PUT') {
        GLOSSARY = JSON.parse(await readBody(req) || '{}');
        saveGlossary();
        return json(res, 200, { ok: true, terms: Object.keys(GLOSSARY).length });
      }
    }

    /* --- 文体の見本 --- */
    if (p === '/api/style') {
      if (req.method === 'GET') return json(res, 200, STYLE);
      if (req.method === 'PUT') {
        STYLE = JSON.parse(await readBody(req) || '{}');
        saveStyle();
        return json(res, 200, { ok: true, pairs: Object.keys(STYLE).length });
      }
    }

    /* --- 翻訳メモリ --- */
    if (p === '/api/tm') {
      if (req.method === 'GET') {
        const q = (url.searchParams.get('q') || '').toLowerCase();
        const rows = Object.entries(TM)
          .filter(([k, v]) => !q || k.toLowerCase().includes(q) || String(v).toLowerCase().includes(q))
          .slice(0, +(url.searchParams.get('limit') || 200))
          .map(([k, v]) => ({ key: k, source: k.slice(k.indexOf('|', k.indexOf('|') + 1) + 1), target: v }));
        return json(res, 200, { total: Object.keys(TM).length, rows });
      }
      if (req.method === 'DELETE') { TM = {}; tmDirty = true; saveTM(); return json(res, 200, { ok: true }); }
      if (req.method === 'POST') {                      // 訳文の手直し
        const b = JSON.parse(await readBody(req) || '{}');
        if (b.key) { TM[b.key] = b.target; tmDirty = true; saveTM(); }
        return json(res, 200, { ok: true });
      }
    }

    /* --- OpenAI 互換。他のツールから叩けるようにするための窓口 --- */
    if (p === '/v1/chat/completions' && req.method === 'POST') {
      const b = JSON.parse(await readBody(req) || '{}');
      const msgs = b.messages || [];
      const text = [...msgs].reverse().find(m => m.role === 'user')?.content ?? '';
      // system で方向と profile を指定できる: "lang=en>ja profile=game"
      const sys = msgs.find(m => m.role === 'system')?.content || '';
      const dir = /lang=(\w+)>(\w+)/.exec(sys);
      const prof = /profile=(\w+)/.exec(sys);
      const r = await translateOne(typeof text === 'string' ? text : JSON.stringify(text), {
        src: dir ? dir[1] : 'en',
        tgt: dir ? dir[2] : 'ja',
        profile: prof ? prof[1] : 'game',
      });
      return json(res, 200, {
        id: 'plamo-' + Date.now(),
        object: 'chat.completion',
        created: Math.floor(Date.now() / 1000),
        model: 'plamo-2-translate',
        choices: [{ index: 0, message: { role: 'assistant', content: r.text }, finish_reason: 'stop' }],
        usage: { prompt_tokens: 0, completion_tokens: 0, total_tokens: 0 },
      });
    }
    if (p === '/v1/models') {
      return json(res, 200, { object: 'list', data: [{ id: 'plamo-2-translate', object: 'model', owned_by: 'pfn' }] });
    }

    /* --- 画面 --- */
    let file = p === '/' ? '/index.html' : p;
    const full = path.join(HERE, 'public', path.normalize(file).replace(/^([/\\])+/, ''));
    if (full.startsWith(path.join(HERE, 'public')) && fs.existsSync(full) && fs.statSync(full).isFile()) {
      res.writeHead(200, { 'content-type': MIME[path.extname(full)] || 'application/octet-stream' });
      return res.end(fs.readFileSync(full));
    }

    json(res, 404, { error: 'not found', path: p });
  } catch (e) {
    json(res, 500, { error: String(e.message || e) });
  }
});

server.listen(CFG.port, '127.0.0.1', () => {
  console.log(`PLaMo 翻訳ゲートウェイ  http://127.0.0.1:${CFG.port}/`);
  console.log(`  llama-server : ${CFG.llama}`);
  console.log(`  翻訳メモリ    : ${CFG.tmPath} (${Object.keys(TM).length} 件)`);
  console.log(`  用語集        : ${CFG.glossaryPath} (${Object.keys(GLOSSARY).length} 語)`);
});
