/**
 * Encoding-aware text layer on top of the pure byte sniffer in binary-detect.ts.
 *
 * binary-detect.ts answers a binary yes/no and recognizes UTF-8 / BOM-tagged
 * UTF-16 only — every other text encoding (Shift_JIS/CP932, EUC-JP, ISO-2022-JP,
 * windows-1252/Latin-1, …) trips its strict-UTF-8 gate and is reported as
 * `utf8-decode-fail`, i.e. "binary". That is wrong for the common case of a
 * Japanese .txt / CSV saved on Windows (CP932).
 *
 * This module recovers those: when the sniffer's only complaint is
 * `utf8-decode-fail`, it trial-decodes a short ladder of candidate encodings
 * with iconv-lite and picks the one that produces the least mojibake. Callers
 * (Read/Grep/Edit) then transcode the whole file to UTF-8 for the LLM while
 * keeping the raw bytes so edits round-trip back to the original encoding.
 *
 * iconv-lite is pure JS (no ICU dependency), so Shift_JIS/EUC-JP decoding works
 * the same on small-icu Node builds where the native TextDecoder lacks those
 * labels. Only UTF-8 and BOM-tagged UTF-16 stay on the built-in decoder.
 */

import iconv from 'iconv-lite';
import {
  looksLikeBinaryBytes,
  controlCharRatio,
  CONTROL_CHAR_RATIO_THRESHOLD,
  SNIFF_HEAD_BYTES,
  stripLeadingBom,
} from './binary-detect.js';

const UTF16LE_BOM = Buffer.from([0xff, 0xfe]);
const UTF16BE_BOM = Buffer.from([0xfe, 0xff]);
const UTF8_BOM = Buffer.from([0xef, 0xbb, 0xbf]);

// Internal encoding label for UTF-8 files that carry a leading BOM. TextDecoder
// strips the BOM on read, so without tracking it separately an Edit/Write would
// silently drop it and break Windows tools that expect it. Treated as UTF-8
// everywhere except that the BOM is re-added on encode.
export const UTF8_BOM_LABEL = 'utf-8-bom';

function hasUtf8Bom(buf: Buffer): boolean {
  return buf.length >= 3 && buf[0] === 0xef && buf[1] === 0xbb && buf[2] === 0xbf;
}

/**
 * Strict UTF-8 decode of a COMPLETE buffer. Unlike binary-detect's streaming
 * `decodeText` (used for 8 KB head sniffing, where a multibyte char cut at the
 * sample boundary must be tolerated), this flushes at EOF, so a genuinely
 * truncated trailing sequence is fatal instead of being silently dropped.
 */
function strictUtf8Whole(buf: Buffer): string | null {
  try {
    return new TextDecoder('utf-8', { fatal: true }).decode(buf);
  } catch {
    return null;
  }
}

export type TextEncodingVerdict =
  | { binary: true; reason: string }
  | { binary: false; encoding: string };

export type DecodedFile =
  | { binary: true; reason: string }
  | { binary: false; encoding: string; text: string };

/**
 * Candidate encodings tried, in priority order, when a buffer is not valid
 * UTF-8 but also shows no hard binary signal. Japanese-first because that is
 * the dominant real case (Windows CP932). windows-1252 is last: it decodes
 * almost any byte, so it must only win when the multibyte candidates all fail
 * (strict `<` tie-break below keeps the earliest candidate on equal scores).
 */
// Japanese-first, and deliberately Japanese-only among the multibyte codecs:
// GBK/Big5 (Chinese) would grab ambiguous kana/halfwidth Japanese and mojibake
// it. windows-1252 last as the single-byte Western fallback (see the tie-break).
const CANDIDATE_ENCODINGS = ['Shift_JIS', 'EUC-JP', 'ISO-2022-JP', 'windows-1252'];

/** Accept a trial decode only when at most this fraction of chars are garbage. */
const MOJIBAKE_ACCEPT_RATIO = 0.02;

/**
 * A near-zero mojibake score is not enough on its own: windows-1252 maps almost
 * every byte and multibyte CJK encodings (GBK/Big5) accept many random byte
 * pairs, so high-entropy binary can decode "cleanly" into a soup of symbols and
 * rare ideographs. Require that the decoded chars are also *predominantly*
 * ordinary text — ASCII, Japanese kana/kanji, CJK punctuation, fullwidth forms,
 * or Latin accented letters. Real text scores ~1.0; binary-as-cp1252/GBK scores
 * well below this because it is dominated by symbol/rare-block code points.
 */
const PLAUSIBLE_TEXT_MIN = 0.8;

const REPLACEMENT_CHAR = 0xfffd;

