/**
 * Dispatch a single RMM Overshell execution.
 *
 *   1. Validate script_id against the in-code registry.
 *   2. Resolve the target device.
 *   3. Per-user 24h rate limit (50 executions). Records every decision in
 *      analyzer_cost_audit so admins see RMM activity alongside LLM activity.
 *   4. Insert pending row in rmm_executions.
 *   5. Resolve Overshell component_uid (discover-on-demand if cache empty).
 *   6. Call client.runQuickJob — store the returned jobUid + flip to
 *      'running'. The worker takes over from there.
 *   7. Best-effort generic audit_log entry.
 */

import { randomBytes } from 'node:crypto';
import postgresClient from '@/lib/services/postgres-client';
import { getDattoRMMClient } from '@/lib/services/datto-rmm-factory';
import { presignUpload } from '@/lib/services/b2/client';
import {
  evaluateCost,
  recordCostAuditDecision,
} from '@/lib/services/analyzer/cost-guard';
import { audit } from '@/lib/services/audit';
import {
  resolveOvershellComponent,
  resolveLogliftComponent,
} from './settings';
import { getScript } from './scripts';
import {
  resolveAssetSelfTarget,
  resolveSiteAnchorTarget,
} from './target-resolver';
import {
  countUserExecutionsLast24h,
  createPendingExecution,
  markExecutionFailedToDispatch,
  markExecutionRunning,
} from './persistence';

const RATE_LIMIT_PER_24H = 50;

export type ExecutionTarget =
  | { type: 'site_anchor'; companyId: number | string }
  | {
      type: 'asset_self';
      deviceUid: string;
      hostname?: string | null;
      companyId?: number | string | null;
      assetType?: 'flexible_asset' | 'configuration';
      assetId?: number | string;
    };

export interface QueueExecutionInput {
  scriptId: string;
  target: ExecutionTarget;
  performedByUserId: string | null;
  triggeredByAuditId?: string | null;
}

export interface QueueExecutionResult {
  executionId: string;
  status: 'queued' | 'running' | 'failed';
  error?: string;
}

export async function queueExecution(
  input: QueueExecutionInput
): Promise<QueueExecutionResult> {
  const script = getScript(input.scriptId);
  if (!script) {
    throw new Error(`Unknown script_id: ${input.scriptId}`);
  }
  if (script.target_type !== input.target.type) {
    throw new Error(
      `Script "${script.id}" expects target_type=${script.target_type} but caller passed ${input.target.type}`
    );
  }

  // Resolve the target device.
  let deviceUid: string;
  let hostname: string | null;
  let companyId: number | string | null;
  let assetType: 'flexible_asset' | 'configuration' | null = null;
  let assetId: number | string | null = null;

  if (input.target.type === 'site_anchor') {
    const resolved = await resolveSiteAnchorTarget(input.target.companyId);
    if (!resolved) {
      throw new Error(
        `No Wulf Nurse Production endpoint registered in Datto RMM for company ${input.target.companyId}.`
      );
    }
    deviceUid = resolved.device_uid;
    hostname = resolved.hostname;
    companyId = input.target.companyId;
  } else {
    const resolved = await resolveAssetSelfTarget(input.target.deviceUid);
    deviceUid = resolved.device_uid;
    hostname = resolved.hostname ?? input.target.hostname ?? null;
    companyId = input.target.companyId ?? null;
    assetType = input.target.assetType ?? null;
    assetId = input.target.assetId ?? null;
  }

  // Rate-limit per user (24h rolling window).
  if (input.performedByUserId) {
    const recent = await countUserExecutionsLast24h(input.performedByUserId);
    if (recent >= RATE_LIMIT_PER_24H) {
      const evaluation = await evaluateCost({
        userId: input.performedByUserId,
        estimatedCost: 0,
        confirmedCost: false,
      });
      await recordCostAuditDecision({
        userId: input.performedByUserId,
        action: 'rmm_execute',
        evaluation: { ...evaluation, decision: 'blocked', decisionReason: `Rate-limited: ${recent} executions in last 24h (limit ${RATE_LIMIT_PER_24H})` },
        context: { scriptId: input.scriptId, recent24h: recent, limit: RATE_LIMIT_PER_24H },
      });
      throw new Error(
        `RMM execution rate limit reached (${recent}/${RATE_LIMIT_PER_24H} in 24h).`
      );
    }
    // Approved decision logged for telemetry.
    const evaluation = await evaluateCost({
      userId: input.performedByUserId,
      estimatedCost: 0,
      confirmedCost: false,
    });
    await recordCostAuditDecision({
      userId: input.performedByUserId,
      action: 'rmm_execute',
      evaluation,
      context: { scriptId: input.scriptId, recent24h: recent, deviceUid, hostname },
    });
  }

  // Fork on transport — b2_upload uses the LogLift component + webhook flow.
  if (script.transport === 'b2_upload') {
    return dispatchB2Upload({
      script,
      deviceUid,
      hostname,
      companyId,
      assetType,
      assetId,
      performedByUserId: input.performedByUserId,
      triggeredByAuditId: input.triggeredByAuditId ?? null,
    });
  }

  // Resolve the Overshell component (discover-on-demand if cache empty).
  let componentUid: string;
  let variableName: string;
  try {
    const resolved = await resolveOvershellComponent();
    componentUid = resolved.componentUid;
    variableName = resolved.variableName;
  } catch (err) {
    throw new Error(
      err instanceof Error
        ? err.message
        : 'Could not resolve the Overshell component'
    );
  }

  const jobName = `Pulse: ${script.name}`;
  const variables = [{ name: variableName, value: script.body }];

  // Insert pending row first.
  const created = await createPendingExecution({
    scriptId: script.id,
    scriptVersion: script.version,
    targetType: input.target.type,
    targetDeviceUid: deviceUid,
    targetHostname: hostname,
    targetCompanyId: companyId,
    triggeredByAuditId: input.triggeredByAuditId ?? null,
    assetType,
    assetId,
    jobName,
    variables,
    performedByUserId: input.performedByUserId,
  });

  // Dispatch.
  const client = getDattoRMMClient();
  let jobUid: string | null = null;
  try {
    const resp = await client.runQuickJob(deviceUid, {
      jobName,
      jobComponent: {
        componentUid,
        variables,
      },
    });
    // Datto's response is a {} on success per their convention; the job uid
    // sometimes comes back in different shapes depending on tenant config.
    // Accept either shape and persist whatever we can.
    jobUid =
      (resp?.uid as string | undefined) ??
      (resp?.jobUid as string | undefined) ??
      (resp?.id as string | undefined) ??
      (resp?.job?.uid as string | undefined) ??
      null;
  } catch (err) {
    const message = err instanceof Error ? err.message : String(err);
    await markExecutionFailedToDispatch(created.id, message);
    return { executionId: created.id, status: 'failed', error: message };
  }

  if (!jobUid) {
    // Datto accepted the request but didn't return an id we can poll. Mark
    // failed-to-dispatch so we don't leave the row dangling. The next run
    // can be resubmitted.
    const message =
      'Datto RMM accepted the runQuickJob request but did not return a job uid.';
    await markExecutionFailedToDispatch(created.id, message);
    return { executionId: created.id, status: 'failed', error: message };
  }

  await markExecutionRunning(created.id, jobUid);

  // Generic admin-visible audit entry.
  await audit.log({
    userId: input.performedByUserId ?? undefined,
    action: 'rmm.execute',
    resource: 'datto_device',
    resourceId: deviceUid,
    details: {
      execution_id: created.id,
      script_id: script.id,
      target_type: input.target.type,
      job_uid: jobUid,
      hostname,
    },
  });

  return { executionId: created.id, status: 'running' };
}

