import fs from 'node:fs/promises';
import os from 'node:os';
import { spawn } from 'node:child_process';
import { db, id, now } from '../db.js';
import { decryptSecret } from './cryptoStore.js';
import { config } from '../config.js';
import { logger } from './logger.js';
import { listExecutableAssets } from '../models/assetModel.js';
import { listExecutableCustomVariables } from '../models/variableModel.js';
import { getBooleanSetting, getStringSetting } from '../models/settingsModel.js';
import { path } from '../utils/pathUtils.js';

// The execution kill-switch can be set at boot via ALLOW_SCRIPT_EXECUTION and
// flipped at runtime via the Config screen (allow_script_execution). The DB
// value, when present, takes precedence so the UI toggle is actually effective.
function scriptExecutionAllowed() {
  return getBooleanSetting('allow_script_execution', config.allowScriptExecution);
}

export function resolvePowerShellExecutable() {
  return getStringSetting('powershell_bin', config.powershellBin).trim();
}

export function powerShellSpawnFailureLines(error, executable) {
  const code = error?.code ? ` (${error.code})` : '';
  const lines = [
    `Unable to start configured PowerShell executable "${executable}"${code}.`,
    `Node spawn error: ${error?.message || 'unknown process start failure'}`
  ];
  if (error?.code === 'ENOENT') {
    lines.push('The executable was not found on the API server PATH.');
    lines.push('Windows: set Config > PowerShell Binary to "powershell.exe" or the full path "C:\\Windows\\System32\\WindowsPowerShell\\v1.0\\powershell.exe".');
    lines.push('PowerShell 7: set it to "pwsh.exe" or the full pwsh install path.');
  }
  lines.push(`API process platform: ${process.platform}; PATH: ${process.env.PATH || '(empty)'}`);
  return lines;
}

export function executeRunPlan(runplanId, userId) {
  // Create the durable job record before async execution so the UI can attach to logs immediately.
  const runplan = db.prepare('SELECT * FROM runplans WHERE id = ?').get(runplanId);
  if (!runplan) throw new Error('RunPlan not found');
  const script = db.prepare('SELECT * FROM scripts WHERE id = ?').get(runplan.script_id);
  if (!script) throw new Error('Script not found');
  const hosts = db.prepare(`
    SELECT h.*, c.name AS credential_name, c.kind AS credential_kind, c.username AS credential_username,
           c.secret_cipher, c.secret_iv, c.secret_tag
    FROM runplan_hosts rh
    JOIN hosts h ON h.id = rh.host_id
    LEFT JOIN credentials c ON c.id = h.credential_id
    WHERE rh.runplan_id = ?
    ORDER BY h.name
  `).all(runplanId);
  if (hosts.length === 0) throw new Error('RunPlan has no hosts');

  const jobId = id('job');
  db.prepare(`
    INSERT INTO jobs (id, runplan_id, script_id, status, triggered_by, started_at, created_at)
    VALUES (?, ?, ?, 'running', ?, ?, ?)
  `).run(jobId, runplan.id, script.id, userId, now(), now());

  for (const host of hosts) {
    db.prepare(`
      INSERT INTO job_hosts (id, job_id, host_id, status, started_at)
      VALUES (?, ?, ?, 'queued', NULL)
    `).run(id('jhost'), jobId, host.id);
  }

  const executionContext = {
    jobId,
    runplanId: runplan.id,
    scriptId: script.id,
    scriptName: script.name,
    triggeredBy: userId,
    customVariables: listExecutableCustomVariables(userId),
    assets: listExecutableAssets(userId)
  };

  void runJob(jobId, script, hosts, Boolean(runplan.parallel), executionContext).catch((error) => {
    logger.error('job runner crashed', { jobId, error });
    db.prepare('UPDATE jobs SET status = ?, ended_at = ? WHERE id = ?').run('failed', now(), jobId);
  });

  return db.prepare('SELECT * FROM jobs WHERE id = ?').get(jobId);
}

