#!/usr/bin/env node
'use strict';
/**
 * pre-commit.js -- local CI quality gate for LearnSpace.
 *
 * Checks (in order, fastest first):
 *   1. Node --check syntax on staged .js files in backend/src/ and frontend/js/
 *   2. No emoji in newly-added lines of staged source files (not .md, not scripts/)
 *   3. No new console.log / console.debug / debugger in staged backend/frontend .js
 *   4. Backend route auth lint (check-route-auth.js) -- only if backend touched
 *   5. Backend tests (npm test) -- only if backend touched; baseline = 3 fails
 *
 * Bypass: git commit --no-verify
 */

const { execSync, spawnSync } = require('child_process');
const path = require('path');

// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------

const REPO = execSync('git rev-parse --show-toplevel').toString().trim();

function git(args) {
  return execSync('git ' + args, { cwd: REPO, encoding: 'utf8' });
}

let failed = false;
const errors = [];

function fail(msg) {
  errors.push(msg);
  failed = true;
}

function ok(msg) {
  process.stdout.write('  [OK] ' + msg + '\n');
}

function warn(msg) {
  process.stdout.write('  [WARN] ' + msg + '\n');
}

function section(title) {
  process.stdout.write('\n' + title + '\n');
}

// ---------------------------------------------------------------------------
// Staged files
// ---------------------------------------------------------------------------

const stagedRaw = git('diff --cached --name-only --diff-filter=ACM').trim();
const stagedFiles = stagedRaw ? stagedRaw.split('\n').filter(Boolean) : [];

if (stagedFiles.length === 0) {
  process.stdout.write('No staged files -- nothing to check.\n');
  process.exit(0);
}

// Source JS files: production code paths (not scripts/, not backend/scripts/, not tests/)
function isSourceJs(f) {
  if (!f.endsWith('.js')) return false;
  if (f.startsWith('backend/src/'))  return true;
  if (f.startsWith('frontend/js/'))  return true;
  if (f.startsWith('frontend/') && f.split('/').length === 2) return true; // whiteboard.js etc
  if (f.startsWith('js/'))           return true; // shared api.js, mobile.js etc
  return false;
}

const sourceJsFiles = stagedFiles.filter(isSourceJs);

// Files to check for emoji / debug statements (same set: production source only)
// Includes HTML and CSS for emoji check
const sourceCodeFiles = stagedFiles.filter(f =>
  (f.startsWith('backend/src/') || f.startsWith('frontend/') || f.startsWith('js/')) &&
  (f.endsWith('.js') || f.endsWith('.html') || f.endsWith('.css'))
);

const backendTouched = stagedFiles.some(f => f.startsWith('backend/'));

process.stdout.write(
  '\nLearnSpace pre-commit checks (' + stagedFiles.length + ' staged file(s))\n'
);

// ---------------------------------------------------------------------------
// Check 1: Syntax
// ---------------------------------------------------------------------------

section('1. Syntax check (node --check)');

if (sourceJsFiles.length === 0) {
  warn('No backend/src or frontend/js files staged -- skipping syntax check');
} else {
  let syntaxOk = true;
  for (const f of sourceJsFiles) {
    const absPath = path.join(REPO, f);
    const r = spawnSync(process.execPath, ['--check', absPath], { encoding: 'utf8' });
    if (r.status !== 0) {
      const msg = (r.stderr || r.stdout || '').trim().split('\n').join('\n    ');
      fail('Syntax error in ' + f + ':\n    ' + msg);
      syntaxOk = false;
    }
  }
  if (syntaxOk) ok(sourceJsFiles.length + ' file(s) -- no syntax errors');
}

// ---------------------------------------------------------------------------
// Check 2: No emoji in newly-added lines of source files
// ---------------------------------------------------------------------------

section('2. No emoji in staged changes');

