// Lowmoor 日本語化パイプライン: 適用フェーズ
// tm.json の訳文を index.html の文字列リテラルへ、位置指定で差し戻す。
// 検証に落ちた訳（タグ欠落・プレースホルダ欠落など）は英語のまま残す。
import fs from 'fs';
import { addFontSwitcher } from './fontpatch.mjs';
import { rewriteLexicon } from './lexicon.mjs';
import { structuralEdits } from './structure.mjs';
import { scanLiteralsInHoles, scanJsStrings } from './jsscan.mjs';

const SRC = 'app/game/index.html';
const OUT = 'build/index.html';
const src = fs.readFileSync(SRC, 'utf8');
const tm = JSON.parse(fs.readFileSync('tm.json', 'utf8'));
// 手訳の上書き層: DeepL より優先する
if (fs.existsSync('overrides.json')) {
  const ov = JSON.parse(fs.readFileSync('overrides.json', 'utf8'));
  for (const k in ov) if (!k.startsWith('_')) tm[k] = ov[k];
}

// --- extract.mjs と同じスキャナ ---------------------------------------
const ranges = [];
{
  const re = /<script>([\s\S]*?)<\/script>/g;
  let m;
  while ((m = re.exec(src))) ranges.push([m.index + '<script>'.length, m.index + '<script>'.length + m[1].length]);
}
// 走査は jsscan.mjs（正規表現・テンプレートの入れ子に対応）に任せる。
// ここに同じ処理を写していたが、正規表現リテラルを飛ばせず、1.6.3 の
// 難読化コードで位置がずれて **コードの断片を訳してしまった**。
// 走査器は一つに統一する。
let strings = [];
for (const [a, b] of ranges) strings = strings.concat(scanJsStrings(src.slice(a, b), a));

// 上の走査はバッククォートからバッククォートまでを一つの文字列として読むので、
//   `<span class="m">${esc(has ? x : "not yet taken")}</span>`
// のように ${ } の中に置かれたリテラルが丸ごと見えない。入れ子を数える走査で
// 穴の中だけを別に拾い、位置で重複を除いて足す。
{
  const inHoles = scanLiteralsInHoles(src, ranges);
  const seen = new Set(strings.map(s => s.start));
  let added = 0;
  for (const s of inHoles) if (!seen.has(s.start)) { strings.push(s); seen.add(s.start); added++; }
  strings.sort((a, b) => a.start - b.start);
  if (added) console.log(`  \${} の中から拾ったリテラル: ${added} 件`);
}

