#!/usr/bin/env node
/*
 * Emit Android string resources for push-notification fallback text.
 *
 * Single source of truth: public/locales/{en,ru}.json under the `Push`
 * namespace (the same file i18next loads on web). This script takes the
 * Android-relevant subset and writes values{,-ru}/push_strings.xml.
 *
 * Usage:
 *   node scripts/gen-push-strings.mjs              # default: android/app/build/generated/res/push/manual/
 *   node scripts/gen-push-strings.mjs --out <dir>  # write into <dir>/values{,-ru}/
 *
 * The Gradle build calls this with --out into build/generated/res/push/<variant>/.
 * The no-arg default writes into the build dir too, so generated XMLs never
 * appear in src/main/res/.
 *
 * Why not parse the JSON at runtime in VojoFirebaseMessagingService?
 * Because that hot path runs before WebView, before JS, under killed
 * process — any JSONObject parse failure becomes a missed push. The
 * review agent's verdict (see §5.27): keep Android on aapt-validated
 * resources, pay the drift cost at build time, not at push time.
 */

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 LOCALES_DIR = path.join(ROOT, 'public', 'locales');
const DEFAULT_OUT = path.join(
  ROOT,
  'android',
  'app',
  'build',
  'generated',
  'res',
  'push',
  'manual'
);

// Keys from the Push namespace that end up in Android resources.
// SW-only strings (encrypted_message, incoming_call, open_to_answer) are
// consumed by the Service Worker at runtime against the JSON bundle — no
// need to ship them as Android resources.
const ANDROID_KEYS = [
  'new_message',
  'new_messages',
  'invitation',
  'invite_body',
  'invite_body_no_room',
  'invite_body_no_inviter',
  'invite_body_generic',
  'missed_call',
  'missed_call_body',
  'channel_group',
  'channel_dm',
  'channel_dm_description',
  'channel_group_room',
  'channel_group_room_description',
  'self_name',
  'action_mark_as_read',
  'action_reply',
  'reply_hint',
  'reply_failed',
];

// i18next uses named placeholders ({{inviter}}); Android string resources
// use positional formatters (%1$s). Hardcoding the name→position map here
// keeps the Java call signature stable — PushStrings.inviteBody(ctx,
// inviter, roomName) always passes inviter in position 1, roomName in
// position 2, regardless of how the translators order them in the JSON.
// Adding a new placeholder: add it here AND update PushStrings accordingly.
// `caller` reuses position 1: it only appears in missed_call_body, which
// has no other placeholders, so the position assignment is keyed per-key
// in practice — the table just enumerates every placeholder name we accept.
const PLACEHOLDER_POSITIONS = {
  inviter: 1,
  roomName: 2,
  caller: 1,
};

const LANGS = {
  en: 'values',
  ru: 'values-ru',
};

function convertPlaceholders(text, locale, key) {
  const seen = new Set();
  const converted = text.replace(/\{\{(\w+)\}\}/g, (_, name) => {
    const pos = PLACEHOLDER_POSITIONS[name];
    if (!pos) {
      throw new Error(
        `[${locale}] Push.${key}: unknown placeholder {{${name}}}. ` +
          `Add it to PLACEHOLDER_POSITIONS in gen-push-strings.mjs ` +
          `and thread the value through PushStrings.java.`
      );
    }
    seen.add(name);
    return `%${pos}$s`;
  });
  return { text: converted, placeholders: seen };
}