// Emoji codepoint ranges (excludes pure dingbats like check/cross marks)
// U+1F300-1F9FF: Misc Symbols & Pictographs, Emoticons, Transport, etc.
// U+1F000-1F2FF: Mahjong, Domino, Playing Cards
// U+1F650-1F67F: Ornamental Dingbats
// U+1F900-1F9FF: Supplemental Symbols
// Excluded intentionally: U+2600-27BF (Misc Symbols -- includes arrows, dingbats used in terminal UI)
// because those block tool output characters (check marks, crosses, stars)
const EMOJI_RE = /[\u{1F300}-\u{1F9FF}\u{1F000}-\u{1F2FF}\u{1F650}-\u{1F67F}]/u;

// Build a set of source code file paths for fast lookup
const sourceCodeSet = new Set(sourceCodeFiles);

const diff = git('diff --cached --unified=0');
const diffLines = diff.split('\n');

let currentFile = null;
let lineNum = 0;
let emojiOk = true;

for (const line of diffLines) {
  if (line.startsWith('+++ b/')) {
    currentFile = line.slice(6);
    lineNum = 0;
  } else if (line.startsWith('@@')) {
    const m = line.match(/\+(\d+)/);
    if (m) lineNum = parseInt(m[1], 10) - 1;
  } else if (line.startsWith('+') && !line.startsWith('+++')) {
    lineNum++;
    if (!currentFile) continue;
    // Only check production source files (backend/src/, frontend/)
    if (!sourceCodeSet.has(currentFile)) continue;
    // Skip .md files (docs may have emoji)
    if (currentFile.endsWith('.md')) continue;
    if (EMOJI_RE.test(line)) {
      const found = [...line.matchAll(/[\u{1F300}-\u{1F9FF}\u{1F000}-\u{1F2FF}\u{1F650}-\u{1F67F}]/gu)]
        .map(m2 => m2[0]).join(' ');
      fail('Emoji in ' + currentFile + ':' + lineNum + ' -- "' + found + '"\n    Use inline SVG with .ic class instead');
      emojiOk = false;
    }
  } else if (!line.startsWith('-')) {
    lineNum++;
  }
}

if (emojiOk) ok('No emoji in staged source changes');

// ---------------------------------------------------------------------------
// Check 3: No new console.log / console.debug / debugger
// ---------------------------------------------------------------------------

section('3. No new debug statements');

// Pattern split across strings to avoid self-match when this file is staged
const DEBUG_WORD_A = 'console';
const DEBUG_WORD_B = '.log';
const DEBUG_WORD_C = '.debug';
const DEBUGGER_KW  = 'debugger';
const DEBUG_RE = new RegExp(
  '\\b(' + DEBUG_WORD_A + '\\' + DEBUG_WORD_B +
  '|' + DEBUG_WORD_A + '\\' + DEBUG_WORD_C +
  '|' + DEBUGGER_KW + ')\\b'
);

// Source JS files set for debug check
const sourceJsSet = new Set(sourceJsFiles);

let debugCurrentFile = null;
let debugLineNum = 0;
let debugOk = true;

for (const line of diffLines) {
  if (line.startsWith('+++ b/')) {
    debugCurrentFile = line.slice(6);
    debugLineNum = 0;
  } else if (line.startsWith('@@')) {
    const m = line.match(/\+(\d+)/);
    if (m) debugLineNum = parseInt(m[1], 10) - 1;
  } else if (line.startsWith('+') && !line.startsWith('+++')) {
    debugLineNum++;
    if (!debugCurrentFile) continue;
    if (!sourceJsSet.has(debugCurrentFile)) continue;
    const content = line.slice(1); // strip leading '+'
    // Skip pure comment lines (// and * in jsdoc blocks)
    if (/^\s*(\/\/|\*)/.test(content)) continue;
    if (DEBUG_RE.test(content)) {
      fail('Debug statement in ' + debugCurrentFile + ':' + debugLineNum + ':\n    ' + content.trim());
      debugOk = false;
    }
  } else if (!line.startsWith('-')) {
    debugLineNum++;
  }
}

if (debugOk) ok('No debug statements in staged source JS');

// ---------------------------------------------------------------------------
// Check 4: Backend route auth lint
// ---------------------------------------------------------------------------

section('4. Backend route auth lint');