export function executeScriptTest(scriptId, payload, userId) {
  const script = db.prepare(`
    SELECT * FROM scripts
    WHERE id = ? AND (
      visibility = 'shared'
      OR owner_user_id = ?
      OR group_id IN (SELECT group_id FROM user_groups WHERE user_id = ?)
    )
  `).get(scriptId, userId, userId);
  if (!script) throw new Error('Script not found');

  const host = db.prepare(`
    SELECT h.*, c.name AS credential_name, c.kind AS credential_kind, c.username AS credential_username,
           c.secret_cipher, c.secret_iv, c.secret_tag
    FROM hosts h
    JOIN credentials c ON c.id = ?
      AND (
        c.visibility = 'shared'
        OR c.owner_user_id = ?
        OR c.group_id IN (SELECT group_id FROM user_groups WHERE user_id = ?)
      )
    WHERE h.id = ? AND (
      h.visibility = 'shared'
      OR h.owner_user_id = ?
      OR h.group_id IN (SELECT group_id FROM user_groups WHERE user_id = ?)
    )
  `).get(payload.credentialId, userId, userId, payload.hostId, userId, userId);
  if (!host) throw new Error('Visible host and credential are required');

  const jobId = id('job');
  db.prepare(`
    INSERT INTO jobs (id, runplan_id, script_id, status, triggered_by, started_at, created_at)
    VALUES (?, NULL, ?, 'running', ?, ?, ?)
  `).run(jobId, script.id, userId, now(), now());
  db.prepare(`
    INSERT INTO job_hosts (id, job_id, host_id, status, started_at)
    VALUES (?, ?, ?, 'queued', NULL)
  `).run(id('jhost'), jobId, host.id);

  const executionContext = {
    jobId,
    runplanId: 'script-test',
    scriptId: script.id,
    scriptName: script.name,
    triggeredBy: userId,
    testRun: true,
    customVariables: listExecutableCustomVariables(userId),
    assets: listExecutableAssets(userId)
  };

  appendLog(jobId, host.id, 'system', `Test run requested for ${script.name} using credential ${host.credential_name}`);
  void runJob(jobId, script, [host], false, executionContext).catch((error) => {
    logger.error('script test runner crashed', { jobId, error });
    appendLog(jobId, host.id, 'stderr', `Script test runner crashed: ${error.message}`);
    db.prepare('UPDATE jobs SET status = ?, ended_at = ? WHERE id = ?').run('failed', now(), jobId);
  });

  return db.prepare('SELECT * FROM jobs WHERE id = ?').get(jobId);
}

async function runJob(jobId, script, hosts, parallel, executionContext) {
  // RunPlans can fan out in parallel or execute serially while preserving identical log semantics.
  const runner = async (host) => runHost(jobId, script, host, executionContext);
  if (parallel) {
    await Promise.all(hosts.map(runner));
  } else {
    for (const host of hosts) await runner(host);
  }

  const failed = db.prepare('SELECT COUNT(*) AS count FROM job_hosts WHERE job_id = ? AND status = ?').get(jobId, 'failed').count;
  db.prepare('UPDATE jobs SET status = ?, ended_at = ? WHERE id = ?').run(failed ? 'failed' : 'completed', now(), jobId);
}

async function runHost(jobId, script, host, executionContext) {
  // Each host owns its own temp wrapper and job_host lifecycle for per-target evidence.
  db.prepare('UPDATE job_hosts SET status = ?, started_at = ? WHERE job_id = ? AND host_id = ?').run('running', now(), jobId, host.id);
  appendLog(jobId, host.id, 'system', `Starting ${script.name} on ${host.name} (${host.transport})`);

  if (!scriptExecutionAllowed()) {
    appendLog(jobId, host.id, 'stderr', 'Script execution is disabled by ALLOW_SCRIPT_EXECUTION=false.');
    finishHost(jobId, host.id, 'failed', 126);
    return;
  }

  const file = path.join(os.tmpdir(), `poshmanager-${jobId}-${host.id}.ps1`);

  try {
    const secret = decryptSecret(host);
    const wrapper = buildWrapper(script.content, host, executionContext);
    await fs.writeFile(file, wrapper, { mode: 0o600 });
    await spawnPowerShell(jobId, host, file, secret, executionContext);
  } catch (error) {
    appendLog(jobId, host.id, 'stderr', error.message);
    finishHost(jobId, host.id, 'failed', 1);
  } finally {
    await fs.rm(file, { force: true }).catch(() => {});
  }
}

