// ゲームが更新されたときに、増えた文だけを訳して日本語版を作り直す。
//
//   node update.mjs            -- 差分を数えるだけ（訳さない）
//   node update.mjs --run      -- 差分を訳してビルドまで
//   node update.mjs --run --pack  -- asar の作り直しまで
//
// 手順:
//   1. Steam 側の app.asar を展開する（英語の原版であることを確かめる）
//   2. 版が上がっているか見る
//   3. 文字列を抜き直し、tm.json にまだ無いものを数える
//   4. PLaMo で差分だけ訳す
//   5. ビルド → 検査 → 詰め直し
import fs from 'fs';
import path from 'path';
import { execFileSync, execSync } from 'child_process';
import { gameVersion } from './verdetect.mjs';

const GAME = process.env.LOWMOOR_DIR
  || "E:/SteamLibrary/steamapps/common/Lowmoor Idle RPG An Adventurer's Chronicle";
const RES = path.join(GAME, 'resources');
const GATEWAY = process.env.PLAMO_GATEWAY || 'http://127.0.0.1:8080';
const RUN = process.argv.includes('--run');
const PACK = process.argv.includes('--pack');

// Windows では npx は npx.cmd なので shell 経由でないと spawn できない。
// 一方 node.exe は "C:\Program Files\..." で空白を含むから、shell に通すと
// そこで切れる。だから二つに分ける。
const q = s => '"' + String(s).replace(/"/g, '\\"') + '"';
const sh = (cmd, args) => execFileSync(cmd, args,
  { encoding: 'utf8', maxBuffer: 1 << 28, shell: process.platform === 'win32' });
const node = args => execFileSync(process.execPath, args,
  { encoding: 'utf8', maxBuffer: 1 << 28 });
const step = s => console.log('\n== ' + s);

/* ---- 1. 展開 -------------------------------------------------------- */
step('Steam 側の app.asar を展開');
const asar = path.join(RES, 'app.asar');
if (!fs.existsSync(asar)) throw new Error('見つかりません: ' + asar);

// 日本語版を入れたままだと、それを「原文」として読んでしまう。
// 更新後は Steam が英語の原版で上書きするので、そちらを待つ。
const head = fs.readFileSync(asar);
if (head.includes(Buffer.from('JAFONT_KEY'))) {
  const bak = path.join(RES, 'app.asar.en.bak');
  console.log('  いま入っているのは日本語版です。');
  if (fs.existsSync(bak)) {
    console.log('  → 退避してある英語版 (app.asar.en.bak) を原文として使います。');
    console.log('    ※ Steam の更新がまだ来ていないなら、それで正しい。');
    console.log('      更新後にやり直すときは、先に Steam に更新させてください。');
  } else {
    throw new Error('英語の原版が見つかりません。Steam で「ゲームファイルの整合性を確認」してください。');
  }
}
const source = head.includes(Buffer.from('JAFONT_KEY')) ? path.join(RES, 'app.asar.en.bak') : asar;

const prevVer = fs.existsSync('app/game/index.html')
  ? gameVersion(fs.readFileSync('app/game/index.html', 'utf8'))
  : null;

// これから読む原文の版を、展開する前に確かめる。
//
// 退避（app.asar.en.bak）は「無ければ取る」でしか作られないので、ゲームが
// 更新されたあとに日本語版を入れ直すと、退避だけ古い版のまま残る。それに
// 気づかず展開すると app/ が古い原文で上書きされ、手元の新しい原文が消える。
// 1.6.1 を訳そうとして、退避が 1.5.2 だったせいで 1.6.0 の原文を失った。
const srcVer = gameVersion(fs.readFileSync(source).toString('utf8'));
const cmpVer = (a, b) => {
  const pa = String(a).split('.').map(Number), pb = String(b).split('.').map(Number);
  for (let i = 0; i < Math.max(pa.length, pb.length); i++) {
    const d = (pa[i] || 0) - (pb[i] || 0);
    if (d) return d;
  }
  return 0;
};
if (prevVer && srcVer && cmpVer(srcVer, prevVer) < 0) {
  throw new Error(
    `原文として読もうとしているものが古すぎます（${srcVer}）。手元の app/ は ${prevVer} です。\n` +
    `  読もうとした先: ${source}\n` +
    '  これを展開すると手元の新しい原文が消えます。中止しました。\n' +
    '  退避（app.asar.en.bak）が更新前のまま残っているのが原因です。\n' +
    '  Steam の「ゲームファイルの整合性を確認」で英語の原版を入れ直してから、やり直してください。');
}

fs.rmSync('app', { recursive: true, force: true });

// asar は <名前>.unpacked を隣に探す。app.asar.en.bak から展開しようとすると
// app.asar.en.bak.unpacked を見に行って失敗するので、一時的に app.asar という
// 名前で並べ直してから展開する。
let extractFrom = source;
const tmp = path.join(process.cwd(), '.srctmp');
if (path.basename(source) !== 'app.asar') {
  fs.rmSync(tmp, { recursive: true, force: true });
  fs.mkdirSync(tmp, { recursive: true });
  fs.copyFileSync(source, path.join(tmp, 'app.asar'));
  const un = path.join(RES, 'app.asar.unpacked');
  if (fs.existsSync(un)) fs.cpSync(un, path.join(tmp, 'app.asar.unpacked'), { recursive: true });
  extractFrom = path.join(tmp, 'app.asar');
}
sh('npx', ['--yes', '@electron/asar', 'extract', q(extractFrom), 'app']);
fs.rmSync(tmp, { recursive: true, force: true });

const cur = fs.readFileSync('app/game/index.html', 'utf8');
const newVer = gameVersion(cur);
if (!newVer) throw new Error('版番号が読み取れません。ゲーム側の書き方が変わった可能性があります（verdetect.mjs を見直してください）。');
console.log(`  版: ${prevVer || '(不明)'} -> ${newVer}`);
if (prevVer && prevVer === newVer) console.log('  版は同じです。文面だけ変わっていることもあるので、下の差分で判断してください。');

/* ---- 2. 文字列を抜き直す -------------------------------------------- */
step('文字列を抜き直す');
// extract4 はテンプレートリテラルの中の地の文を拾う。ここに並べ忘れると、
// 前の版の out4-tpl.json（＝古いオフセット）がそのまま使われ、患部が
// 気づかれないまま英語で残る。1.6.1 の取り込みで実際に並べ忘れていた。
for (const s of ['extract.mjs', 'extract-html.mjs', 'extract2.mjs', 'extract3.mjs', 'extract4.mjs']) {
  if (fs.existsSync(s)) node([s]);
}
// 抽出物が今の原文と同じ時点のものか、念のため見る
{
  const srcAt = fs.statSync('app/game/index.html').mtimeMs;
  for (const f of ['out-label-safe.json', 'out-sentence.json', 'out-html-items.json',
    'out2-plain-go.json', 'out3-go.json', 'out4-tpl.json']) {
    if (fs.existsSync(f) && fs.statSync(f).mtimeMs < srcAt)
      throw new Error(`${f} が原文より古いままです。抽出の並びから漏れています。`);
  }
}

/* ---- 3. 差分を数える ------------------------------------------------ */
step('翻訳メモリに無いものを数える');
const tm = JSON.parse(fs.readFileSync('tm.json', 'utf8'));
const ov = JSON.parse(fs.readFileSync('overrides.json', 'utf8'));
const known = new Set([...Object.keys(tm), ...Object.keys(ov)]);

// out-html-items / out4-tpl は文字列ではなく {start,end,text} の並び。
// どちらも画面に出る文言なので、差分の数え上げに入れる。
const SETS = ['out-sentence.json', 'out-fragment-safe.json', 'out-label-safe.json',
  'out-html.json', 'out-html-items.json', 'out2-plain-go.json', 'out3-go.json', 'out4-tpl.json'];
const all = new Set();
for (const f of SETS) {
  if (!fs.existsSync(f)) continue;
  for (const it of JSON.parse(fs.readFileSync(f, 'utf8'))) {
    const s = typeof it === 'string' ? it : it.text;
    if (typeof s === 'string' && s.trim()) all.add(s);
  }
}

/* 訳す値打ちがあるか。
 *
 * 抽出は広めに拾うので、JWT や CSS セレクタ、属性の断片まで混ざる。それらを
 * モデルに通しても patch.mjs が最後に弾くだけで、時間と「要確認」の数だけが
 * 無駄に増える。実際 1.6.0 の一巡目は 50 件中 47 件が弾かれ、原因の大半が
 * これだった。訳す前に落としておく。 */
const worthTranslating = s => {
  if (s.includes('${')) return false;                       // テンプレート式
  if (/^[A-Za-z0-9_\-]{40,}$/.test(s) || s.includes('eyJ')) return false;  // 鍵・トークン
  if (/^[#.\[]/.test(s) || /:not\(|::|\[data-/.test(s)) return false;      // セレクタ
  if (/="/.test(s)) return false;                           // 属性の断片
  if (/^&|&utm_|&amp;/.test(s)) return false;               // クエリ文字列
  if (!/\s/.test(s) && s.length > 20) return false;         // 空白なしの長い塊
  if (!/[A-Za-z]{2}/.test(s)) return false;
  // SVG のパス。座標の羅列で、字よりも数字と記号のほうが多い。
  //
  // 以前は先頭1文字だけで見ていたので、"A Good Boy" "A Larger Satchel" のように
  // 「A␣」で始まる普通の文言まで巻き添えで捨てていた（A が円弧コマンドなので）。
  // 実績名がまとめて未訳のまま残っていたのはこれが原因。
  // 全体がパスの字種だけでできている場合に限って落とす。
  if (/^[MmLlHhVvCcSsQqTtAaZz][\d\s.,-]/.test(s)
    && /^[MmLlHhVvCcSsQqTtAaZz\d\s.,-]+$/.test(s)) return false;
  const letters = (s.match(/[A-Za-z]/g) || []).length;
  const digits = (s.match(/[\d.]/g) || []).length;
  if (digits > letters) return false;
  // JavaScript そのもの。
  //
  // 1.6.3 でゲームのコードが難読化され、連結を割って断片を拾う抽出器
  // （extract2 / extract3）が、コードの切れ端まで「訳す候補」として出すように
  // なった。4KB の JS ブロブを PLaMo に渡すと、長すぎて生成が詰まり、
  // 翻訳が丸ごと止まる。実際 872 件中 387 件がこれで、処理が停止した。
  if (/=>|\)\{|\|\||&&|JSON\.stringify|\};|;const |;let |=!0|=!1|\.push\(|document\.|window\./.test(s)) return false;
  const punct = (s.match(/[(){}[\];=<>|&]/g) || []).length;
  if (punct > s.length / 8) return false;        // 記号だらけ＝コード
  return true;
};

const todo = [...all].filter(s => !known.has(s)).filter(worthTranslating);
const dropped = [...all].filter(s => !known.has(s) && !worthTranslating(s)).length;
if (dropped) console.log(`  （訳す値打ちのないもの ${dropped} 件は除外しました：鍵・セレクタ・属性片など）`);
const chars = todo.reduce((a, s) => a + s.length, 0);
console.log(`  抽出 ${all.size} 件 / 未訳 ${todo.length} 件 / ${chars} 文字`);
if (todo.length) {
  console.log('  例:');
  todo.slice(0, 8).forEach(s => console.log('    ・' + JSON.stringify(s.slice(0, 80))));
  fs.writeFileSync('update-todo.json', JSON.stringify(todo, null, 1));
  console.log('  一覧: update-todo.json');
}
// 消えた文（もう使われていない訳）も数えておく。消しはしない。
const gone = Object.keys(tm).filter(s => !all.has(s)).length;
if (gone) console.log(`  なお、いまのゲームに出てこない訳が ${gone} 件あります（残しておいて害はありません）`);

if (!RUN) {
  console.log('\n--run を付けると、差分を訳してビルドまで進みます。');
  process.exit(0);
}

/* ---- 4. 差分を訳す --------------------------------------------------- */
if (todo.length) {
  step('差分を訳す');
  const h = await (await fetch(GATEWAY + '/api/health')).json().catch(() => ({ ok: false }));
  if (!h.ok) throw new Error('PLaMo が動いていません。F:\\PLaMo\\start-plamo.ps1 を実行してください。');
  console.log(`  ${h.model} / 用語 ${h.glossaryTerms} 語`);
  node(['translate-plamo.mjs', 'push-glossary']);
  execFileSync(process.execPath, ['translate-plamo.mjs','run','update-todo.json'], { stdio: 'inherit' });
  node(['repair-tm.mjs']);
} else {
  console.log('\n差分はありません。ビルドだけやり直します。');
}

/* ---- 5. ビルドと検査 ------------------------------------------------- */
step('ビルドと検査');
const out = node(['patch.mjs']);
console.log(out.split('\n').filter(l => /語彙表|字体|LINT|SYNTAX|applied|miss|!/.test(l)).join('\n'));
if (!/LINT: clean/.test(out) || !/SYNTAX CHECK: OK/.test(out))
  throw new Error('検査に通りませんでした。上の出力を確認してください。');

// ブラウザ API の列挙値が訳されていないか。訳されると音が止まる、字体が落ちる、
// といった「例外も lint も出ないのに壊れている」形になるので、ここで必ず見る。
step('システム側に触れていないかの点検');
console.log(node(['systemcheck.mjs']).trim());

step('API の列挙値の点検');
console.log(node(['apienum.mjs']).trim());

// 実際に音が鳴るところまで確かめる。静的検査だけだと、次に別の綴りで
// 同じことが起きたときに素通りする。
step('音が鳴るかの確認');
node(['mkapp.mjs']);
try {
  console.log(node(['audiotest.mjs', 'build/index-test.html']).split(/\r?\n/).slice(-14).join('\n'));
} catch (e) {
  console.error(String(e.stdout || '') + String(e.stderr || ''));
  throw new Error('音の確認に失敗しました。Web Audio の列挙値が訳されていないか確認してください。');
}

if (PACK) {
  step('asar を詰め直す');
  fs.rmSync('pack', { recursive: true, force: true });
  fs.mkdirSync('pack', { recursive: true });
  sh('cmd', ['/c', 'xcopy', 'app', 'pack\\app', '/E', '/I', '/Q', '/Y']);
  for (const d of ['pack/app/steam/builder', 'pack/app/steam/output'])
    fs.rmSync(d, { recursive: true, force: true });
  fs.copyFileSync('build/index.html', 'pack/app/game/index.html');
  fs.rmSync('dist-app.asar', { force: true });
  sh('npx', ['--yes', '@electron/asar', 'pack', 'pack/app', 'dist-app.asar',
    '--unpack-dir', q('node_modules/steamworks.js/**')]);
  fs.copyFileSync('dist-app.asar', 'release/app.asar');
  fs.copyFileSync('build/index.html', 'release/lowmoor-ja.html');
  fs.rmSync('pack', { recursive: true, force: true });
  console.log('  release/app.asar を更新しました ' +
    (fs.statSync('release/app.asar').size / 1e6).toFixed(1) + 'MB');
  console.log('  install-ja.ps1 を実行すると Steam 側に入ります。');
}
console.log('\n完了。');