// ACTUAL_UNPROTECTED: current unprotected count as of 2026-05-29.
// The check-route-auth.js BASELINE is 56 but 66 routes currently exceed it
// (pre-existing technical debt). We block only if NEW routes are added beyond 66.
const ROUTE_LINT_ACTUAL = 66;

if (!backendTouched) {
  warn('No backend files staged -- skipping route lint');
} else {
  const lintResult = spawnSync(
    process.execPath,
    [path.join(REPO, 'backend/scripts/check-route-auth.js')],
    { cwd: path.join(REPO, 'backend'), encoding: 'utf8' }
  );

  const lintOut = (lintResult.stdout || '') + (lintResult.stderr || '');

  // Parse the current unprotected count from output
  const countMatch = lintOut.match(/(\d+) unprotected \(baseline:/);
  const currentCount = countMatch ? parseInt(countMatch[1], 10) : null;

  if (currentCount !== null && currentCount > ROUTE_LINT_ACTUAL) {
    const newRoutes = currentCount - ROUTE_LINT_ACTUAL;
    fail(
      'Route auth lint: ' + newRoutes + ' new unprotected route(s) added (was ' +
      ROUTE_LINT_ACTUAL + ', now ' + currentCount + ').\n' +
      '    Add requireOwnership/requireRole/requirePermission, or mark intentional:\n' +
      '    // @public-by-design: <reason>'
    );
  } else if (currentCount === null && lintResult.status !== 0) {
    // Permission key failure -- always block
    const msg = lintOut.trim().split('\n').slice(-4).join('\n    ');
    fail('Route auth lint failed (permission key issue):\n    ' + msg);
  } else {
    const note = currentCount !== null
      ? currentCount + ' unprotected routes (pre-existing debt, not blocking)'
      : 'passed';
    ok('Route auth lint -- ' + note);
  }
}

// ---------------------------------------------------------------------------
// Check 5: Backend tests
// ---------------------------------------------------------------------------

section('5. Backend tests');

const TEST_FAIL_BASELINE = 3;

if (!backendTouched) {
  warn('No backend files staged -- skipping tests');
} else {
  const testResult = spawnSync(
    process.execPath,
    ['--test', 'tests/*.test.js'],
    {
      cwd: path.join(REPO, 'backend'),
      encoding: 'utf8',
      shell: true,   // needed for glob expansion on Windows
      timeout: 30000,
    }
  );

  const output = (testResult.stdout || '') + (testResult.stderr || '');
  const outputLines = output.split('\n');

  // Count individual test failures: lines with the Unicode cross mark at line start
  // Exclude the "failing tests:" header and suite roll-up lines
  // Node test runner uses '✖' (heavy multiplication x) for failures
  const CROSS = '✖';
  const failLines = outputLines.filter(l => {
    const t = l.trim();
    return t.startsWith(CROSS) &&
           !t.startsWith(CROSS + ' failing tests') &&
           !t.startsWith(CROSS + ' Auth');
  });
  const failCount = failLines.length;

  if (failCount > TEST_FAIL_BASELINE) {
    const newFails = failLines
      .slice(TEST_FAIL_BASELINE)
      .map(l => '    ' + l.trim())
      .join('\n');
    fail(
      'Backend tests: ' + failCount + ' failures (baseline = ' + TEST_FAIL_BASELINE + '). New:\n' + newFails
    );
  } else {
    ok('Backend tests: ' + failCount + ' failure(s) -- within baseline of ' + TEST_FAIL_BASELINE);
  }
}

// ---------------------------------------------------------------------------
// Final result
// ---------------------------------------------------------------------------

process.stdout.write('\n');

if (failed) {
  process.stderr.write('\x1b[31mPRE-COMMIT FAILED\x1b[0m -- commit aborted.\n\n');
  for (const e of errors) {
    process.stderr.write('\x1b[31m  [FAIL]\x1b[0m ' + e + '\n');
  }
  process.stderr.write('\nBypass with: git commit --no-verify\n\n');
  process.exit(1);
} else {
  process.stdout.write('\x1b[32mAll checks passed.\x1b[0m\n\n');
  process.exit(0);
}
