#!/usr/bin/env node
'use strict';
/*
 * prose-lint — Adom house-style linter.
 *
 * Flags em-dashes and the tics that make AI-written copy read as AI-written.
 * Deterministic, zero-dependency, markdown/code-aware (it will not flag a
 * `--flag` in a code span, a number range in a table, or a URL).
 *
 * Contract (adom-cli-design): human output is line-oriented and ends with an
 * OK:/ERROR: verdict + Hint: lines; `--json` emits { status, data, hints }.
 * Exit 0 = clean or soft-only; exit 1 = hard findings (em-dashes) — or any
 * finding under --strict; exit 2 = usage error.
 */

const fs = require('fs');
const path = require('path');

// ----------------------------------------------------------------------------
// Rules. severity: 'error' (hard — em-dashes), 'warn' (AI tells), 'info' (filler)
// A rule is { id, severity, re, msg, fix? }. `re` must be a global regex.
// `msg(m)` receives the RegExp match array. `fix` (optional) is the literal
// replacement for a safe 1:1 swap used by --fix.
// ----------------------------------------------------------------------------

// tell-words → plain-language suggestion (also drives --fix for 1:1 swaps)
const TELL_WORDS = {
  'delve': 'dig in / look at',
  'seamless': 'smooth / simple',
  'seamlessly': 'smoothly / cleanly',
  'in-depth': 'detailed / thorough',
  'robust': 'solid / reliable',
  'leverage': 'use',
  'leverages': 'uses',
  'leveraging': 'using',
  'utilize': 'use',
  'utilizes': 'uses',
  'utilizing': 'using',
  'utilise': 'use',
  'boasts': 'has',
  'cutting-edge': 'new / modern',
  'state-of-the-art': 'modern',
  'unlock': 'enable / get',
  'unleash': 'release / enable',
  'elevate': 'improve / raise',
  'empower': 'let / help',
  'empowers': 'lets / helps',
  'realm': 'area / world',
  'landscape': 'field / market',
  'tapestry': 'mix',
  'testament': 'proof / sign',
  'foster': 'build / support',
  'fosters': 'builds / supports',
  'underscore': 'show / stress',
  'underscores': 'shows / stresses',
  'showcase': 'show',
  'showcases': 'shows',
  'showcasing': 'showing',
  'embark': 'start',
  'ever-evolving': 'changing',
  'ever-changing': 'changing',
  'fast-paced': 'fast',
  'game-changer': 'big deal',
  'game-changing': 'major',
  'revolutionize': 'change / improve',
  'revolutionizes': 'changes / improves',
  'revolutionizing': 'changing / improving',
  'harness': 'use',
  'harnesses': 'uses',
  'streamline': 'simplify / speed up',
  'streamlines': 'simplifies',
  'holistic': 'whole / overall',
  'myriad': 'many',
  'plethora': 'plenty / many',
  'bustling': 'busy',
  'meticulous': 'careful',
  'meticulously': 'carefully',
  'vibrant': 'lively',
  'pivotal': 'key',
  'paramount': 'critical / top',
  'nestled': 'set / located',
  'endeavor': 'effort / try',
  'facilitate': 'help / enable',
  'facilitates': 'helps / enables',
  'commence': 'start',
  'commences': 'starts',
  'plethora of': 'many',
};
// 1:1 lowercase swaps safe for --fix (no "/" alternatives in value)
const SAFE_FIX = {
  'utilize': 'use', 'utilizes': 'uses', 'utilizing': 'using', 'utilise': 'use',
  'leverage': 'use', 'leverages': 'uses', 'leveraging': 'using',
  'commence': 'start', 'commences': 'starts',
  'facilitate': 'help', 'facilitates': 'helps',
  'boasts': 'has', 'myriad': 'many', 'plethora': 'plenty',
};

function tellWordRule() {
  const words = Object.keys(TELL_WORDS).filter((w) => !w.includes(' '));
  // longest first so "in-depth" wins over "depth"; escape hyphens
  const alt = words
    .sort((a, b) => b.length - a.length)
    .map((w) => w.replace(/[-]/g, '\\-'))
    .join('|');
  return {
    id: 'tell-word',
    severity: 'warn',
    re: new RegExp('\\b(' + alt + ')\\b', 'gi'),
    msg: (m) => {
      const key = m[1].toLowerCase();
      const sug = TELL_WORDS[key];
      return `AI-tell word "${m[1]}"` + (sug ? ` — prefer plain language (e.g. ${sug})` : '');
    },
  };
}