// Minimal XML escaping for string resources. Single/double quotes must be
// backslash-escaped per Android resource rules; ampersand/angle brackets
// get entity-encoded. No string in the Push namespace today contains any
// of these, but the check has to exist because translators will edit
// the source JSON and the script must not silently corrupt their copy.
function xmlEscape(text) {
  return text
    .replace(/&/g, '&amp;')
    .replace(/</g, '&lt;')
    .replace(/>/g, '&gt;')
    .replace(/'/g, "\\'")
    .replace(/"/g, '\\"');
}

function readBundle(locale) {
  const jsonPath = path.join(LOCALES_DIR, `${locale}.json`);
  const raw = fs.readFileSync(jsonPath, 'utf8');
  const parsed = JSON.parse(raw);
  const push = parsed?.Push;
  if (!push || typeof push !== 'object') {
    throw new Error(`${jsonPath} is missing the "Push" namespace`);
  }
  return push;
}

function verifyParity(bundles) {
  const locales = Object.keys(bundles);
  const [first, ...rest] = locales;
  const firstKeys = new Set(Object.keys(bundles[first]));
  rest.forEach((locale) => {
    const keys = new Set(Object.keys(bundles[locale]));
    const missingInOther = [...firstKeys].filter((k) => !keys.has(k));
    const extraInOther = [...keys].filter((k) => !firstKeys.has(k));
    if (missingInOther.length || extraInOther.length) {
      throw new Error(
        `Push namespace key drift between "${first}" and "${locale}".\n` +
          `  Missing in ${locale}: ${JSON.stringify(missingInOther)}\n` +
          `  Extra in ${locale}: ${JSON.stringify(extraInOther)}`
      );
    }
  });
  ANDROID_KEYS.forEach((key) => {
    locales.forEach((locale) => {
      if (typeof bundles[locale][key] !== 'string') {
        throw new Error(`Push.${key} missing or non-string in ${locale}.json`);
      }
    });
    // Placeholder tokens must match across locales for any given key —
    // a translator adding {{user}} on one side silently produces
    // literal-curly-brace output on the other surface.
    const tokenSets = locales.map((locale) => {
      const text = bundles[locale][key];
      const tokens = new Set();
      text.replace(/\{\{(\w+)\}\}/g, (_, name) => {
        tokens.add(name);
        return '';
      });
      return { locale, tokens };
    });
    const baseline = tokenSets[0];
    tokenSets.slice(1).forEach((entry) => {
      const baselineArr = [...baseline.tokens].sort();
      const entryArr = [...entry.tokens].sort();
      if (baselineArr.length !== entryArr.length || baselineArr.some((t, i) => t !== entryArr[i])) {
        throw new Error(
          `Placeholder drift on Push.${key}: ` +
            `${baseline.locale}=${JSON.stringify(baselineArr)}, ` +
            `${entry.locale}=${JSON.stringify(entryArr)}`
        );
      }
    });
  });
}

function emitResource(locale, bundle, resDir) {
  const lines = [
    "<?xml version='1.0' encoding='utf-8'?>",
    '<!--',
    '  AUTO-GENERATED by scripts/gen-push-strings.mjs.',
    `  DO NOT EDIT — edit public/locales/${locale}.json and rerun`,
    '  the Gradle build (or `npm run gen:push-strings` manually).',
    '-->',
    '<resources>',
  ];
  ANDROID_KEYS.forEach((key) => {
    const raw = bundle[key];
    const { text, placeholders } = convertPlaceholders(raw, locale, key);
    const formattedAttr = placeholders.size > 0 ? ' formatted="true"' : '';
    lines.push(`    <string name="push_${key}"${formattedAttr}>${xmlEscape(text)}</string>`);
  });
  lines.push('</resources>');
  lines.push('');
  const outPath = path.join(resDir, LANGS[locale], 'push_strings.xml');
  fs.mkdirSync(path.dirname(outPath), { recursive: true });
  fs.writeFileSync(outPath, lines.join('\n'), 'utf8');
  return outPath;
}

function main() {
  const outIdx = process.argv.indexOf('--out');
  if (outIdx !== -1 && !process.argv[outIdx + 1]) {
    throw new Error('--out requires a directory argument');
  }
  const resDir = outIdx !== -1 ? path.resolve(process.argv[outIdx + 1]) : DEFAULT_OUT;

  const bundles = Object.keys(LANGS).reduce((acc, locale) => {
    acc[locale] = readBundle(locale);
    return acc;
  }, {});
  verifyParity(bundles);
  Object.keys(LANGS).forEach((locale) => {
    const outPath = emitResource(locale, bundles[locale], resDir);
    process.stdout.write(`  wrote ${path.relative(ROOT, outPath)}\n`);
  });
}

try {
  main();
} catch (err) {
  process.stderr.write(`gen-push-strings: ${err.message}\n`);
  process.exit(1);
}