/** Fraction of decoded chars that are U+FFFD or non-whitespace C0/C1 controls. */
function mojibakeRatio(decoded: string): number {
  if (decoded.length === 0) return 1;
  let bad = 0;
  for (let i = 0; i < decoded.length; i++) {
    const code = decoded.charCodeAt(i);
    if (code === REPLACEMENT_CHAR) {
      bad++;
    } else if (
      (code <= 0x08 || code === 0x0b || code === 0x0c || (code >= 0x0e && code <= 0x1f) ||
        code === 0x7f || (code >= 0x80 && code <= 0x9f))
    ) {
      bad++;
    }
  }
  return bad / decoded.length;
}

/** True for code points that appear in ordinary human text (see PLAUSIBLE_TEXT_MIN). */
function isPlausibleTextChar(code: number): boolean {
  return (
    code === 0x09 || code === 0x0a || code === 0x0d || // tab / newlines
    (code >= 0x20 && code <= 0x7e) ||                  // ASCII printable
    (code >= 0x00c0 && code <= 0x00ff) ||              // Latin-1 accented letters
    (code >= 0x3000 && code <= 0x303f) ||              // CJK symbols & punctuation
    (code >= 0x3040 && code <= 0x309f) ||              // Hiragana
    (code >= 0x30a0 && code <= 0x30ff) ||              // Katakana
    (code >= 0x4e00 && code <= 0x9fff) ||              // CJK Unified Ideographs
    (code >= 0xff00 && code <= 0xffef)                 // Fullwidth / halfwidth forms
  );
}

/** Fraction of decoded chars that are ordinary text (not stray symbols/rare blocks). */
function plausibleTextRatio(decoded: string): number {
  if (decoded.length === 0) return 0;
  let ok = 0;
  for (let i = 0; i < decoded.length; i++) {
    if (isPlausibleTextChar(decoded.charCodeAt(i))) ok++;
  }
  return ok / decoded.length;
}

/**
 * Fraction of chars that are *body-text* Japanese/Latin — like isPlausibleTextChar
 * but EXCLUDING halfwidth katakana (U+FF61–FF9F) and Latin-1 symbols. Used only to
 * break ties between candidates: EUC-JP kana decoded (wrongly) as Shift_JIS lands
 * in halfwidth katakana, so the candidate whose decode is richer in fullwidth
 * kana/kanji is the right one. Two encodings can both hit zero mojibake, so this
 * secondary key is what stops EUC-JP text from being mislabeled Shift_JIS.
 */
function preferredTextRatio(decoded: string): number {
  if (decoded.length === 0) return 0;
  let ok = 0;
  for (let i = 0; i < decoded.length; i++) {
    const code = decoded.charCodeAt(i);
    if (
      code === 0x09 || code === 0x0a || code === 0x0d ||
      (code >= 0x20 && code <= 0x7e) ||                  // ASCII printable
      (code >= 0x00c0 && code <= 0x00ff) ||              // Latin-1 accented letters
      (code >= 0x3000 && code <= 0x303f) ||              // CJK symbols & punctuation
      (code >= 0x3040 && code <= 0x309f) ||              // Hiragana
      (code >= 0x30a0 && code <= 0x30ff) ||              // full Katakana
      (code >= 0x4e00 && code <= 0x9fff) ||              // CJK Unified Ideographs
      (code >= 0xff01 && code <= 0xff60)                 // Fullwidth forms (not halfwidth)
    ) {
      ok++;
    }
  }
  return ok / decoded.length;
}

/**
 * Extend the pure binary sniffer with non-UTF-8 text recovery.
 * Returns a concrete encoding label callers pass to decodeToUtf8.
 */
export function detectTextEncoding(head: Buffer): TextEncodingVerdict {
  const base = looksLikeBinaryBytes(head);
  if (!base.binary) return { binary: false, encoding: base.encoding };

  // Only a strict-UTF-8 failure is potentially recoverable text. A magic-byte
  // signature, an embedded NUL, or a high control-char ratio is a hard binary
  // signal — leave those rejected exactly as before.
  if (base.reason !== 'utf8-decode-fail') return base;

  // utf8-decode-fail returns before binary-detect's own control-ratio gate, so
  // re-check it here: high control density means binary, not exotic-encoded text.
  if (controlCharRatio(head) > CONTROL_CHAR_RATIO_THRESHOLD) {
    return { binary: true, reason: base.reason };
  }

  let best: string | null = null;
  let bestScore = Infinity;
  let bestMojibake = 1;
  let bestPlausible = 0;
  for (const enc of CANDIDATE_ENCODINGS) {
    if (!iconv.encodingExists(enc)) continue;
    const decoded = iconv.decode(head, enc);
    const mojibake = mojibakeRatio(decoded);
    // Primary key: mojibake (×10 so it dominates). Secondary: prefer the decode
    // richer in body-text Japanese/Latin, which separates true EUC-JP from the
    // same bytes decoded as Shift_JIS halfwidth katakana.
    const score = mojibake * 10 + (1 - preferredTextRatio(decoded));
    if (score < bestScore) {
      bestScore = score;
      best = enc;
      bestMojibake = mojibake;
      bestPlausible = plausibleTextRatio(decoded);
    }
  }

  if (best && bestMojibake <= MOJIBAKE_ACCEPT_RATIO && bestPlausible >= PLAUSIBLE_TEXT_MIN) {
    return { binary: false, encoding: best.toLowerCase() };
  }
  return { binary: true, reason: base.reason };
}