/**
 * Dispatch a LogLift-style execution: the Datto component uploads gzipped
 * evidence to B2 and POSTs a metadata webhook back to Pulse. The execution
 * row stays `running` until the webhook lands (or the 5-minute timeout fires).
 *
 * Variables passed to the Datto component:
 *   RunId           — correlation token; the webhook handler uses it to find
 *                     this row.
 *   ClientId        — Datto-side site uuid (informational; matches CS_PROFILE_UID).
 *   ObjectKey       — full B2 object key the collector will PUT to.
 *   UploadUrl       — pre-presigned PUT URL (30-min TTL); collector uploads
 *                     the gzip directly to B2 with no presign-fetch round-trip.
 *   WebhookUrl      — Pulse's /api/rmm/loglift/upload endpoint.
 *   WebhookSecret   — OPENCLAW_API_KEY; collector sends it as x-openclaw-key.
 *
 * Optional IssueDescription / TicketNumber can be added later by extending
 * QueueExecutionInput.
 */
async function dispatchB2Upload(args: {
  script: NonNullable<ReturnType<typeof getScript>>;
  deviceUid: string;
  hostname: string | null;
  companyId: number | string | null;
  assetType: 'flexible_asset' | 'configuration' | null;
  assetId: number | string | null;
  performedByUserId: string | null;
  triggeredByAuditId: string | null;
}): Promise<QueueExecutionResult> {
  const {
    script,
    deviceUid,
    hostname,
    companyId,
    assetType,
    assetId,
    performedByUserId,
    triggeredByAuditId,
  } = args;

  // The collector needs the Datto site uuid (datto_rmm_sites.uid) — that's
  // what folds into the B2 object key as ClientId.
  const siteRes = await postgresClient.query<{ site_uid: string }>(
    `SELECT s.uid AS site_uid
       FROM datto_rmm_devices d
       JOIN datto_rmm_sites s ON s.id = d.site_id
      WHERE d.uid = $1
      LIMIT 1`,
    [deviceUid]
  );
  if (siteRes.rowCount === 0) {
    throw new Error(
      `Datto device ${deviceUid} has no associated site — cannot derive ClientId for LogLift.`
    );
  }
  const clientId = siteRes.rows[0].site_uid;

  // Resolve LogLift component (discover-on-demand if cache empty).
  let logliftUid: string;
  try {
    const resolved = await resolveLogliftComponent();
    logliftUid = resolved.componentUid;
  } catch (err) {
    throw new Error(
      err instanceof Error ? err.message : 'Could not resolve the LogLift component'
    );
  }

  const baseUrl = (process.env.BETTER_AUTH_URL ?? '').replace(/\/$/, '');
  if (!baseUrl) {
    throw new Error(
      'BETTER_AUTH_URL must be set for LogLift dispatch (used as the webhook URL the collector POSTs to).'
    );
  }
  const webhookUrl = `${baseUrl}/api/rmm/loglift/upload`;
  const webhookSecret = process.env.OPENCLAW_API_KEY;
  if (!webhookSecret) {
    throw new Error(
      'OPENCLAW_API_KEY must be set for LogLift dispatch (collector authenticates with it as x-openclaw-key).'
    );
  }

  const runId = `pulse_${randomBytes(6).toString('hex')}_${Date.now()}`;
  const jobName = `Pulse: ${script.name}`;

  // Build the B2 object key + presigned PUT URL up-front. The collector
  // uploads directly with no presign-fetch round-trip. Hostname must be
  // present (asset_self LogLift dispatch always knows the target hostname).
  if (!hostname) {
    throw new Error(
      `LogLift dispatch requires a hostname for device ${deviceUid} (used as the B2 object-key path component).`
    );
  }
  const safeHostname = hostname.replace(/[^A-Za-z0-9_.-]/g, '_');
  const ts = formatObjectKeyTimestamp(new Date());
  const objectKey = `${clientId}/${safeHostname}/eventlogs_${ts}.json.gz`;
  const uploadUrl = presignUpload(objectKey, 1800); // 30-min TTL

  const variables = [
    { name: 'RunId', value: runId },
    { name: 'ClientId', value: clientId },
    { name: 'ObjectKey', value: objectKey },
    { name: 'UploadUrl', value: uploadUrl },
    { name: 'WebhookUrl', value: webhookUrl },
    { name: 'WebhookSecret', value: webhookSecret },
  ];

  // Strip secrets/signed URLs before persisting — the row's `variables`
  // column is admin-readable. ObjectKey is fine to keep; the presigned URL
  // contains a SigV4 signature that lets anyone PUT to that key for 30 min.
  const persistedVariables = variables.filter(
    (v) => v.name !== 'WebhookSecret' && v.name !== 'UploadUrl'
  );

  const created = await createPendingExecution({
    scriptId: script.id,
    scriptVersion: script.version,
    targetType: 'asset_self',
    targetDeviceUid: deviceUid,
    targetHostname: hostname,
    targetCompanyId: companyId,
    triggeredByAuditId,
    assetType,
    assetId,
    jobName,
    variables: persistedVariables,
    performedByUserId,
    transport: 'b2_upload',
    runId,
  });

  const client = getDattoRMMClient();
  let jobUid: string | null = null;
  try {
    const resp = await client.runQuickJob(deviceUid, {
      jobName,
      jobComponent: {
        componentUid: logliftUid,
        variables,
      },
    });
    jobUid =
      (resp?.uid as string | undefined) ??
      (resp?.jobUid as string | undefined) ??
      (resp?.id as string | undefined) ??
      (resp?.job?.uid as string | undefined) ??
      null;
  } catch (err) {
    const message = err instanceof Error ? err.message : String(err);
    await markExecutionFailedToDispatch(created.id, message);
    return { executionId: created.id, status: 'failed', error: message };
  }

  if (!jobUid) {
    const message =
      'Datto RMM accepted the runQuickJob request but did not return a job uid.';
    await markExecutionFailedToDispatch(created.id, message);
    return { executionId: created.id, status: 'failed', error: message };
  }

  await markExecutionRunning(created.id, jobUid);

  await audit.log({
    userId: performedByUserId ?? undefined,
    action: 'rmm.loglift.dispatched',
    resource: 'datto_device',
    resourceId: deviceUid,
    details: {
      execution_id: created.id,
      script_id: script.id,
      run_id: runId,
      client_id: clientId,
      object_key: objectKey,
      job_uid: jobUid,
      hostname,
    },
  });

  return { executionId: created.id, status: 'running' };
}

/**
 * Format a Date as `YYYYMMDD_HHMMSS` (UTC). Matches the file portion of
 * `OBJECT_KEY_REGEX` (`eventlogs_[0-9_]+\.json\.gz`).
 */
function formatObjectKeyTimestamp(d: Date): string {
  const y = d.getUTCFullYear();
  const m = String(d.getUTCMonth() + 1).padStart(2, '0');
  const day = String(d.getUTCDate()).padStart(2, '0');
  const h = String(d.getUTCHours()).padStart(2, '0');
  const mi = String(d.getUTCMinutes()).padStart(2, '0');
  const s = String(d.getUTCSeconds()).padStart(2, '0');
  return `${y}${m}${day}_${h}${mi}${s}`;
}

export const _EXECUTOR_INTERNALS = { RATE_LIMIT_PER_24H, formatObjectKeyTimestamp };
