#!/usr/bin/env node
/**
 * remote-read-allow — строго read-only разрешитель удалённого доступа к боевому
 * порталу для РАЗГОВОРНОГО режима стены (owner-authorized 2026-06-18).
 *
 * Открывает РОВНО две формы, всё остальное → ok:false (→ default-deny стены/роутера):
 *   1) ssh liderra-prod '<одна read-only команда>'
 *        read-команды: cat/grep/egrep/fgrep/ls/head/tail/stat/wc/file/less/more/sed
 *        sed — только print-формы (без -i/--in-place/-f/--file и без write-команд w/W);
 *        psql — только SELECT-only (через -c '<SQL>'; блок write-ключевых слов и -f/-o).
 *   2) gh <GET-операция>: run list|view, workflow list|view, api с методом GET.
 *
 * Жёсткие инварианты (консервативно, owner-решения 2026-06-18):
 *   - ssh-цель строго literal 'liderra-prod'; никаких ssh-опций (-L/-R/-o/...);
 *   - удалённая команда — РОВНО ОДНА, без пайпов/цепочек (|/;/&&/||) и редиректов;
 *   - sub-shell ($()/backticks/<()/heredoc) в любой части → отказ;
 *   - gh api: блок -X/--method ≠ GET и body-флагов (-f/-F/--field/--raw-field/--input).
 *
 * Чистая функция, без I/O. fail-safe: любой бросок/неоднозначность → { ok:false }.
 * Остаточный риск (принят владельцем): psql SELECT-only определяется разбором SQL —
 * вызов write-функции внутри SELECT (nextval/setval/pg_*) не ловится по ключевым
 * словам. Поверхность сужена до явного `-c '<SELECT…>'` без множественных стейтментов.
 */
import { tokenizeBash } from './bash-tokenizer.mjs';

const SSH_HOST = 'liderra-prod';

// read-only удалённые команды (по token[0]) — файловое чтение.
const REMOTE_FILE_READ = new Set([
  'cat', 'grep', 'egrep', 'fgrep', 'ls', 'head', 'tail', 'stat', 'wc', 'file', 'less', 'more', 'sed',
]);

// Редирект-/control-токены, которые tokenizeBash кладёт внутрь сегмента.
const REDIRECT_TOKENS = new Set(['>', '>>', '<', '<<', '>&', '&', '|']);

// SQL write-ключевые слова: наличие любого → НЕ SELECT-only.
const SQL_WRITE_RE = /\b(insert|update|delete|drop|alter|create|truncate|grant|revoke|copy|merge|call|do|vacuum|reindex|cluster|comment|begin|commit|rollback|savepoint|set|reset|lock|into|nextval|setval|refresh|prepare|execute|deallocate|listen|notify|unlisten|reassign|cursor|fetch|move|declare|analyze)\b/i;

// Допустимое начало SELECT-only запроса.
const SQL_READ_HEAD_RE = /^(select|with|table|values|show|explain)\b/i;

function fail() { return { ok: false }; }
function ok(kind) { return { ok: true, kind }; }

function hasRedirect(tokens) {
  return tokens.some((t) => REDIRECT_TOKENS.has(t));
}

/** Единственный сегмент без sub-shell/редиректов/control-хвоста → его токены, иначе null. */
function singleCleanSegment(tok) {
  if (!tok || !tok.ok || tok.hasSubshell) return null;
  if (!Array.isArray(tok.segments) || tok.segments.length !== 1) return null;
  const seg = tok.segments[0];
  if (seg.op !== null) return null; // завершающий control-op (напр. фоновый &)
  const tokens = Array.isArray(seg.tokens) ? seg.tokens : [];
  if (tokens.length === 0) return null;
  if (hasRedirect(tokens)) return null;
  return tokens;
}

/**
 * Главный разрешитель. command — полная Bash-строка инструмента.
 * tokenize инъектируем для тестов.
 */
export function classifyRemoteReadCommand(command, { tokenize = tokenizeBash } = {}) {
  try {
    if (typeof command !== 'string' || !command.trim()) return fail();
    const tokens = singleCleanSegment(tokenize(command));
    if (!tokens) return fail();
    const head = tokens[0];
    if (head === 'ssh') return classifySsh(tokens, tokenize);
    if (head === 'gh') return classifyGh(tokens);
    return fail();
  } catch {
    return fail();
  }
}

/** ssh liderra-prod '<remotecmd>' — ровно 3 токена, цель literal, опций нет. */
function classifySsh(tokens, tokenize) {
  if (tokens.length !== 3) return fail();        // ssh + host + единственный quoted-remote
  if (tokens[1] !== SSH_HOST) return fail();     // никаких опций/иных хостов
  const remote = tokens[2];
  if (typeof remote !== 'string' || !remote.trim()) return fail();
  return classifyRemoteInner(remote, tokenize);
}