function buildWrapper(content, host, executionContext = {}) {
  // Build PowerShell wrappers without shell interpolation; host values are quoted as PS literals.
  const runtimeVariables = runtimeVariableValues(host, executionContext);
  const customVariables = executionContext.customVariables || [];
  const assetVariables = (executionContext.assets || []).map((asset) => ({
    name: `asset:${asset.id}`,
    value: asset.localPath,
    valueType: 'string'
  }));
  const rendered = renderTokens(content, [...runtimeVariables, ...customVariables, ...assetVariables]);
  const scriptWithPrelude = `${buildVariablePrelude(runtimeVariables, customVariables)}\n${rendered}`;
  const escaped = scriptWithPrelude.replace(/'@/g, "'`@");
  if (host.transport === 'local') {
    return [
      '$ErrorActionPreference = "Continue"',
      `$scriptText = @'`,
      escaped,
      "'@",
      '$scriptBlock = [ScriptBlock]::Create($scriptText)',
      '& $scriptBlock'
    ].join('\n');
  }

  if (host.transport === 'ssh') {
    return [
      '$ErrorActionPreference = "Continue"',
      `$scriptText = @'`,
      escaped,
      "'@",
      '$scriptBlock = [ScriptBlock]::Create($scriptText)',
      `Invoke-Command -HostName ${psString(host.address)} -UserName $env:POSHM_USERNAME -ScriptBlock $scriptBlock`
    ].join('\n');
  }

  return [
    '$ErrorActionPreference = "Continue"',
    '$securePassword = ConvertTo-SecureString $env:POSHM_SECRET -AsPlainText -Force',
    '$credential = [System.Management.Automation.PSCredential]::new($env:POSHM_USERNAME, $securePassword)',
    `$scriptText = @'`,
    escaped,
    "'@",
    '$scriptBlock = [ScriptBlock]::Create($scriptText)',
    `Invoke-Command -ComputerName ${psString(host.address)} -Credential $credential -ScriptBlock $scriptBlock`
  ].join('\n');
}

function spawnPowerShell(jobId, host, file, secret, executionContext = {}) {
  // Secrets are passed through process environment variables and are never written to job logs.
  const runtimeEnv = Object.fromEntries(runtimeVariableValues(host, executionContext).map((variable) => [variable.name, String(variable.value ?? '')]));
  const assetManifest = JSON.stringify(executionContext.assets || []);
  const executable = resolvePowerShellExecutable();
  const args = ['-NoProfile', '-ExecutionPolicy', 'Bypass', '-File', file];
  return new Promise((resolve) => {
    appendLog(jobId, host.id, 'system', `Launching PowerShell executable "${executable}"`);
    const child = spawn(executable, args, {
      env: {
        ...process.env,
        ...runtimeEnv,
        POSHM_HOST_ID: host.id,
        POSHM_HOST_NAME: host.name,
        POSHM_HOST_ADDRESS: host.address,
        POSHM_HOST_TRANSPORT: host.transport,
        POSHM_ASSET_MANIFEST: assetManifest,
        POSHM_USERNAME: host.credential_username || '',
        POSHM_SECRET: secret || ''
      },
      shell: false
    });
    let settled = false;

    function settle(status, exitCode) {
      if (settled) return;
      settled = true;
      finishHost(jobId, host.id, status, exitCode);
      resolve();
    }

    child.stdout.on('data', (chunk) => appendChunk(jobId, host.id, 'stdout', chunk));
    child.stderr.on('data', (chunk) => appendChunk(jobId, host.id, 'stderr', chunk));
    child.on('error', (error) => {
      for (const line of powerShellSpawnFailureLines(error, executable)) appendLog(jobId, host.id, 'stderr', line);
      settle('failed', 127);
    });
    child.on('close', (code, signal) => {
      if (settled) return;
      if (signal) appendLog(jobId, host.id, 'stderr', `PowerShell terminated by signal ${signal}`);
      const exitCode = code ?? 1;
      settle(exitCode === 0 ? 'completed' : 'failed', exitCode);
    });
  });
}

