/**
 * services/clone-step-runner.js — one place that decides HOW a clone step runs.
 *
 * Owns: transport selection for Clone & Scale steps (agent vs SSH) and the
 *       refusal rules that keep a step from being reported failed while it is
 *       still running.
 * Does NOT own: the command catalog (services/ssh-executor.js COMMAND_WHITELIST,
 *       mirrored agent-side by scripts/gen-clone-catalog.js), recipe storage
 *       (db/clone-recipes.js), or the HTTP surface (routes/{ebs,db}-clone.js).
 *
 * Why it exists: both clone routes had their own copy of execute-step, and both
 * hard-required an SSH target. install.sh never configures one — modern agents
 * are outbound-poll only — so the entire Clone & Scale wizard was unreachable on
 * a standard install, the same way /os-diagnostics was. Two copies also meant
 * fixing one would have left the other behind.
 */

'use strict';

const pool     = require('../db/index');
const sshDb    = require('../db/ssh-targets');
const executor = require('./ssh-executor');
const { COMMAND_WHITELIST } = require('./ssh-executor');
const channel  = require('./agent-channel');
const { decrypt } = require('../crypto-utils');
const ebsJobs  = require('../db/ebs-jobs');

// Steps that legitimately run for many minutes. server.js applies a 120s
// backstop to every non-exempt request, so dispatching one of these
// synchronously would return a failure to the operator while adpreclone was
// still working — the rc=124 mistake, where a timeout got reported as "it
// failed" on a managed server that had in fact come up.
//
// These run DETACHED instead, through the same machinery the ADOP phases use:
// an ebs_jobs row, clone_step_bg on the agent, and the existing 15s poller
// against apps_job_status. The caller gets a job_id back immediately and polls
// GET /api/ebs-ops/jobs/:id.
const LONG_RUNNING = [
  '.preclone.',
  '.cfgclone.',
  '.autoconfig.',
];

// Exact keys, not fragments. `.rman.duplicate` as a FRAGMENT also matches
// `db.clone.rman.duplicate_preflight` — a read-only check that would then have
// been dispatched as a background job and polled for nothing. Caught by
// tests/test-clone-long-running.js, which exists for exactly this widening.
const LONG_RUNNING_EXACT = [
  // An RMAN active duplicate copies an entire database over the network. Hours,
  // not minutes — the clearest case there is for the background-job path.
  'db.clone.rman.duplicate',
];

const AGENT_TIMEOUT_MS = 110000; // inside the 120s request backstop

function isLongRunning(commandKey) {
  const k = String(commandKey);
  return LONG_RUNNING_EXACT.includes(k) || LONG_RUNNING.some(frag => k.includes(frag));
}

/**
 * Run one clone step.
 *
 * @param {object}  o
 * @param {number}  o.connectionId  Oracle connection the step belongs to.
 * @param {number} [o.targetId]     SSH target, when the operator has one.
 * @param {string}  o.commandKey    Key in COMMAND_WHITELIST / CLONE_COMMANDS.
 * @param {object} [o.vars]         {{VAR}} substitutions (validated agent-side).
 * @param {number}  o.userId        For ownership scoping and audit.
 * @param {string} [o.sysPassword]  Per-run secret for a step that declares
 *                                  `credential: 'sys_password'`. Never stored,
 *                                  never logged, never placed in argv.
 * @param {boolean} [o.dryRun]      Render and return the command the agent WOULD
 *                                  run, executing nothing. No job row, no
 *                                  credential on the wire, no confirmation.
 * @returns {Promise<object>} stepResult, always shaped the same either way.
 */