// --- 検証: 訳が原文の構造を保っているか --------------------------------
const tags = s => (s.match(/<\/?[a-zA-Z][^>]*>/g) || []).map(t => t.replace(/\s+/g, ' ')).sort().join('|');
const phs = s => (s.match(/\{[a-zA-Z]+\}/g) || []).sort().join('|');
const interp = s => (s.match(/\$\{/g) || []).length;

// DeepL が返す HTML エンティティ。原文になければ混入とみなす。
const ents = s => (s.match(/&(quot|amp|lt|gt|#39);/g) || []).length;

// ドット記法で読まれているプロパティ名の一覧。`LEGACY.mods.open` のような
// アクセスはこちらで書き換えようがないので、同名の文字列リテラルは訳さない。
const dotProps = new Set([...src.matchAll(/\.([A-Za-z_$][\w$]*)/g)].map(m => m[1]));

// ドット参照もこちらで書き換えるので、保護の例外にする識別子（REORDER を見よ）
const FORCE_IDENT = new Set(['Weapon']);

// クォートなしのオブジェクトキーとして使われている名前。
//
// 以前はこれを「キーの側も一緒に書き換える」で通していた。装備部位のように
// 参照がすべてリテラルなら、それで筋が通る。だが 1.6.0 で該当が 27 → 237 に
// 増え、その中に CHART_QUARTERS[q.id] のように **変数で引く** キーが混ざった。
// 変数側は data から来るので書き換えようがなく、起動と同時に落ちた。
//
// 書き換えてよいのは、参照経路を一つずつ確かめた下の名前だけ。それ以外は
// 「構造の一部」とみなして英語のまま据え置く。表示に出る語を少し諦めても、
// 静かに壊れるよりはよい。
const KEY_REWRITE_OK = new Set([
  'Weapon', 'Shield', 'Helm', 'Breastplate', 'Pauldrons',
  'Bracers', 'Gauntlets', 'Leggings', 'Boots',   // 装備部位（ARMOR_BASES / SLOT_WARD）
  'Ranger', 'Druid',                              // HERO_ART の肖像キー
]);
const unquotedKeys = new Set(
  [...src.matchAll(/[{,][ \t\r\n]*([A-Za-z_$][\w$]*)[ \t]*:/g)].map(m => m[1]));

// ブラウザ API に渡る文字列は訳してはいけない。
// scrollIntoView({block:"nearest"}) の "nearest" のような列挙値がこれにあたる。
// 静的な語彙表ではなく、ソース中で「API のオプション値として現れているか」で判定する。
const apiValues = new Set([
  ...[...src.matchAll(/\b(?:behavior|block|inline|position|mode|alignment|format|method|credentials|cache|redirect|referrerPolicy|composite|fill|easing|direction|type)\s*:\s*["']([A-Za-z-]+)["']/g)].map(m => m[1]),
  ...[...src.matchAll(/(?:createElement|createElementNS|getContext|setAttribute|removeAttribute|matchMedia|requestFullscreen)\(\s*["']([^"']+)["']/g)].map(m => m[1]),
  ...[...src.matchAll(/typeof\s+[\w.$[\]]+\s*[!=]==\s*["']([a-z]+)["']/g)].map(m => m[1]),
  ...[...src.matchAll(/\.style\.[\w]+\s*=\s*["']([^"';]+)["']/g)].map(m => m[1]),
  ...[...src.matchAll(/localStorage\.(?:getItem|setItem|removeItem)\(\s*["']([^"']+)["']/g)].map(m => m[1]),
]);
// 綴りが同じだけの巻き添えを避けるため、単語1語のものだけを保護対象にする
const apiWords = new Set([...apiValues].filter(v => /^[A-Za-z][\w-]*$/.test(v)));

// ブラウザが与える値と突き合わせている文字列。
//
//   if (location.protocol === "file:") document.documentElement.classList.add("app")
//
// この "file:" を訳すと比較が永久に偽になる。実際これで、デスクトップ版が
// 「枠なしで画面いっぱい」ではなく「青いデスクトップの上の小窓」になった。
// 比較の相手がブラウザ由来の値なら、こちらの literal は書き換えられないので訳さない。
const RUNTIME_ROOT = /\b(location|navigator|document|window|screen|history|process|localStorage|sessionStorage|performance|matchMedia|ev|evt|event)\b/;
const runtimeCmp = new Set();
for (const m of src.matchAll(/([A-Za-z_$][\w$.[\]]*)\s*[!=]==\s*["']([^"']+)["']/g))
  if (RUNTIME_ROOT.test(m[1])) runtimeCmp.add(m[2]);
for (const m of src.matchAll(/["']([^"']+)["']\s*[!=]==\s*([A-Za-z_$][\w$.[\]]*)/g))
  if (RUNTIME_ROOT.test(m[2])) runtimeCmp.add(m[1]);
// 状態を表すプロパティとの比較は、相手が何であれブラウザの決めた綴り。
//   audioCtx.state === "running"   → 「流れる」と訳され、永久に偽になっていた。
// audioCtx は RUNTIME_ROOT に無いので上の二つでは拾えなかった。
for (const m of src.matchAll(/\.(?:state|readyState|visibilityState|protocol|nodeName|tagName)\s*[!=]==?\s*["']([^"']+)["']/g))
  runtimeCmp.add(m[1]);
for (const m of src.matchAll(/["']([^"']+)["']\s*[!=]==?\s*[A-Za-z_$][\w$.[\]]*\.(?:state|readyState|visibilityState|protocol|nodeName|tagName)\b/g))
  runtimeCmp.add(m[1]);
// URL のスキームは形で分かるので、比較に現れていなくても落とす
const isScheme = s => /^[a-z][a-z0-9+.-]*:$/.test(s);

// CSS の値。
//   p.fill.style.clipPath = "inset(0 " + n + "% 0 0)"
// この "inset(0 " を訳すと「インセット(0」になる。構文は通り、例外も出ず、
// ブラウザが値を黙って捨てるだけ——ゲージの塗りが出なくなって初めて分かる。
// 連結して組み立てるので代入先から辿れない。値の形で見分ける。
const CSS_FUNC = /^\s*(?:inset|translate[XYZ3d]*|scale[XYZ3d]*|rotate[XYZ3d]*|skew|matrix3?d?|perspective|calc|clamp|rgba?|hsla?|var|url|attr|(?:repeating-)?(?:linear|radial|conic)-gradient|blur|brightness|contrast|drop-shadow|grayscale|invert|saturate|sepia|hue-rotate|cubic-bezier|steps|polygon|circle|ellipse|path|counter|env|color-mix)\s*\(/i;
// CSS の宣言そのもの（style.cssText に渡す "background:...; margin:0;" の形）。
// 1.6.0 でこれが「背景: var(--desk); マージン: 0;」と訳され、lint が拾った。
// プロパティ名は決まった綴りしかないので、それが並んでいるかで見分ける。
const CSS_PROP = /(?:^|;)\s*(?:background|color|margin|padding|border|display|position|top|left|right|bottom|width|height|font|line-height|overflow|z-index|opacity|transform|flex|gap|grid|align|justify|white-space|text-align|cursor|zoom|inset|filter|box-shadow|border-radius|visibility|pointer-events)[a-z-]*\s*:/i;
const isCssValue = s =>
  CSS_FUNC.test(s) ||
  /^-?[\d.]+(?:px|%|em|rem|vh|vw|svh|deg|ms|s|fr|ch)\b/.test(s) ||
  (CSS_PROP.test(s) && /;/.test(s) && !/[.!?。]/.test(s));

// 実行時に組み立てる合成キー。
//   COURT_WEATHER_LINES[wb.group + ":" + wb.state]   → "mines:2"
// 片方は data から来るので、こちらのリテラルだけ訳すと引けなくなる。
const isCompositeKey = s => /^[a-z][\w-]*:[\w-]+$/.test(s);

// セレクタ・メディアクエリ・DOM id など、丸ごと API に渡る文字列は形を問わず保護する
const apiStrings = new Set([
  ...[...src.matchAll(/(?:querySelector|querySelectorAll|matchMedia|createElement|createElementNS|getContext|getElementById|closest|matches)\(\s*["']([^"']+)["']/g)].map(m => m[1]),
  ...[...src.matchAll(/classList\.\w+\(\s*["']([^"']+)["']/g)].map(m => m[1]),
  ...[...src.matchAll(/(?:setAttribute|removeAttribute|getAttribute|hasAttribute)\(\s*["']([^"']+)["']/g)].map(m => m[1]),
  ...[...src.matchAll(/addEventListener\(\s*["']([^"']+)["']/g)].map(m => m[1]),
  ...[...src.matchAll(/localStorage\.\w+\(\s*["']([^"']+)["']/g)].map(m => m[1]),
  ...[...src.matchAll(/\$\(\s*["']([^"']+)["']\s*\)/g)].map(m => m[1]),
  // className / dataset への代入も、CSS 側の綴りと組で効くので触らない。
  // out4 経由の表示テキストには当てはまらないので、そちらは validateDisplay に回る。
  ...[...src.matchAll(/\.className\s*=\s*["']([^"']+)["']/g)].map(m => m[1]),
  // dataset のうち help / helptitle / helpstats は、値がそのままツールチップの
  // 本文になる。ここまで保護すると説明文が丸ごと英語で残るので外す
  // （名誉録のリセット説明が英語のままだったのはこれが原因）。
  ...[...src.matchAll(/\.dataset\.(\w+)\s*=\s*["']([^"']+)["']/g)]
    .filter(m => !/^(help|helptitle|helpstats|label|title|alt)$/.test(m[1])).map(m => m[2]),
]);

// ブラウザ API のプロパティへの代入。右辺はまるごと訳してはいけない。
//
// これを見落として「音が出ない」を出した。実際に壊れていたのは:
//   tone.type = "lowpass"              → "ローパス"   … 例外を投げて音が止まる
//   ctx.font  = ... + "px Tahoma, sans-serif" → "…、サンセリフ体" … 字体が既定に落ちる
//   ctx.textBaseline = "middle"        → "中間"       … 黙って無視される
//
// 綴りが決まっているプロパティは、右辺の式ぜんぶを触らない範囲として扱う。
// font のように連結で組み立てるものがあるので、リテラル単位ではなく範囲で守る。
const API_ASSIGN_PROPS =
  'type|oversample|curve|panningModel|distanceModel|channelCountMode|channelInterpretation'
  + '|font|textAlign|textBaseline|lineCap|lineJoin|globalCompositeOperation|direction'
  + '|crossOrigin|preload|loading|decoding|referrerPolicy|inputMode|enterKeyHint'
  + '|autocomplete|encoding|contentEditable|dir|srcset|sizes';
const apiRanges = [];
for (const m of src.matchAll(new RegExp('\\.(?:' + API_ASSIGN_PROPS + ')\\s*=[^=]', 'g'))) {
  let i = m.index + m[0].length, depth = 0;
  const lim = Math.min(src.length, i + 400);
  for (; i < lim; i++) {
    const c = src[i];
    if (c === '"' || c === "'" || c === '`') { const q = c; i++; while (i < lim) { if (src[i] === '\\') { i += 2; continue; } if (src[i] === q) break; i++; } continue; }
    if (c === '(' || c === '[' || c === '{') depth++;
    else if (c === ')' || c === ']' || c === '}') { if (depth === 0) break; depth--; }
    else if ((c === ';' || c === '\n') && depth === 0) break;
  }
  apiRanges.push([m.index, i]);
}
apiRanges.sort((a, b) => a[0] - b[0]);
const isApiSite = p => {
  let lo = 0, hi = apiRanges.length - 1;
  while (lo <= hi) {
    const mid = (lo + hi) >> 1, r = apiRanges[mid];
    if (p < r[0]) hi = mid - 1; else if (p >= r[1]) lo = mid + 1; else return true;
  }
  return false;
};

// ブラウザが決めている綴りそのもの。既定引数（function beep(…, type="square")）や
// 変数を経由する場合は代入の形に現れないので、値の側でも押さえる。
// 小文字ちょうどのものだけ。画面に出る語は "Center" のように大文字で始まる。
const WEB_API_VALUES = new Set([
  // OscillatorNode.type / BiquadFilterNode.type
  'sine', 'square', 'sawtooth', 'triangle', 'custom',
  'lowpass', 'highpass', 'bandpass', 'lowshelf', 'highshelf', 'peaking', 'notch', 'allpass',
  // AudioContext.state / WaveShaperNode.oversample / PannerNode
  'running', 'suspended', 'closed', '2x', '4x', 'equalpower', 'hrtf',
  // canvas: textBaseline / textAlign / lineCap / lineJoin
  'alphabetic', 'hanging', 'ideographic', 'middle', 'butt', 'bevel', 'miter',
  // document.readyState / visibilityState
  'loading', 'interactive', 'complete', 'visible', 'hidden',
]);

// 画面には出ないがセーブや分岐のキーになっている内部語。訳しても得がなく、
// 訳せばセーブの中身が日本語になるだけなので、英語のまま据え置く。
const NEVER = new Set([
  'ash', 'dawn', 'crown', 'relic', 'register', 'seat', 'oath',
  'torch', 'mule', 'prentice', 'maw', 'vale',
  'drowned', 'grave', 'purse', 'brace', 'blood', 'sword',
]);

function validate(en, ja) {
  if (!ja || !ja.trim()) return 'empty';
  if (NEVER.has(en)) return 'internal-key';
  if (WEB_API_VALUES.has(en)) return 'web-api-value';
  // 識別子そのものの文字列で、かつどこかでプロパティとして読まれているもの
  if (/^[A-Za-z_$][\w$]*$/.test(en) && dotProps.has(en) && !FORCE_IDENT.has(en)) return 'identifier-property';
  if ((apiWords.has(en) || apiStrings.has(en)) && !FORCE_IDENT.has(en)) return 'browser-api-value';
  if (runtimeCmp.has(en) || isScheme(en)) return 'runtime-compared';
  if (isCssValue(en)) return 'css-value';
  if (isCompositeKey(en)) return 'composite-key';
  // クォートなしのキーになっている名前は、参照経路を確かめたもの以外は触らない
  if (/^[A-Za-z_$][\w$]*$/.test(en) && unquotedKeys.has(en) && !KEY_REWRITE_OK.has(en))
    return 'structural-key';
  // テンプレートリテラル中の ${...} は JS 式そのもの。訳して壊すより英語のまま残す。
  if (en.includes('${')) return 'template-expression';
  if (tags(en) !== tags(ja)) return 'tag-mismatch';
  if (phs(en) !== phs(ja)) return 'placeholder-mismatch';
  if (interp(en) !== interp(ja)) return 'interpolation-mismatch';
  if (ents(ja) > ents(en)) return 'entity-injected';
  if (/[A-Za-z]{3,}/.test(ja) && !/[ぁ-んァ-ン一-龯]/.test(ja)) return 'untranslated';
  return null;
}

// --- 再エスケープ -------------------------------------------------------
function esc(s, q) {
  let out = s.replace(/\\/g, '\\\\');
  if (q === '`') out = out.replace(/`/g, '\\`').replace(/\$\{/g, '\\${');
  else out = out.split(q).join('\\' + q).replace(/\n/g, '\\n').replace(/\r/g, '');
  return out;
}

// --- 「ここは表示だ」と位置で分かる範囲 ----------------------------------
// 門は語の綴りで判断するので、同じ綴りが別の場所で構造に使われていると
// 表示側まで巻き添えで英語のまま残る。実例:
//
//   el.style.display = "none";                  ← 訳してはいけない
//   $("chartwrit").textContent = w ? w.name : "none";   ← 「なし」と出すべき
//
// textContent / title / placeholder への代入は、値が文字どおり画面に出る。
// HTML を組み立てる innerHTML は、タグや class 名が混じるので入れない。
// この範囲に入るリテラルだけ、綴りの門ではなく壊れ方の門で見る。
const displayRanges = [];
for (const m of src.matchAll(/\.(?:textContent|innerText|title|placeholder|ariaLabel|dataset\.(?:help|helptitle|helpstats))\s*=[^=]/g)) {
  let i = m.index + m[0].length, depth = 0;
  const lim = Math.min(src.length, i + 600);
  for (; i < lim; i++) {
    const c = src[i];
    if (c === '(' || c === '[' || c === '{') depth++;
    else if (c === ')' || c === ']' || c === '}') { if (depth === 0) break; depth--; }
    else if ((c === ';' || c === '\n') && depth === 0) break;
  }
  displayRanges.push([m.index, i]);
}
// 表の中の「文言を組み立てる小さな関数」も、返り値がそのまま画面に出る。
//   {id:"standing", eff:l => l>1 ? "members' rate and rope-and-plank crew"
//                              : l ? "members' rate" : "none"}
// この "none" は「効果なし」の意味だが、綴りが CSS の none と同じせいで
// browser-api-value に巻き込まれ、名誉録の全行が英語のまま残っていた。
// id や costs は素の文字列なので、arrow の本体だけを範囲にする。
for (const m of src.matchAll(/\b(?:eff|line|note|say)\s*:\s*(?:\([^)]{0,40}\)|[A-Za-z_$][\w$]*)\s*=>/g)) {
  let i = m.index + m[0].length, depth = 0;
  const lim = Math.min(src.length, i + 900);
  for (; i < lim; i++) {
    const c = src[i];
    if (c === '"' || c === "'" || c === '`') { const q = c; i++; while (i < lim) { if (src[i] === '\\') { i += 2; continue; } if (src[i] === q) break; i++; } continue; }
    if (c === '(' || c === '[' || c === '{') depth++;
    else if (c === ')' || c === ']' || c === '}') { if (depth === 0) break; depth--; }
    else if (c === ',' && depth === 0) break;
  }
  displayRanges.push([m.index, i]);
}
displayRanges.sort((a, b) => a[0] - b[0]);
const isDisplaySite = p => {
  let lo = 0, hi = displayRanges.length - 1;
  while (lo <= hi) {
    const mid = (lo + hi) >> 1, r = displayRanges[mid];
    if (p < r[0]) hi = mid - 1;
    else if (p >= r[1]) lo = mid + 1;
    else return true;
  }
  return false;
};

// --- 難読化されたコードの語順書き換え ------------------------------------
//
// 1.6.3 でゲームのコードが minify された。整形されていた頃は
// `const MECH_ADD = {` や `function roundLine(` を目印にできたが、
// 変数名が 1〜2 文字に潰れて全部消えた。文字列リテラルとオブジェクトのキーは
// 残るので、そちらを足場に **原文の位置** で切り出して置き換える。
// 結果は他の置換と同じ「オフセット指定の編集」なので、重なりの排除にも乗る。
const MINIFIED = !/const MECH_ADD\s*=\s*\{/.test(src);
let struct = { edits: [], protect: [], notes: [] };
if (MINIFIED) {
  console.log('  難読化されたコードです（1.6.3 以降の形）');
  struct = structuralEdits(src);
}
// 語彙表の中は触らない。断片だけ訳すと「一撃の作用炎15% 頻繁に。」になる。
// 表そのものは英語のまま残すが、引く側を日本語にしてあるので画面には出ない。
const protectRanges = struct.protect.slice().sort((a, b) => a[0] - b[0]);
const isProtected = p => {
  let lo = 0, hi = protectRanges.length - 1;
  while (lo <= hi) {
    const mid = (lo + hi) >> 1, r = protectRanges[mid];
    if (p < r[0]) hi = mid - 1; else if (p >= r[1]) lo = mid + 1; else return true;
  }
  return false;
};

// --- 適用 ---------------------------------------------------------------
const reasons = {};
let applied = 0, skipped = 0, displaySites = 0;
// 切り分け用: LM_ONLY=struct|literal|tpl|html で、その系統だけを適用する
const ONLY = process.env.LM_ONLY || '';
const edits = (ONLY && ONLY !== 'struct') ? [] : [...struct.edits];
const DEBUG = process.env.LM_DEBUG ? process.env.LM_DEBUG.split('|') : [];
for (const st of strings) {
  const ja = tm[st.cooked];
  // 語彙表の中は、引く側を日本語にしてあるので触らない
  if (isProtected(st.start)) {
    if (ja !== undefined) { reasons['lexicon-table'] = (reasons['lexicon-table'] || 0) + 1; skipped++; }
    continue;
  }
  // API のプロパティへ渡る範囲は、表示位置の判定より先に落とす。
  // font のように連結で組み立てる断片は、リテラル単体を見ても分からない。
  if (isApiSite(st.start)) {
    if (ja !== undefined) { reasons['api-range'] = (reasons['api-range'] || 0) + 1; skipped++; }
    continue;
  }
  const disp = isDisplaySite(st.start);
  const check = disp ? validateDisplay : validate;
  if (DEBUG.includes(st.cooked)) console.log('  [debug]', JSON.stringify(st.cooked), '訳=', JSON.stringify(ja), disp ? '(表示位置)' : '', '門=', ja === undefined ? '訳なし' : (check(st.cooked, ja) || '通る'), '@', st.start);
  if (ja === undefined) continue;
  const why = check(st.cooked, ja);
  if (why) { reasons[why] = (reasons[why] || 0) + 1; skipped++; continue; }
  const LO = +(process.env.LM_LO || 0), HI = +(process.env.LM_HI || Infinity);
  if ((!ONLY || ONLY === 'literal') && st.start >= LO && st.start < HI)
    edits.push({ start: st.start, end: st.end, text: st.quote + esc(ja, st.quote) + st.quote });
  applied++;
  if (disp) displaySites++;
}
// --- クォートなしオブジェクトキーの追随 --------------------------------
// ARMOR_BASES = {Shield:[...]} のようにキーがクォートされていない表がある。
// 文字列リテラル側だけ訳すと ARMOR_BASES["盾"] が undefined になって落ちるので、
// 訳した語が識別子としてキーに使われている箇所も同時にクォート付きの訳語へ書き換える。
const strRanges = strings.map(st => [st.start, st.end]);
const insideString = i => strRanges.some(([a, b]) => i >= a && i < b);

const identLits = new Set();
for (const st of strings) {
  const ja = tm[st.cooked];
  if (ja === undefined || validate(st.cooked, ja)) continue;
  // 書き換えてよいと確かめた名前だけ。無条件にやると、変数で引くキーまで
  // 巻き込んで起動時にクラッシュする（1.6.0 で実際に踏んだ）。
  if (/^[A-Za-z_$][\w$]*$/.test(st.cooked) && KEY_REWRITE_OK.has(st.cooked)) identLits.add(st.cooked);
}
let keyRewrites = 0;
const keyNames = new Set();
if (identLits.size) {
  const alts = [...identLits].sort((a, b) => b.length - a.length).join('|');
  const re = new RegExp('([{,][ \\t\\r\\n]*)(' + alts + ')([ \\t]*:)', 'g');
  let m;
  while ((m = re.exec(src))) {
    const at = m.index + m[1].length;
    if (insideString(at)) continue;
    edits.push({ start: at, end: at + m[2].length, text: JSON.stringify(tm[m[2]]) });
    keyNames.add(m[2]);
    keyRewrites++;
  }
}

// --- 静的HTML側（テキストノードと data-help 等の属性） -----------------
let htmlApplied = 0, htmlSkipped = 0;
if (fs.existsSync('out-html-items.json')) {
  const items = JSON.parse(fs.readFileSync('out-html-items.json', 'utf8'));
  for (const it of items) {
    const ja = tm[it.text];
    if (ja === undefined) continue;
    // 静的HTML のテキストノードと data-help 等の属性は、位置からして表示。
    // 綴りの門（structural-key など）は当てはまらないので壊れ方だけ見る。
    const why = validateDisplay(it.text, ja);
    if (why) { reasons['html:' + why] = (reasons['html:' + why] || 0) + 1; htmlSkipped++; continue; }
    const enc = it.kind === 'text'
      ? ja.replace(/&(?![a-zA-Z#][a-zA-Z0-9]*;)/g, '&amp;')     // タグは validate が同一を保証済み
      : ja.replace(/&(?![a-zA-Z#][a-zA-Z0-9]*;)/g, '&amp;').replace(/"/g, '&quot;');
    edits.push({ start: it.start, end: it.end, text: enc });
    htmlApplied++;
  }
}

// --- テンプレートリテラルの中の地の文 -----------------------------------
// `<button ...>Equip</button>` のように、画面文言がテンプレートの中に
// 直接書かれている箇所。これまで「${ があるから」と丸ごと避けていたため、
// Equip / mastered / Accessory などが英語のまま残っていた。
//
// ここは「> と < に挟まれた地の文」「title/data-help 等の属性値」だけを
// 対象にしており、構造ではなく表示だと位置で分かっている。だから
// structural-key や identifier-property の門は通さない（それらは
// 「裸のリテラルが値として使われているかもしれない」ための門で、
// ここには当てはまらない）。壊れ方を見る門だけを残す。
function validateDisplay(en, ja) {
  if (!ja || !ja.trim()) return 'empty';
  if (isCssValue(en)) return 'css-value';
  if (tags(en) !== tags(ja)) return 'tag-mismatch';
  if (phs(en) !== phs(ja)) return 'placeholder-mismatch';
  if (interp(en) !== interp(ja)) return 'interpolation-mismatch';
  if (ents(ja) > ents(en)) return 'entity-injected';
  if (/\$\{|`/.test(ja)) return 'breaks-template';
  if (/[A-Za-z]{3,}/.test(ja) && !/[ぁ-んァ-ン一-龯]/.test(ja)) return 'untranslated';
  return null;
}

let tplApplied = 0, tplSkipped = 0;
if (fs.existsSync('out4-tpl.json')) {
  const items = JSON.parse(fs.readFileSync('out4-tpl.json', 'utf8'));
  for (const it of items) {
    const ja = tm[it.text];
    if (ja === undefined) continue;
    const why = validateDisplay(it.text, ja);
    if (why) { reasons['tpl:' + why] = (reasons['tpl:' + why] || 0) + 1; tplSkipped++; continue; }
    // まず HTML として、そのあとテンプレートリテラルとして守る
    let enc = ja.replace(/&(?![a-zA-Z#][a-zA-Z0-9]*;)/g, '&amp;');
    if (it.kind !== 'text') enc = enc.replace(/"/g, '&quot;');
    enc = esc(enc, '`');
    edits.push({ start: it.start, end: it.end, text: enc });
    tplApplied++;
  }
}

// --- 重なりの排除 -------------------------------------------------------
// 抽出器が4系統あるので、同じ場所を二重に置き換えようとすることがある。
// 片方が範囲の途中で終わると、そこから先が壊れた文字列として残る
// （1.6.0 で "…します。`ne line and never…" という形で踏んだ）。
// 先に始まるもの・長いものを残し、重なった側は捨てて数を報告する。
edits.sort((a, b) => a.start - b.start
  || (b.kind === 'struct') - (a.kind === 'struct')
  || (b.end - b.start) - (a.end - a.start));
{
  const keep = [];
  let lastEnd = -1, dropped = 0;
  for (const e of edits) {
    if (e.start < lastEnd) { dropped++; continue; }
    keep.push(e); lastEnd = e.end;
  }
  if (dropped) console.log('重なりのため見送った置換:', dropped, '件');
  edits.length = 0; edits.push(...keep);
}

edits.sort((a, b) => b.start - a.start);

let out = src;
for (const e of edits) out = out.slice(0, e.start) + e.text + out.slice(e.end);

// --- 語順の組み替え -----------------------------------------------------
// 英語は「動詞 目的語」、日本語は「目的語 を 動詞」。文を `+` で継ぎ足して
// 組み立てている箇所は、リテラルを訳すだけでは語順が英語のまま残る。
// 頻繁に目に入るものだけ、組み立てそのものを日本語の語順に書き換える。
const REORDER = [
  // 装備の枠名（Main hand / Off hand / Armour / Accessory）。
  //
  // この4語は G.equip の **キー** で、そのままセーブに入る。リテラルを訳すと
  // セーブの中身が日本語になり、版をまたいだとき装備が外れて見える。実際いまは
  // 「Main hand → メインハンド」だけが訳され、Armour と Accessory は門で
  // 止まっていて、画面に英語と日本語が混ざっていた。
  //
  // 内部の値は触らず、表の升目に出すときだけ日本語に読み替える。
  // こうすると既存のセーブもそのまま読める。
  {
    name: 'kit-slot-label',
    find: /<td class="kitslot">\$\{sl\}<\/td>/,
    to: '<td class="kitslot">${({"Main hand":"利き手","メインハンド":"利き手",'
      + '"Off hand":"盾手","手近なところで":"盾手",'
      + '"Armour":"鎧","Accessory":"装身具"})[sl] || sl}</td>',
  },

  // 戦闘ログの一行。画面でいちばん読まれる場所で、いちばん崩れていた。
  //
  //   "you cut " + name + " for " + youDmg
  //   ". It stands at " + fhp + " of " + foeMax + "."
  //
  // 断片ごとに訳すと「あなたが切り開く追い詰められたゼラチナス・キューブの
  // ために9、それはあなたに5. It stands at 33 of 42.」になる。目的語が先に
  // 来る日本語では組み立てそのものを変えるしかないので、分岐ごと書き下ろす。
  // 二つの声（世界の語り口／簡明な言葉）はどちらも残す。
  {
    name: 'combat-log-you',
    find: /if\(youDmg\) bit\.push\(plainWords[\s\S]*?youDmg\)\);/,
    to: `if(youDmg) bit.push(plainWords
      ? name + "に " + youDmg + " の打撃"
      : f.cast
        ? (crit ? "送りが" + name + "を貫き、" + youDmg
                : "送りが" + name + "を捉え、" + youDmg)
        : (crit ? "清き一撃が" + name + "を斬り開き、" + youDmg
                : name + "に斬りつけ、" + youDmg));`,
  },
  {
    name: 'combat-log-surprise',
    find: /else if\(round\.surprise < 0 && !round\.you\.length\)\s*[\r\n]*\s*bit\.push\([\s\S]*?\);/,
    to: `else if(round.surprise < 0 && !round.you.length)
    bit.push(plainWords ? "不意を突かれ、この回は攻撃できない"
      : "足が定まらぬうちに詰め寄られ、こちらの一撃は間に合わない");`,
  },
  {
    name: 'combat-log-miss',
    find: /else bit\.push\(plainWords \? "[^"]*" : f\.cast \? "[^"]*" : "[^"]*"\);/,
    to: 'else bit.push(plainWords ? "外した" : f.cast ? "送りは逸れた" : "一撃は空を切った");',
  },
  {
    name: 'combat-log-held',
    find: /if\(round\.held\) bit\.push\(plainWords \?[\s\S]*?\);/,
    to: `if(round.held) bit.push(plainWords ? "相手は拘束され、この回は攻撃なし"
      : "相手は縛めに掛かり、その一撃は費やされない");`,
  },
  {
    name: 'combat-log-foe-surprised',
    find: /else if\(round\.surprise > 0\) bit\.push\([\s\S]*?\);/,
    to: `else if(round.surprise > 0) bit.push(plainWords ? "相手は不意を突かれ、この回は攻撃なし"
      : "相手はまだあなたを探して身を巡らせており、その一撃は費やされない");`,
  },
  {
    name: 'combat-log-foe-hit',
    find: /else if\(foeDmg\) bit\.push\(plainWords \?[\s\S]*?foeDmg\);/,
    to: `else if(foeDmg) bit.push(plainWords ? "被弾 " + foeDmg
      : "返す一撃を受けて " + foeDmg);`,
  },
  {
    name: 'combat-log-warded',
    find: /bit\.push\(plainWords \?[^;]*\bate\b[^;]*\);/,
    to: `bit.push(plainWords ? "被弾 " + ate + "（MP で肩代わり）"
      : "一撃は入ったが、護りの井戸がそれを飲んだ：マナ " + ate);`,
  },
  {
    name: 'combat-log-foe-miss',
    find: /else if\(round\.foe\.length\) bit\.push\([\s\S]*?\);/,
    to: 'else if(round.foe.length) bit.push(plainWords ? "相手は外した" : "相手の一撃は空を切った");',
  },
  {
    name: 'combat-log-tail',
    find: /const head = bit\.join\(", "\);\s*[\r\n]+\s*const tail = plainWords[\s\S]*?head\.charAt\(0\)\.toUpperCase\(\) \+ head\.slice\(1\) \+ tail \+ "<\/span>";/,
    to: `const head = bit.join("、");
  const tail = plainWords
    ? "。" + name + " " + Math.round(round.fhp) + "/" + f.foeMax
      + "、あなた " + Math.floor(round.hp) + "/" + hpTotal() + "。"
    : "。残り " + Math.round(round.fhp) + " / " + f.foeMax + "。";
  return "<span data-helptitle='第" + round.n + "回' data-help='" + slip + "'>"
    + head + tail + "</span>";`,
  },

  // 保存からの経過。"Saved " + ("just now" | "12s ago" | "3m ago") と組むので、
  // 断片ごとに訳すと「保存済み 12s 前」の語順になる。まとめて書き直す。
  {
    name: 'save-age',
    find: /return "[^"]*" \+ \(s < 5 \? "[^"]*" : s < 60 \? s\+"[^"]*" : Math\.floor\(s\/60\)\+"[^"]*"\);/,
    to: 'return (s < 5 ? "たった今保存" : s < 60 ? s+"秒前に保存" : Math.floor(s/60)+"分前に保存");',
  },

  // 名誉録の段位表示「0 of 20」。列幅が 6.5em しかないので短くする必要があり、
  // 「0／20」に置き換える（原文コメントも「短くしてある」と断っている）。
  { name: 'roll-mark-of',
    find: /mark = shown\+" of "\+u\.max;/,
    to: 'mark = shown+"／"+u.max;' },

  // 討伐ログ: 「Downed X with one clean stroke. Took Y, and a second besides.」
  { name: 'kill-log',
    find: /log\(\(m\.pale\?[\s\S]{0,900}?crit\?"lg-hit":undefined\);/,
    to: `log((m.pale?"<span class='lg-crit'>凶兆。</span> ":"")
        + esc(m.display)+"を"
        + (crit?"<span class='lg-crit'>清き一撃で</span>":"")
        + pick(KILL_VERBS)
        + "。<span class='"+rar.cls+"'>"+esc(m.trophy)+(ri>0?"（"+rar.name+"）":"")+"</span>を得た"
        + (crit?"。さらにもう一つ":"")+"。", crit?"lg-hit":undefined);` },

  // 進行中の仕事: 「Tracking a cornered chimera」
  { name: 'hunt-task',
    find: /setTask\(pick\(HUNT_VERBS\)\+" "\+m\.display,/,
    to: 'setTask(m.display+"を"+pick(HUNT_VERBS),' },

  // 掲示板の依頼
  { name: 'chore-fetch',
    find: /pick\(inVale\(\) \? VALE_CHORE_FETCH : CHORE_FETCH\)\+" "\+questFetch\(\)/,
    to: 'questFetch()+"を"+pick(inVale() ? VALE_CHORE_FETCH : CHORE_FETCH)' },
  { name: 'chore-hunt',
    find: /pick\(inVale\(\) \? VALE_CHORE_HUNTS : CHORE_HUNTS\)\+" "\+plural\(mon\[0\]\)/,
    to: 'plural(mon[0])+"を"+pick(inVale() ? VALE_CHORE_HUNTS : CHORE_HUNTS)' },

  // 獣を極めたときの一行
  { name: 'mastered-log',
    find: /log\("[^"]*"\+esc\(m\.name\)\+"[^"]*<b>[^<]*<\/b>[^"]*",\s*"lg-sys"\);/,
    to: 'log(esc(m.name)+"の習性、巣、弱点を徹底して調べ上げた。<b>極めた</b>（+0.5%速度、以後のすべての年代記に残る）。", "lg-sys");' },

  // 功績の一行。
  // 1.6.0 で log() に第三引数（"guild"）が足された。引数はまた増えうるので、
  // 末尾は決め打ちにせず「残りをそのまま引き継ぐ」形にしておく。
  { name: 'ach-log',
    find: /log\("[^"]*<b>"\+esc\(a\.name\)\+"<\/b>[^"]*"(,[^;)]*)?\);/,
    to: (m, tail) => 'log("功績を達成: <b>"+esc(a.name)+"</b>。ギルドが手当を上乗せする（+1%速度）。"'
      + (tail || '') + ');' },

  // 拾得品。1.6.0 で末尾に findCeremony(e.name) が足された。後ろは引き継ぐ。
  { name: 'leavings-better',
    find: /log\("[^"]*"\+lootName\(e\.name, e\.band\)\+"[^"]*"\s*((?:\+[\s\S]*?)?)\);/,
    to: (m, tail) => 'log("骸のかたわらに"+lootName(e.name, e.band)+"。いま身に着けているものより良い。"'
      + (tail || '') + ');' },
  { name: 'level-move',
    find: /: " " \+ e\.slot \+ "[^"]*" \+ e\.was \+ "[^"]*" \+ e\.now \+ "\.";/,
    to: ': " " + e.slot + "の品質 " + e.was + " → " + e.now + "。";' },

  // 技能点を得た一行
  // こちらも 1.6.0 で第三引数が付いた。中身は丸ごと書き直し、"lg-sys" の後ろは引き継ぐ。
  { name: 'charter-earn',
    find: /log\("[^"]*"\+\(n===1\?"[^"]*":n\+"[^"]*"\)[\s\S]*?\), "lg-sys"([^;)]*)\);/,
    to: (m, tail) => 'log(why+"により、ギルドがあなたの名の下に"+(n===1?"技能点を1":"技能点を"+n)+"点記帳した。"\n'
      + '      +(G.charter.hand ? "ペンはあなたの手にある。<b>技能</b>（メニュー）を開いて使うとよい。"\n'
      + '                       : "先にペンを取らなければ、ギルドがまもなく代わりに選ぶ。"), "lg-sys"'
      + (tail || '') + ');' },

  // ウィンドウのタイトルバー
  { name: 'wintitle',
    // 検索式に Lowmoor と書いていたが、テンプレートの地の文も訳すように
    // なった結果その語が「ロウムーア」に変わり、この規則が静かに死んでいた。
    // 訳語に依存しないよう、構造（${DEMO?…} を含むテンプレート）で当てる。
    find: /\$\("wintitle"\)\.textContent = `[^`]*\$\{DEMO\?[^`]*`;/,
    to: '$("wintitle").textContent = `Lowmoor${DEMO?"（体験版）":""}: ${G.name}／${G.race} ${G.cls}（ランク${G.level}） ― ${region().name}`;' },

  // --- 英語の単複の出し分けを畳む -----------------------------------------
  // 日本語に複数形はない。(n===1?"":"s") の形が残っていると「3選択s待っている」に
  // なる。リテラルは訳されて中身が変わるので、分岐の形のほうで拾う。
  // 1.6.0 で「重要な選択」の印（! マーク）が後ろに足された。
  //
  // 末尾まで飲み込もうとして `;` で切ると事故る。訳文の中に `;` が入ることが
  // あり（"); " のように）、正規表現は文字列の中の `;` と文の終わりを区別できない。
  // 実際それで括弧が合わなくなって構文エラーになった。
  // 頭の単複の部分だけを置き換え、後ろの式には触らない。
  { name: 'plural-choices',
    find: /btn\.innerHTML = "[^"]*<b>"\+n\+"<\/b>[^"]*"\+\(n===1\?"":"s"\)\+"[^"]*"/,
    to: 'btn.innerHTML = "✉ <b>"+n+"</b> 件の選択が待っている"' },
  // その下の読み上げ用の一文。こちらも頭だけ。
  { name: 'plural-choices-note',
    find: /const note = n\+"[^"]*"\+\(n===1\?"[^"]*":"[^"]*"\)\+"[^"]*"/,
    to: 'const note = n+"件の選択が待っている"' },
  // ペンを渡す確認。単複の出し分けが二つ（"s" と "it"/"all N"）入っていて、
  // 訳すと「ギルドが消費するit今、」のように崩れる。文ごと書き直す。
  { name: 'plural-skillpoint',
    find: /\$\("pennote"\)\.innerHTML = "[\s\S]{0,400}?<\/div>";/,
    to: '$("pennote").innerHTML = "<div>技能点を <b>"+n+"</b> 点持っている。</div>"\n'
      + '          + "<div>ペンを返せば、ギルドがいまこの"+n+"点を、あなたの職の道筋に沿って使う。'
      + 'やり直しはリセット以外にきかない。</div>";' },
  { name: 'plural-penyes',
    find: /\$\("penyes"\)\.textContent = n===1 \? "[^"]*" : "[^"]*"\+n;/,
    to: '$("penyes").textContent = n+"点すべてギルドに任せる";' },
  { name: 'plural-seasonpts',
    find: /"[^"]*"\+\(seasonPts===1 \? "" : "s"\)\+/,
    to: '" 点"+' },
  { name: 'plural-days',
    find: /\(d === 1 \? "[^"]*" : "[^"]*"\)/, to: '"日間"' },
  { name: 'plural-badges',
    find: /\(b === 1 \? "[^"]*" : "[^"]*"\)/, to: '"個の記章"' },
  { name: 'plural-weight',
    find: /\(weight === 1 \? "[^"]*" : "[^"]*"\)/, to: '"点"' },

  // 売却ログの語順（"Sold X for N gold pieces." → 「Xを N ゴールドで売った。」）
  //
  // 訳語が変わっても当たるように、文字列そのものではなく組み立ての骨格で拾う。
  // 翻訳エンジンを差し替えるとリテラルの中身は変わるが、コードの形は変わらない。
  { name: 'sold-log',
    find: /log\("Sold "\+lootName\(item, ri, true\)\+\(qty>1\?" ×"\+qty:""\)\+[\s\S]{0,160}?\+pick\(remarks\)\);/,
    to: `log(lootName(item, ri, true)+(qty>1?" ×"+qty:"")+"を<span class='lg-gold'>"+fmtNum(got)+" ゴールド</span>で売った。"+pick(remarks));` },

  // 商店の一行。訳すとタグと文の順序が崩れやすいので、丸ごと書き直す。
  { name: 'counter-info',
    find: /\$\("counterinfo"\)\.innerHTML = "[\s\S]{0,200}?fmtNum\(G\.gold\)[\s\S]{0,80}?;/,
    to: '$("counterinfo").innerHTML = "ペル親方が買取台の向こうで待っている。手持ちは<b id=\\"counterpurse\\">"+fmtNum(G.gold)+" ゴールド</b>。";' },

  // G.equip.Weapon: "Weapon" を訳したのでドット参照も添字に直す
  { name: 'equip-weapon', find: /G\.equip\.Weapon\b/g, to: 'G.equip[' + JSON.stringify(tm['Weapon'] || 'Weapon') + ']' },
];

// テンプレートリテラルの中に閉じ込められていて、リテラル置換では届かない断片。
// 出現箇所が一意であることを数えたうえで、文字列としてそのまま差し替える。
// 1.5.x のころ、抽出器が届かなかった場所を力ずくで置き換えていた名残。
// 静的HTML とテンプレートの地の文を抽出するようになってからは通常の経路で
// 訳されるので、冒険者票の見出し（Name / Folk / Trade / Origin / Rank）と
// "Rank ${G.level}" はここから外した。当たらない規則を残しておくと、
// 毎回の SUB miss が背景雑音になり、本当に死んだ規則を見落とす。
const LITERAL_SUBS = [
  ['gold pieces', 'ゴールド'],
  ['"Rank "+G.level', '"ランク"+G.level'],
  ['"battle-fury"', '"戦の猛り"'],
  // 日本語に複数形はない。掲示板が「ゴブリンsを追い返す」「バグベアsを始末する」と
  // 書いていたのはこれ。英語名詞に s を足す関数がそのまま日本語名詞に効いていた。
  ['const plural = s => /s$/.test(s) ? s+"es" : s+"s";',
   'const plural = s => s;   /* 日本語に複数形はない */'],
  // 同じ理由で、単複を出し分けている箇所も畳む。「3選択s待っている」が出ていた。
  ['function art(s){ return /^[aeiou]/i.test(s) ? "an " : "a "; }',
   'function art(s){ return ""; }   /* 日本語に不定冠詞は要らない */'],
];

let subHits = 0;
// 1.6.3 以降は難読化されており、整形版向けのこれらは当たらない。
// structure.mjs が同じ役目を位置指定で果たすので、ここは黙って飛ばす。
for (const [from, to] of (MINIFIED ? [] : LITERAL_SUBS)) {
  const n = out.split(from).length - 1;
  if (!n) { console.warn('  ! SUB miss:', JSON.stringify(from.slice(0, 60))); continue; }
  out = out.split(from).join(to);
  subHits += n;
}
console.log('literal substitutions:', subHits);

let reordered = [];
for (const r of (MINIFIED ? [] : REORDER)) {
  const before = out;
  out = out.replace(r.find, r.to);
  if (out !== before) reordered.push(r.name);
  else console.warn('  ! REORDER miss:', r.name);
}

// --- 日本語フォントを font-family に足す --------------------------------
const FONT_EN = 'Tahoma,"MS Sans Serif",Geneva,sans-serif';
const FONT_JA = 'Tahoma,"MS Sans Serif","MS PGothic","Yu Gothic UI",Meiryo,Geneva,sans-serif';
let fontHits = 0;
out = out.split(FONT_EN).join(FONT_JA);
fontHits += (src.match(/Tahoma,"MS Sans Serif",Geneva,sans-serif/g) || []).length;
const FONT_EN2 = "Tahoma,'MS Sans Serif',Geneva,sans-serif";
const FONT_JA2 = "Tahoma,'MS Sans Serif','MS PGothic','Yu Gothic UI',Meiryo,Geneva,sans-serif";
fontHits += (out.match(/Tahoma,'MS Sans Serif',Geneva,sans-serif/g) || []).length;
out = out.split(FONT_EN2).join(FONT_JA2);

// 日本語は単語間スペースで折り返せないので、はみ出す箇所を素直に折り返させる
out = out.replace('</style>', '  /* JA localisation */\n  body, button, input, select, textarea{ line-break:strict; }\n  .log, .tip, td, th, label, button{ overflow-wrap:anywhere; }\n</style>');

// 数値を述べる行の語彙表を、日本語の語順で書き直す。
// 断片ごとの訳では語順が英語のまま残るため、関数ごと差し替える。
if (!MINIFIED) out = rewriteLexicon(out);
else console.log('  語彙表: 難読化版のため mechLine を包む方式で差し替え済み');

// 設定に字体の切替を足す（同梱フォントの埋め込みを含む）
out = addFontSwitcher(out);

fs.mkdirSync('build', { recursive: true });
fs.writeFileSync(OUT, out);

console.log("translated literals applied:", applied);
console.log("html strings applied:", htmlApplied, "skipped:", htmlSkipped);
console.log("template texts applied:", tplApplied, "skipped:", tplSkipped);
console.log("  うち表示位置と判定した箇所:", displaySites, "/ 表示位置の範囲:", displayRanges.length);
console.log('skipped (validation):', skipped, reasons);
console.log('unquoted object keys rewritten:', keyRewrites, [...keyNames].join(','));
console.log('font-family sites patched:', fontHits);
console.log('output:', OUT, (out.length / 1024 / 1024).toFixed(2) + ' MB');

// --- lint: ブラウザ API に日本語が渡っていないか -------------------------
// scrollIntoView({block:"最も近い"}) の類は構文としては正しく、実行して初めて落ちる。
// 出力を読み直して、日本語が API の値の位置に立っていないかを機械的に見る。
const JA = '[ぁ-んァ-ヶ一-龯]';
const LINTS = [
  ['DOM option value', new RegExp('\\b(?:behavior|block|inline|position|mode|format|method|type)\\s*:\\s*["\'][^"\']*' + JA, 'g')],
  ['createElement/getContext', new RegExp('(?:createElement|createElementNS|getContext|matchMedia)\\(\\s*["\'][^"\']*' + JA, 'g')],
  ['setAttribute name', new RegExp('setAttribute\\(\\s*["\'][^"\']*' + JA, 'g')],
  ['typeof compare', new RegExp('typeof\\s+[\\w.$\\[\\]]+\\s*[!=]==\\s*["\'][^"\']*' + JA, 'g')],
  ['style value', new RegExp('\\.style\\.\\w+\\s*=\\s*["\'][^"\']*' + JA, 'g')],
  ['localStorage key', new RegExp('localStorage\\.\\w+\\(\\s*["\'][^"\']*' + JA, 'g')],
  ['addEventListener type', new RegExp('addEventListener\\(\\s*["\'][^"\']*' + JA, 'g')],
  ['querySelector', new RegExp('querySelector(?:All)?\\(\\s*["\'][^"\']*' + JA, 'g')],
  ['fetch url', new RegExp('fetch\\(\\s*["\'][^"\']*' + JA, 'g')],
  ['classList', new RegExp('classList\\.\\w+\\(\\s*["\'][^"\']*' + JA, 'g')],
  // location.protocol === "ファイル：" の類。構文は通るが判定が永久に偽になる。
  ['runtime compare', new RegExp('\\b(?:location|navigator|document|window|screen|history|process)\\.[\\w.]+\\s*[!=]==\\s*["\'][^"\']*' + JA, 'g')],
  // .style.clipPath = "インセット(0" の類。例外を出さずにブラウザが値を捨てるので、
  // 実行してもエラーにならない。ゲージが白いまま、という形でしか気づけない。
  // 難読化されると `.style.padding="4px 2px",x.textContent="日本語"` のように
  // 別の代入がカンマで連なる。値の外まで見に行かないよう、式の境界で止める。
  ['style assignment', new RegExp('\\.style\\.\\w+\\s*=\\s*[^;\\n,]{0,80}?["\'][^"\']*' + JA, 'g')],
  ['setProperty', new RegExp('setProperty\\([^;\\n]{0,160}?' + JA, 'g')],
  ['cssText', new RegExp('cssText\\s*=[^;\\n]{0,160}?' + JA, 'g')],
  // Web Audio / canvas の列挙値。ここが訳されると **音が出なくなる**。
  //   tone.type = "lowpass"            → "ローパス"  … 例外を投げる
  //   audioCtx.state === "running"     → "流れる"    … 永久に偽、音が鳴らない
  //   function beep(…, type="square")  → "平方"      … 例外を投げる
  //   ctx.font = …+"px Tahoma, sans-serif" → "…、サンセリフ体" … 字体が既定に落ちる
  // 例外を投げるものは console にも出るが、比較が偽になるものは何も出ない。
  // 1.6.1 でこれを丸ごと出荷してしまったので、ビルドで止める。
  ['audio/canvas enum',
    new RegExp('\\.(?:type|oversample|panningModel|distanceModel|textBaseline|textAlign'
      + '|lineCap|lineJoin|globalCompositeOperation)\\s*=\\s*["\'][^"\']*' + JA, 'g')],
  ['audio/canvas enum (既定引数)',
    new RegExp('\\b(?:type|oversample|textBaseline|textAlign|lineCap|lineJoin)\\s*=\\s*'
      + '["\'][^"\']*' + JA + '[^"\']*["\']\\s*[),]', 'g')],
  ['state compare',
    new RegExp('\\.(?:state|readyState|visibilityState)\\s*[!=]==?\\s*["\'][^"\']*' + JA, 'g')],
  // font は連結で組み立てるので、代入の右辺ぜんぶを見る
  ['canvas font', new RegExp('\\.font\\s*=[^;\\n]{0,200}?' + JA, 'g')],
];
let lintHits = 0;
for (const [name, re] of LINTS) {
  const hits = out.match(re);
  if (hits) {
    lintHits += hits.length;
    console.error('LINT ' + name + ': ' + hits.length);
    [...new Set(hits)].slice(0, 5).forEach(h => console.error('   ' + h.replace(/\s+/g, ' ')));
  }
}
console.log(lintHits ? 'LINT: ' + lintHits + ' PROBLEM(S)' : 'LINT: clean');
if (lintHits) process.exitCode = 1;

// --- 健全性チェック: JS がパースできるか -------------------------------
const js = [...out.matchAll(/<script>([\s\S]*?)<\/script>/g)].map(m => m[1]).join('\n;\n');
try {
  new Function(js);
  console.log('SYNTAX CHECK: OK');
} catch (e) {
  console.error('SYNTAX CHECK: FAILED ->', e.message);
  process.exitCode = 1;
}
