#!/usr/bin/env node

import { spawn, spawnSync } from 'node:child_process';
import { readdir, stat } from 'node:fs/promises';
import path from 'node:path';
import process from 'node:process';

const watchRoot = process.env.GO_WATCH_ROOT || process.cwd();
const intervalMs = Number(process.env.GO_WATCH_INTERVAL_MS || 700);
const debounceMs = Number(process.env.GO_WATCH_DEBOUNCE_MS || 250);
const shutdownGraceMs = Number(process.env.GO_WATCH_SHUTDOWN_GRACE_MS || 2500);
const restartDelayMs = Number(process.env.GO_WATCH_RESTART_DELAY_MS || 200);
const commandIndex = process.argv.indexOf('--');
const command = commandIndex >= 0 ? process.argv.slice(commandIndex + 1) : ['go', 'run', './cmd/gateway'];
const prestartCommand = process.env.GO_WATCH_PRESTART || '';
const ignoredDirs = new Set([
  '.git',
  '.nx',
  'coverage',
  'dist',
  'node_modules',
  'tmp',
  'vendor',
]);
const watchedExtensions = new Set(['.go', '.mod', '.sum', '.sql']);

if (!command.length) {
  console.error('[go-watch] missing command after --');
  process.exit(1);
}

let child = null;
let knownSignature = '';
let restarting = false;
let restartQueued = false;
let pendingRestart = null;
let shuttingDown = false;
let stopped = false;

function log(message) {
  console.log(`[go-watch] ${message}`);
}

function sleep(ms) {
  return new Promise((resolve) => {
    setTimeout(resolve, ms);
  });
}

async function collectFiles(dir, files = []) {
  let entries = [];
  try {
    entries = await readdir(dir, { withFileTypes: true });
  } catch {
    return files;
  }
  for (const entry of entries) {
    if (entry.name.startsWith('.') && entry.name !== '.env') {
      continue;
    }
    if (entry.isDirectory()) {
      if (!ignoredDirs.has(entry.name)) {
        await collectFiles(path.join(dir, entry.name), files);
      }
      continue;
    }
    if (watchedExtensions.has(path.extname(entry.name))) {
      files.push(path.join(dir, entry.name));
    }
  }
  return files;
}

async function signature() {
  const files = await collectFiles(watchRoot);
  files.sort();
  const parts = [];
  for (const file of files) {
    try {
      const info = await stat(file);
      parts.push(`${file}:${info.mtimeMs}:${info.size}`);
    } catch {
      parts.push(`${file}:deleted`);
    }
  }
  return parts.join('\n');
}

function start() {
  if (stopped) {
    return;
  }
  if (prestartCommand) {
    log(`prestart: ${prestartCommand}`);
    const result = spawnSync(prestartCommand, {
      cwd: watchRoot,
      env: process.env,
      shell: true,
      stdio: 'inherit',
    });
    if (result.status !== 0) {
      log(`prestart failed code=${result.status ?? 'null'} signal=${result.signal ?? 'null'}`);
      return;
    }
  }
  log(`starting: ${command.join(' ')}`);
  child = spawn(command[0], command.slice(1), {
    cwd: watchRoot,
    detached: process.platform !== 'win32',
    env: process.env,
    stdio: 'inherit',
  });
  child.on('exit', (code, signal) => {
    if (stopped || restarting) {
      return;
    }
    log(`process exited code=${code ?? 'null'} signal=${signal ?? 'null'}`);
  });
}

function signalProcessTree(processToStop, signal) {
  if (!processToStop || processToStop.pid === undefined) {
    return;
  }
  try {
    if (process.platform === 'win32') {
      processToStop.kill(signal);
    } else {
      process.kill(-processToStop.pid, signal);
    }
  } catch (error) {
    if (error?.code !== 'ESRCH') {
      console.error(`[go-watch] failed to send ${signal}`, error);
    }
  }
}

function stopProcessTree(processToStop) {
  if (!processToStop || processToStop.exitCode !== null || processToStop.signalCode !== null) {
    return Promise.resolve();
  }
  return new Promise((resolve) => {
    let done = false;
    const finish = () => {
      if (done) {
        return;
      }
      done = true;
      clearTimeout(forceTimer);
      clearTimeout(resolveTimer);
      resolve();
    };
    const forceTimer = setTimeout(() => {
      signalProcessTree(processToStop, 'SIGKILL');
    }, shutdownGraceMs);
    const resolveTimer = setTimeout(finish, shutdownGraceMs + 500);
    forceTimer.unref();
    resolveTimer.unref();
    processToStop.once('exit', finish);
    signalProcessTree(processToStop, 'SIGTERM');
  });
}

async function restartNow() {
  if (restarting) {
    restartQueued = true;
    return;
  }
  restarting = true;
  log('change detected, restarting');
  const previous = child;
  child = null;
  await stopProcessTree(previous);
  if (restartDelayMs > 0) {
    await sleep(restartDelayMs);
  }
  restarting = false;
  if (!stopped) {
    start();
  }
  if (restartQueued && !stopped) {
    restartQueued = false;
    restart();
  }
}

function restart() {
  if (pendingRestart) {
    clearTimeout(pendingRestart);
  }
  pendingRestart = setTimeout(() => {
    pendingRestart = null;
    void restartNow();
  }, debounceMs);
}

async function poll() {
  try {
    const nextSignature = await signature();
    if (!knownSignature) {
      knownSignature = nextSignature;
    } else if (nextSignature !== knownSignature) {
      knownSignature = nextSignature;
      restart();
    }
  } catch (error) {
    console.error('[go-watch] scan failed', error);
  }
}

async function main() {
  log(`watching ${watchRoot}`);
  knownSignature = await signature();
  start();
  const timer = setInterval(poll, intervalMs);
  const shutdown = async (exitCode) => {
    if (shuttingDown) {
      return;
    }
    shuttingDown = true;
    stopped = true;
    clearInterval(timer);
    if (pendingRestart) {
      clearTimeout(pendingRestart);
    }
    await stopProcessTree(child);
    process.exit(exitCode);
  };
  process.on('SIGINT', () => {
    void shutdown(130);
  });
  process.on('SIGTERM', () => {
    void shutdown(143);
  });
}

main().catch((error) => {
  console.error('[go-watch] fatal', error);
  process.exit(1);
});