const RULES = [
  // --- hard: em/en-dash used as an em-dash in prose ---
  {
    id: 'em-dash',
    severity: 'error',
    re: /—/g,
    msg: () => 'Em-dash (—) in prose — replace with a comma, colon, period, or parentheses',
  },
  {
    id: 'ascii-em-dash',
    severity: 'error',
    re: / -- /g,
    msg: () => 'Spaced double-hyphen ( -- ) reads as an em-dash — use a comma, colon, or period',
  },
  {
    id: 'en-dash',
    severity: 'warn',
    // en-dash NOT between digits (a real number range like 2–5 is fine)
    re: /(?<!\d)–(?!\d)|(?<=\s)–(?=\s)/g,
    msg: () => 'En-dash (–) in prose — use a hyphen for compounds or rewrite',
  },
  tellWordRule(),
  // --- warn: AI cadence / boilerplate structures ---
  { id: 'cadence-not-just', severity: 'warn', re: /\bit['’]?s not just\b[^.?!\n]{0,60}?,\s*it['’]?s\b/gi,
    msg: () => '"it\'s not just X, it\'s Y" — classic AI cadence; state the point plainly' },
  { id: 'cadence-not-only', severity: 'warn', re: /\bnot only\b[^.?!\n]{0,60}?\bbut also\b/gi,
    msg: () => '"not only X but also Y" — tighten to a plain list or sentence' },
  { id: 'cadence-in-today', severity: 'warn', re: /\bin today['’]?s\b[^.?!\n]{0,40}?\b(world|landscape|era|market|age|environment)\b/gi,
    msg: () => '"In today\'s [adjective] world" opener — cut it and start with the point' },
  { id: 'cadence-when-it-comes', severity: 'warn', re: /\bwhen it comes to\b/gi,
    msg: () => '"When it comes to X" — usually deletable filler' },
  { id: 'cadence-thats-where', severity: 'warn', re: /\bthat['’]?s where\b[^.?!\n]{0,40}?\bcomes? in\b/gi,
    msg: () => '"That\'s where X comes in" — marketing cadence; say what it does' },
  { id: 'cadence-whether', severity: 'warn', re: /\bwhether you['’]?re\b[^.?!\n]{0,60}?\bor\b/gi,
    msg: () => '"Whether you\'re X or Y" opener — reads as landing-page copy' },
  { id: 'cadence-goodbye', severity: 'warn', re: /\bsay goodbye to\b/gi,
    msg: () => '"Say goodbye to X" — ad copy; describe the change directly' },
  { id: 'cadence-look-no-further', severity: 'warn', re: /\blook no further\b/gi,
    msg: () => '"Look no further" — ad cliché; delete' },
  { id: 'cadence-heart-of', severity: 'warn', re: /\bat the heart of\b/gi,
    msg: () => '"At the heart of X" — vague; name the actual relationship' },
  { id: 'cadence-plays-role', severity: 'warn', re: /\bplays? an? (crucial|pivotal|vital|key|significant|important) role\b/gi,
    msg: () => '"plays a [crucial] role" — say what it actually does' },
  { id: 'cadence-navigate', severity: 'warn', re: /\bnavigat(e|ing) the complexit/gi,
    msg: () => '"navigate the complexities of X" — filler; be concrete' },
  { id: 'cadence-result-q', severity: 'warn', re: /\bThe result\?/g,
    msg: () => 'Rhetorical "The result?" fragment — write a full sentence' },
  // --- info: low-signal filler / intensifiers ---
  { id: 'filler-in-order-to', severity: 'info', re: /\bin order to\b/gi, fix: 'to',
    msg: () => '"in order to" → "to"' },
  { id: 'filler-a-variety-of', severity: 'info', re: /\ba (variety|number) of\b/gi,
    msg: () => '"a variety/number of" — prefer "several", "some", or a count' },
  { id: 'filler-intensifier', severity: 'info', re: /\b(very|really|simply|basically|actually|just literally|truly)\b/gi,
    msg: (m) => `filler intensifier "${m[1]}" — usually deletable` },
];

// ----------------------------------------------------------------------------
// Masking: build skip-ranges so we never lint inside code / URLs / front matter.
// Ranges are [start,end) offsets in the original text; offsets are preserved so
// line:col stays accurate.
// ----------------------------------------------------------------------------
function skipRanges(text) {
  const ranges = [];
  // whole-file opt-out
  if (/prose-lint-disable-file/.test(text)) return [[0, text.length]];
  const add = (re) => { let m; re.lastIndex = 0; while ((m = re.exec(text))) { ranges.push([m.index, m.index + m[0].length]); if (m[0].length === 0) re.lastIndex++; } };
  // opt-out regions: prose-lint-disable ... prose-lint-enable (any comment syntax).
  // A region with no matching enable runs to EOF. Lets reference docs name the
  // very words we ban without tripping the linter.
  {
    const marker = /prose-lint-(disable|enable)/g;
    let m, open = -1;
    while ((m = marker.exec(text))) {
      if (m[1] === 'disable' && open < 0) open = m.index;
      else if (m[1] === 'enable' && open >= 0) { ranges.push([open, m.index + m[0].length]); open = -1; }
    }
    if (open >= 0) ranges.push([open, text.length]);
  }
  // leading YAML front matter
  const fm = /^---\n[\s\S]*?\n---/;
  const fmm = fm.exec(text); if (fmm) ranges.push([0, fmm[0].length]);
  add(/```[\s\S]*?```/g);        // fenced code
  add(/~~~[\s\S]*?~~~/g);
  add(/<pre[\s\S]*?<\/pre>/gi);
  add(/<code[\s\S]*?<\/code>/gi);
  add(/`[^`\n]*`/g);             // inline code
  add(/<[^>\n]+>/g);             // html tags (attributes, not prose)
  add(/\]\(([^)\s]+)/g);         // markdown link/image target (url only)
  add(/^\[[^\]]+\]:\s.*$/gm);    // reference-style link defs
  add(/https?:\/\/[^\s)]+/g);    // bare URLs
  add(/[\w.+-]+@[\w.-]+\.\w+/g); // emails
  add(/^\s{0,3}([-*_])(?:\s*\1){2,}\s*$/gm); // hr / thematic break (--- *** ___)
  return ranges;
}
function inSkip(idx, ranges) { for (const [s, e] of ranges) if (idx >= s && idx < e) return true; return false; }

function offsetToLineCol(text, idx) {
  let line = 1, col = 1;
  for (let i = 0; i < idx; i++) { if (text[i] === '\n') { line++; col = 1; } else col++; }
  return [line, col];
}

function lint(text) {
  const ranges = skipRanges(text);
  const findings = [];
  for (const rule of RULES) {
    let m; rule.re.lastIndex = 0;
    while ((m = rule.re.exec(text))) {
      if (m[0].length === 0) { rule.re.lastIndex++; continue; }
      if (inSkip(m.index, ranges)) continue;
      const [line, col] = offsetToLineCol(text, m.index);
      findings.push({ rule: rule.id, severity: rule.severity, line, col, match: m[0].trim(), message: rule.msg(m) });
    }
  }
  findings.sort((a, b) => a.line - b.line || a.col - b.col);
  return findings;
}

// deterministic 1:1 autofix (safe swaps + "in order to" only)
function applyFix(text) {
  const ranges = skipRanges(text);
  const edits = [];
  const push = (re, repl, keepCase) => {
    let m; re.lastIndex = 0;
    while ((m = re.exec(text))) {
      if (inSkip(m.index, ranges)) continue;
      let r = repl;
      if (keepCase && /^[A-Z]/.test(m[0])) r = r[0].toUpperCase() + r.slice(1);
      edits.push([m.index, m.index + m[0].length, r]);
    }
  };
  for (const [w, r] of Object.entries(SAFE_FIX)) push(new RegExp('\\b' + w.replace(/-/g, '\\-') + '\\b', 'gi'), r, true);
  push(/\bin order to\b/gi, 'to', true);
  edits.sort((a, b) => a[0] - b[0]);
  let out = '', last = 0;
  for (const [s, e, r] of edits) { if (s < last) continue; out += text.slice(last, s) + r; last = e; }
  out += text.slice(last);
  return { text: out, count: edits.length };
}

// ----------------------------------------------------------------------------
// CLI
// ----------------------------------------------------------------------------
function parseArgs(argv) {
  const o = { files: [], json: false, strict: false, fix: false, quiet: false };
  for (let i = 0; i < argv.length; i++) {
    const a = argv[i];
    if (a === '--json') o.json = true;
    else if (a === '--strict') o.strict = true;
    else if (a === '--fix') o.fix = true;
    else if (a === '--quiet' || a === '-q') o.quiet = true;
    else if (a === '--stdin' || a === '-') o.stdin = true;
    else if (a === '--help' || a === '-h') o.help = true;
    else if (a === '--version' || a === '-V') o.version = true;
    else if (a.startsWith('-')) { o.badFlag = a; }
    else o.files.push(a);
  }
  return o;
}

const HELP = `prose-lint — Adom house-style linter (no em-dashes, no AI slop)

USAGE
  prose-lint [FILE...]           lint files (markdown/html/txt)
  prose-lint --stdin < file      lint stdin
  echo "text" | prose-lint       lint stdin (auto when no files)

OPTIONS
  --fix        apply safe 1:1 word swaps in place (utilize→use, "in order to"→to)
  --strict     exit non-zero on ANY finding (default: only em-dashes fail)
  --json       machine-readable { status, data, hints[] }
  --quiet, -q  suppress the per-finding lines (summary + verdict only)
  --version    print version
  --help       this help

EXIT  0 clean/soft-only · 1 hard findings (or any under --strict) · 2 usage`;

const SEV_ORDER = { error: 0, warn: 1, info: 2 };
const SEV_LABEL = { error: 'HARD ', warn: 'warn ', info: 'info ' };

function main() {
  const o = parseArgs(process.argv.slice(2));
  if (o.help) { process.stdout.write(HELP + '\n'); process.exit(0); }
  if (o.version) {
    let v = '0.0.0';
    try { v = JSON.parse(fs.readFileSync(path.join(__dirname, '..', 'package.json'), 'utf8')).version; } catch (e) {}
    process.stdout.write('prose-lint ' + v + '\n'); process.exit(0);
  }
  if (o.badFlag) { process.stderr.write('ERROR: unknown flag ' + o.badFlag + '\nHint: run `prose-lint --help`\n'); process.exit(2); }

  // gather inputs
  const inputs = []; // { name, text }
  if (o.files.length) {
    for (const f of o.files) {
      try { inputs.push({ name: f, text: fs.readFileSync(f, 'utf8') }); }
      catch (e) { process.stderr.write('ERROR: cannot read ' + f + ' (' + e.code + ')\n'); process.exit(2); }
    }
  } else {
    let text = '';
    try { text = fs.readFileSync(0, 'utf8'); } catch (e) {}
    if (!text && process.stdin.isTTY) { process.stdout.write(HELP + '\n'); process.exit(0); }
    inputs.push({ name: '<stdin>', text });
  }

  // --fix mode
  if (o.fix) {
    const results = [];
    for (const inp of inputs) {
      const { text, count } = applyFix(inp.text);
      if (inp.name !== '<stdin>' && count > 0) fs.writeFileSync(inp.name, text);
      else if (inp.name === '<stdin>') process.stdout.write(text);
      results.push({ file: inp.name, fixed: count });
    }
    const total = results.reduce((n, r) => n + r.fixed, 0);
    if (o.json) { process.stdout.write(JSON.stringify({ status: 'ok', data: { fixed: total, files: results }, hints: [] }) + '\n'); }
    else {
      for (const r of results) if (r.file !== '<stdin>') process.stdout.write(`fixed ${r.fixed} in ${r.file}\n`);
      process.stdout.write((total ? 'OK: applied ' + total + ' safe swap(s)' : 'OK: nothing to auto-fix') +
        '\nHint: --fix only handles 1:1 word swaps; em-dashes and cadence need a human rewrite. Re-run without --fix to see what remains.\n');
    }
    process.exit(0);
  }

  // lint mode
  let all = [];
  const perFile = [];
  for (const inp of inputs) {
    const f = lint(inp.text);
    perFile.push({ file: inp.name, findings: f });
    all = all.concat(f.map((x) => ({ file: inp.name, ...x })));
  }
  const counts = { error: 0, warn: 0, info: 0 };
  for (const f of all) counts[f.severity]++;

  if (o.json) {
    const hard = counts.error;
    const status = (o.strict ? all.length : hard) ? 'error' : 'ok';
    process.stdout.write(JSON.stringify({
      status,
      data: { counts, total: all.length, files: perFile },
      hints: hard ? [{ level: 'error', code: 'EM_DASH', message: `${hard} hard finding(s) — em-dashes/AI-tells must go before shipping copy` }] : [],
    }) + '\n');
    process.exit((o.strict ? all.length : hard) ? 1 : 0);
  }

  if (!o.quiet) {
    for (const f of all.sort((a, b) => (a.file < b.file ? -1 : a.file > b.file ? 1 : 0) || a.line - b.line || a.col - b.col)) {
      process.stdout.write(`${f.file}:${f.line}:${f.col}  ${SEV_LABEL[f.severity]} [${f.rule}]  ${f.message}\n`);
    }
    if (all.length) process.stdout.write('\n');
  }

  if (all.length === 0) {
    process.stdout.write('OK: prose is clean — no em-dashes or AI-tells found\n');
    process.exit(0);
  }
  const parts = [];
  if (counts.error) parts.push(counts.error + ' hard');
  if (counts.warn) parts.push(counts.warn + ' tell');
  if (counts.info) parts.push(counts.info + ' filler');
  const verdict = counts.error ? 'ERROR' : 'OK';
  process.stdout.write(`${verdict}: ${parts.join(', ')} issue(s) across ${inputs.length} file(s)\n`);
  if (counts.error) process.stdout.write('Hint: em-dashes and " -- " are the hard rule — swap for a comma, colon, or period, then re-run.\n');
  if (counts.warn) process.stdout.write('Hint: "tell" lines are advisory; rewrite the ones that make the copy sound generated.\n');
  process.stdout.write('Hint: `prose-lint --fix` applies the safe word swaps automatically.\n');
  process.exit((o.strict ? all.length : counts.error) ? 1 : 0);
}

main();