function appendChunk(jobId, hostId, stream, chunk) {
  String(chunk)
    .split(/\r?\n/)
    .filter(Boolean)
    .forEach((line) => appendLog(jobId, hostId, stream, line));
}

function appendLog(jobId, hostId, stream, line) {
  db.prepare(`
    INSERT INTO job_logs (id, job_id, host_id, stream, line, created_at)
    VALUES (?, ?, ?, ?, ?, ?)
  `).run(id('log'), jobId, hostId, stream, line, now());
}

function finishHost(jobId, hostId, status, exitCode) {
  db.prepare(`
    UPDATE job_hosts SET status = ?, exit_code = ?, ended_at = ?
    WHERE job_id = ? AND host_id = ?
  `).run(status, exitCode, now(), jobId, hostId);
  appendLog(jobId, hostId, 'system', `Finished with status ${status} and exit code ${exitCode}`);
}

function runtimeVariableValues(host, executionContext) {
  const assetManifest = JSON.stringify(executionContext.assets || []);
  return [
    { name: 'POSHM_JOB_ID', value: executionContext.jobId || '', valueType: 'string' },
    { name: 'POSHM_RUNPLAN_ID', value: executionContext.runplanId || '', valueType: 'string' },
    { name: 'POSHM_SCRIPT_ID', value: executionContext.scriptId || '', valueType: 'string' },
    { name: 'POSHM_SCRIPT_NAME', value: executionContext.scriptName || '', valueType: 'string' },
    { name: 'POSHM_HOST_ID', value: host.id, valueType: 'string' },
    { name: 'POSHM_HOST_NAME', value: host.name, valueType: 'string' },
    { name: 'POSHM_HOST_ADDRESS', value: host.address, valueType: 'string' },
    { name: 'POSHM_HOST_TRANSPORT', value: host.transport, valueType: 'string' },
    { name: 'POSHM_ASSET_MANIFEST', value: assetManifest, valueType: 'string' },
    { name: 'POSHM_TRIGGERED_BY', value: executionContext.triggeredBy || '', valueType: 'string' }
  ];
}

function buildVariablePrelude(runtimeVariables, customVariables) {
  const envLines = runtimeVariables.map((variable) => `$env:${variable.name} = ${psLiteral(variable.value, variable.valueType)}`);
  const setLines = [...runtimeVariables, ...customVariables]
    .filter((variable) => /^[A-Za-z_][A-Za-z0-9_]*$/.test(variable.name))
    .map((variable) => `Set-Variable -Name ${psString(variable.name)} -Value ${psLiteral(variable.value, variable.valueType)} -Scope Script`);
  return [
    '# POSHManager runtime variables',
    ...envLines,
    ...setLines
  ].join('\n');
}

function renderTokens(content, variables) {
  let rendered = String(content || '');
  for (const variable of variables) {
    const token = `{{${variable.name}}}`;
    rendered = rendered.split(token).join(String(variable.value ?? ''));
  }
  return rendered;
}

function psLiteral(value, type = 'string') {
  if (type === 'boolean') return String(value).toLowerCase() === 'true' || value === true ? '$true' : '$false';
  if (type === 'number' && value !== '' && Number.isFinite(Number(value))) return String(Number(value));
  if (type === 'json') return `(ConvertFrom-Json -InputObject ${psString(value)})`;
  return psString(value);
}

function psString(value) {
  return `'${String(value || '').replace(/'/g, "''")}'`;
}