/** Decode raw file bytes of a known encoding into a UTF-8 JS string. */
export function decodeToUtf8(buf: Buffer, encoding: string): string {
  if (encoding === 'utf-8') return buf.toString('utf-8');
  if (encoding === UTF8_BOM_LABEL) return stripLeadingBom(buf.toString('utf-8'));
  return iconv.decode(buf, encoding);
}

/**
 * Encode an (edited) UTF-8 string back into bytes of the original encoding.
 *
 * For UTF-16 the BOM is re-added: the encoding label is only produced for
 * BOM-tagged files (iconv strips the BOM on decode and does not re-add it on
 * encode), and a BOMless UTF-16 file would be rejected as binary on the next
 * Read (leading NUL). Re-adding it keeps the round-trip readable.
 */
export function encodeFromUtf8(text: string, encoding: string): Buffer {
  if (encoding === 'utf-8') return Buffer.from(text, 'utf-8');
  if (encoding === UTF8_BOM_LABEL) return Buffer.concat([UTF8_BOM, Buffer.from(text, 'utf-8')]);
  const body = iconv.encode(text, encoding);
  if (encoding === 'utf-16le') return Buffer.concat([UTF16LE_BOM, body]);
  if (encoding === 'utf-16be') return Buffer.concat([UTF16BE_BOM, body]);
  return body;
}

/**
 * Decode a WHOLE file buffer to a UTF-8 string, choosing the encoding.
 *
 * Detection runs over the entire buffer, not just the head: a file whose first
 * 8 KiB is pure ASCII (a long English header/comment) but whose tail is
 * Shift_JIS must not be mistaken for UTF-8 and mangled to U+FFFD. The fast path
 * is a single whole-file strict UTF-8 decode; only when that fails do we run the
 * candidate ladder.
 */
export function decodeFileText(full: Buffer): DecodedFile {
  // Hard binary signals (magic byte / NUL / high control ratio) MUST be checked
  // before any UTF-8 fast path: a PDF/ZIP/GIF header can be valid UTF-8, and NUL
  // is itself valid UTF-8, so trusting the fast path would let Grep leak binary.
  const head = full.length > SNIFF_HEAD_BYTES ? full.subarray(0, SNIFF_HEAD_BYTES) : full;
  const headVerdict = detectTextEncoding(head);
  if (headVerdict.binary) return headVerdict;

  // BOM-tagged UTF-16 legitimately contains NUL bytes; decode via its encoding
  // and skip the NUL gate below (which would otherwise reject it as binary).
  if (headVerdict.encoding === 'utf-16le' || headVerdict.encoding === 'utf-16be') {
    return { binary: false, encoding: headVerdict.encoding, text: decodeToUtf8(full, headVerdict.encoding) };
  }

  // Whole-file hard-binary check: an ASCII head can hide NUL / high control ratio
  // in the tail (the head-only verdict above cannot see it).
  if (full.includes(0)) return { binary: true, reason: 'nul-byte' };
  if (controlCharRatio(full) > CONTROL_CHAR_RATIO_THRESHOLD) {
    return { binary: true, reason: `control-ratio:${controlCharRatio(full).toFixed(2)}` };
  }

  // Whole-file UTF-8 validation (an ASCII prefix must not hide a Shift_JIS tail;
  // a truncated trailing multibyte must not be silently dropped either). Track a
  // leading BOM so Edit/Write can re-add it.
  const utf8 = strictUtf8Whole(full);
  if (utf8 !== null) {
    return { binary: false, encoding: hasUtf8Bom(full) ? UTF8_BOM_LABEL : 'utf-8', text: utf8 };
  }

  const verdict = detectTextEncoding(full);
  if (verdict.binary) return verdict;
  return { binary: false, encoding: verdict.encoding, text: decodeToUtf8(full, verdict.encoding) };
}

/**
 * Encode a UTF-8 string back to bytes of `encoding`, but only if it round-trips.
 * iconv-lite silently substitutes unrepresentable chars (e.g. an emoji written
 * into a Shift_JIS file) with '?', which would corrupt the save. Returns null so
 * the caller can refuse rather than write bytes that differ from the request.
 */
export function encodeFileText(text: string, encoding: string): Buffer | null {
  const bytes = encodeFromUtf8(text, encoding);
  if (encoding === 'utf-8') return bytes;
  return decodeToUtf8(bytes, encoding) === text ? bytes : null;
}