async function runCloneStep({
  connectionId, targetId, commandKey, vars = {}, userId,
  confirmed = false, sysPassword = '', dryRun = false,
}) {
  const startMs = Date.now();
  const base = { command_key: commandKey, target_id: targetId || null, ran_at: new Date().toISOString() };
  const fail = (msg, extra = {}) => ({
    ...base, stdout: '', stderr: msg, exit_code: null, success: false,
    rejected: true, rejection_reason: msg, duration_ms: Date.now() - startMs, ...extra,
  });

  if (!commandKey) return fail('command_key is required');

  // Destructive clone steps require explicit confirmation. Until now NOTHING in
  // the clone path read a `confirmed` flag — every step was a check or printed a
  // script, so there was nothing to gate. `db.clone.rman.duplicate` CREATES a
  // database over the auxiliary's datafiles, so the gate has to exist before it
  // ships, not after. db-ops enforces the same rule in db-ops-executor.js.
  // A dry run executes nothing, so it needs neither the confirmation nor the
  // credential. Requiring them would make the preview useless for its main
  // purpose: seeing what a destructive step will do BEFORE committing to it.
  const def = (COMMAND_WHITELIST || {})[commandKey];
  if (def && def.destructive && !confirmed && !dryRun) {
    return fail(
      `${commandKey} is destructive — it overwrites the target database. ` +
      'Re-send with confirmed:true; in the UI this is the confirmation dialog.',
      { requires_confirmation: true }
    );
  }

  // A step that declares a credential cannot run without one. This is where the
  // RMAN duplicate was broken end to end on the day it shipped: the page
  // collected the SYS password, and NO layer between the page and the agent
  // carried the field — the route did not destructure it, this function had no
  // parameter for it, and the agent's `params.get("sys_pwd")` was therefore
  // always empty. The agent then staged a blank line into the fd-3 file and rman
  // was handed `sys/""`, so an operator who had typed the DB name to confirm got
  // ORA-01017 — a message about credentials, with nothing pointing at the
  // missing plumbing that produced it.
  //
  // Every unit test passed throughout, because each asserted inside one half of
  // the seam. Refusing here means a future break in the chain surfaces as a
  // refusal before anything runs, instead of as an auth error hours in.
  const needsCredential = !!(def && def.credential === 'sys_password') && !dryRun;
  if (needsCredential) {
    if (!sysPassword) {
      return fail(
        `${commandKey} needs the source SYS password, and none arrived. ` +
        'It is collected per run and never stored — re-enter it and retry.',
        { requires_credential: 'sys_password' }
      );
    }
    // The agent writes this as one line into the 0600 file the script reads with
    // a single `read -r`. A newline would put the remainder on a second line
    // that nothing consumes — the same single-line rule agent.env values carry,
    // for the same reason.
    if (/[\r\n]/.test(sysPassword)) {
      return fail('The SYS password cannot contain a line break.');
    }
  }

  // ── SSH, when the operator has actually configured a target ───────────────
  if (targetId) {
    // The SSH path stages no fd-3 credential file and has no background
    // dispatch, so a step needing either would run with `<&3` on a closed
    // descriptor, synchronously, against the 120s cap. Refuse plainly rather
    // than start a database duplicate that cannot authenticate and cannot
    // outlive the request.
    if (needsCredential || isLongRunning(commandKey)) {
      return fail(
        `${commandKey} runs only through the agent — the SSH path cannot carry ` +
        'its credential or outlive the request. Remove the SSH target selection ' +
        'for this step, or run it on an agent-managed connection.'
      );
    }
    // The SSH executor renders and runs in one call with no preview mode, so
    // there is no honest way to answer "what would this run" on that path.
    // Saying so beats silently executing a step the operator asked to preview.
    if (dryRun) {
      return fail('Dry run is available on the agent path only — the SSH executor ' +
                  'has no preview mode. Clear the SSH target to preview this step.');
    }
    const target = await sshDb.getTargetByIdForUserWithCreds(targetId, userId);
    if (!target) return fail('SSH target not found or access denied');
    const result = await executor.runCommand({
      targetId: parseInt(targetId, 10), commandKey, initiatedBy: userId, extraVars: vars,
    });
    return {
      ...base, via: 'ssh',
      stdout: result.stdout, stderr: result.stderr, exit_code: result.exitCode,
      success: result.ok, rejected: result.rejected || false,
      rejection_reason: result.rejectionReason || null,
      duration_ms: Date.now() - startMs,
    };
  }

  // ── Agent — the path a standard install actually has ──────────────────────
  if (!connectionId) return fail('connection_id is required when no SSH target is configured');

  const { rows } = await pool.query(
    `SELECT id, connection_type, server_type, name, apps_pwd_enc, weblogic_pwd_enc
       FROM oracle_connections
      WHERE id = :p1 AND user_id = :p2`,
    { p1: parseInt(connectionId, 10), p2: userId }
  );
  if (!rows.length) return fail('Connection not found');
  const conn = rows[0];

  if (conn.connection_type !== 'proxy') {
    return fail('This connection is Direct TCP, which has no agent to run shell commands. ' +
                'Configure an SSH target for it, or connect it via the agent installer.');
  }

  // A previewed background step must not book a job. There is nothing to poll,
  // nothing to cancel, and a queued-then-abandoned row is exactly what makes an
  // operator believe something is running. It keeps the `clone_step_bg` op so the
  // agent builds the REAL background script — the sync path would render a
  // different command and preview the wrong thing — with job_id 0, which the
  // agent's `^\d+$` check accepts and never uses, because a dry run stages no
  // credential file and launches nothing.
  if (isLongRunning(commandKey) && dryRun) {
    let dresp;
    try {
      dresp = await channel.sendToAgent(connectionId, {
        method: 'POST',
        path: '/api/ebs-ctrl',
        body: {
          op: 'clone_step_bg', job_id: '0', command_key: commandKey, vars,
          dry_run: true,
          // Not decrypted for a preview: nothing will consume them.
          apps_pwd: '', weblogic_pwd: '',
        },
      }, AGENT_TIMEOUT_MS);
    } catch (err) {
      return fail(`Agent channel error: ${err.message}`);
    }
    const dbody = (dresp && dresp.body) || {};
    if (dresp && dresp.statusCode >= 400) return fail(dbody.error || `Agent returned HTTP ${dresp.statusCode}`);
    return {
      ...base, via: 'agent', dry_run: true,
      stdout: dbody.stdout || '', stderr: dbody.stderr || '',
      // Deliberately null, mirroring the agent: a preview has no exit status, and
      // a 0 here would let any caller read it as a successful run.
      exit_code: null, success: dbody.dry_run === true,
      rejected: false, rejection_reason: null,
      duration_ms: Date.now() - startMs,
    };
  }

  if (isLongRunning(commandKey)) {
    // One clone action at a time per connection. Two adcfgclone runs against the
    // same file system is not a race worth discovering in production, and the
    // control ops already enforce the same rule.
    try {
      const active = await ebsJobs.getActiveJobForConnection(conn.id);
      if (active) {
        return fail(
          `A ${active.op_key} operation is already running on this connection — ` +
          'wait for it to finish or cancel it first.',
          { job_id: active.id, running_op: active.op_key }
        );
      }
    } catch (err) {
      // Non-fatal: a DB hiccup on the mutex check must not block the operator.
      console.error('[clone-step] active-job check failed:', err.message);
    }

    let jobId;
    try {
      ({ id: jobId } = await ebsJobs.createJob(conn.id, commandKey, userId));
    } catch (err) {
      return fail(`Could not record the clone job: ${err.message}`);
    }

    let started;
    try {
      started = await channel.sendToAgent(connectionId, {
        method: 'POST',
        path: '/api/ebs-ctrl',
        body: {
        op: 'clone_step_bg', job_id: String(jobId), command_key: commandKey, vars,
        // adpreclone/adautocfg prompt for these. decrypt(null) throws, so guard
        // each one — an app-tier connection may legitimately have neither.
        apps_pwd:     conn.apps_pwd_enc     ? decrypt(conn.apps_pwd_enc)     : '',
        weblogic_pwd: conn.weblogic_pwd_enc ? decrypt(conn.weblogic_pwd_enc) : '',
        // `sys_pwd` is the key oracle-proxy.py reads (line ~7007). It is NOT
        // `sys_password`, which is what the page posts — the rename happens here,
        // and it is the whole reason this must be pinned by a test that spans
        // both ends rather than checked at either one.
        //
        // Unlike the two above, this is not a stored connection credential: it is
        // supplied per run and forwarded once. Sent only for a step that declares
        // it, so no other clone step carries a secret it has no use for.
        ...(needsCredential ? { sys_pwd: sysPassword } : {}),
      },
      }, AGENT_TIMEOUT_MS);
    } catch (err) {
      await ebsJobs.completeJob(jobId, {
        ok: false, exit_code: 1, duration_ms: Date.now() - startMs,
        stdout: `Agent channel error: ${err.message}`,
      }).catch(() => {});
      return fail(`Agent channel error: ${err.message}`, { job_id: jobId });
    }

    const sbody = (started && started.body) || {};
    if (!sbody.started) {
      // The job row must say WHY rather than sit at queued looking abandoned.
      const why = sbody.error || `Agent returned HTTP ${started && started.statusCode}`;
      await ebsJobs.completeJob(jobId, {
        ok: false, exit_code: 1, duration_ms: Date.now() - startMs, stdout: why,
      }).catch(() => {});
      return fail(why, { job_id: jobId });
    }

    await ebsJobs.startJob(jobId).catch(() => {});
    return {
      ...base, via: 'agent',
      long_running: true, job_id: jobId, status: 'running',
      stdout: '', stderr: '', exit_code: null,
      // NOT success:true — nothing has succeeded yet. The caller polls.
      success: false, rejected: false, rejection_reason: null,
      duration_ms: Date.now() - startMs,
    };
  }

  let resp;
  try {
    resp = await channel.sendToAgent(connectionId, {
      method: 'POST',
      path: '/api/ebs-ctrl',
      body: { op: 'clone_step', command_key: commandKey, vars, dry_run: dryRun === true },
    }, AGENT_TIMEOUT_MS);
  } catch (err) {
    return fail(`Agent channel error: ${err.message}`);
  }

  // sendToAgent resolves to { statusCode, body } — the op's own result is in body.
  // Reading resp.stdout directly is why an earlier agent-routing change looked
  // like it returned nothing at all.
  const body = (resp && resp.body) || {};
  if (resp && resp.statusCode && resp.statusCode >= 400) {
    return fail(body.error || `Agent returned HTTP ${resp.statusCode}`);
  }
  if (!resp || (body.success === false && !body.stdout)) {
    return fail(body.error || 'Agent returned no result');
  }

  return {
    ...base, via: 'agent',
    // Carried through so the UI can render a preview as a preview. Without it a
    // dry run looks exactly like a completed step, which is the one way this
    // feature could do harm.
    ...(body.dry_run === true ? { dry_run: true } : {}),
    stdout: body.stdout || '', stderr: body.stderr || '',
    exit_code: typeof body.exit_code === 'number' ? body.exit_code : null,
    success: body.ok !== false && body.success !== false,
    rejected: false, rejection_reason: null,
    duration_ms: Date.now() - startMs,
  };
}

module.exports = { runCloneStep, isLongRunning, LONG_RUNNING };