/** Внутренняя удалённая команда: ровно одна read-only, без пайпов/редиректов. */
function classifyRemoteInner(remote, tokenize) {
  const tokens = singleCleanSegment(tokenize(remote));
  if (!tokens) return fail();
  const cmd = tokens[0];
  if (cmd === 'psql') return classifyPsql(tokens);
  if (!REMOTE_FILE_READ.has(cmd)) return fail();
  if (cmd === 'sed' && !sedIsPrintOnly(tokens)) return fail();
  return ok('ssh-read');
}

/**
 * sed безопасен только в print-форме: без -i/--in-place (in-place запись) и без
 * -f/--file (внешний скрипт может нести write-команды w/W). Доп. предосторожность:
 * любой токен с буквой w/W (включая в шаблоне) → отказ, чтобы `w file` / `s///w file`
 * не проскочили. Это намеренный over-block в безопасную сторону (используйте grep/cat).
 */
function sedIsPrintOnly(tokens) {
  for (const t of tokens.slice(1)) {
    if (t === '-i' || /^-i/.test(t) || t === '--in-place' || /^--in-place/.test(t)) return false;
    if (t === '-f' || /^-f/.test(t) || t === '--file' || /^--file/.test(t)) return false;
    if (/[wW]/.test(t)) return false; // w/W write-команды sed (и над-осторожно — в шаблоне)
  }
  return true;
}

/** psql — только явный `-c '<SELECT…>'`; блок -f/-o и write-SQL. */
function classifyPsql(tokens) {
  let sql = null;
  for (let i = 1; i < tokens.length; i++) {
    const t = tokens[i];
    // запрет файла-скрипта и записи результата в файл
    if (t === '-f' || /^-f/.test(t) || t === '--file' || /^--file/.test(t)) return fail();
    if (t === '-o' || /^-o/.test(t) || t === '--output' || /^--output/.test(t)) return fail();
    if (t === '-c' || t === '--command') { sql = tokens[i + 1] ?? null; continue; }
    if (/^--command=/.test(t)) { sql = t.slice('--command='.length); continue; }
  }
  if (sql == null) return fail(); // без явного -c '<SELECT>' (интерактив/файл/неявно) → отказ
  return classifySql(sql);
}

/** SELECT-only: один стейтмент, начало select/with/…, без write-ключевых слов. */
function classifySql(sql) {
  if (typeof sql !== 'string' || !sql.trim()) return fail();
  const trimmed = sql.trim().replace(/;\s*$/, '');     // снять один завершающий ;
  if (trimmed.includes(';')) return fail();            // множественные стейтменты
  if (SQL_WRITE_RE.test(trimmed)) return fail();        // любое write-слово
  const head = trimmed.replace(/^\(+\s*/, '');          // снять ведущие скобки
  if (SQL_READ_HEAD_RE.test(head)) return ok('ssh-read'); // SELECT/WITH/TABLE/VALUES/SHOW/EXPLAIN
  if (/^\\/.test(head)) return ok('ssh-read');          // psql meta (\dt \d \l) — read-only
  return fail();
}

/** gh — только read-операции: run list|view, workflow list|view, api GET. */
function classifyGh(tokens) {
  const sub = tokens[1];
  const sub2 = tokens[2];
  if (sub === 'run' && (sub2 === 'list' || sub2 === 'view')) return ok('gh-read');
  if (sub === 'workflow' && (sub2 === 'list' || sub2 === 'view')) return ok('gh-read');
  if (sub === 'api') return classifyGhApi(tokens);
  return fail();
}

/** gh api — только метод GET, без тела запроса. */
function classifyGhApi(tokens) {
  const rest = tokens.slice(2);
  for (let i = 0; i < rest.length; i++) {
    const t = rest[i];
    if (t === '-X' || t === '--method') {
      const m = rest[i + 1];
      if (!m || String(m).toUpperCase() !== 'GET') return fail();
      continue;
    }
    if (/^--method=/.test(t)) {
      if (t.slice('--method='.length).toUpperCase() !== 'GET') return fail();
      continue;
    }
    if (/^-X./.test(t)) { // склеенная форма -XPOST
      if (t.slice(2).toUpperCase() !== 'GET') return fail();
      continue;
    }
    // тело запроса → подразумевает POST/PATCH/PUT
    if (t === '-f' || t === '-F' || t === '--field' || t === '--raw-field' || t === '--input'
      || /^(--field=|--raw-field=|--input=)/.test(t) || /^-f./.test(t) || /^-F./.test(t)) return fail();
  }
  const hasPath = rest.some((t) => typeof t === 'string' && !t.startsWith('-'));
  if (!hasPath) return fail(); // нужен позиционный путь/endpoint
  return ok('gh-read');
}
