/**
 * Hosting Deploy Worker
 *
 * Durable queue drainer for landing-page deployments — a direct clone of the Social
 * Media OS publish worker pattern (services/youtube/publishWorker). The
 * LandingPageDeployment collection is the queue: this worker ticks every 60s, claims
 * `queued` deployments (respecting nextAttemptAt backoff + a workerLockedAt claim),
 * bundles the page into a standalone artifact, uploads it via the provider adapter,
 * records the live URL, and retries with backoff on failure.
 *
 * Fully additive: it only touches the new HostingConnection / LandingPageDeployment
 * collections and reads the existing landing-page artifact + page object. It never
 * modifies any existing generator, route, or worker behaviour.
 */

import { readConnectionSecret } from './connectionSecret';
import { bundleLandingPage } from './landingPageBundler';
import { getHostingAdapter } from './adapters';
import { deleteDeploymentSnapshot, snapshotExists } from './landingPageSnapshot';
import { notificationService } from '../notificationService';

const TICK_INTERVAL_MS = 60 * 1000;
const STALE_LOCK_MS = 10 * 60 * 1000;
const MAX_DEPLOYS_PER_TICK = 2; // jobs claimed+processed sequentially per 60s tick
const MAX_ATTEMPTS = 3;

let workerTimer: ReturnType<typeof setInterval> | null = null;
let tickRunning = false;

function appBaseUrl(): string {
  return (process.env.APP_BASE_URL || process.env.PUBLIC_BASE_URL || process.env.APP_URL || '').replace(/\/$/, '');
}

function backoffMs(attempt: number): number {
  return Math.min(30, Math.pow(2, attempt)) * 60 * 1000; // 2,4,8… minutes, capped 30m
}

/** Mirror the deploy status onto the page object so the detail view shows the live URL. */
async function writeDeploymentToPage(deployment: any, patch: Record<string, any>): Promise<void> {
  try {
    const { getModels } = await import('../../models');
    const { LandingPageContentOS } = getModels();
    const set: Record<string, any> = {};
    for (const [k, v] of Object.entries(patch)) set[`pages.$.deployment.${k}`] = v;
    await LandingPageContentOS.updateOne({ 'pages.id': deployment.landingPageId }, { $set: set });
  } catch (err: any) {
    console.error('[HostingDeployWorker] Failed to write deployment to page:', err?.message);
  }
}

/**
 * Drop the frozen artifacts of this page's earlier deployments once a new one is live.
 *
 * Only the snapshot backing the live page is worth keeping — it is the record of what
 * is actually being served. Older ones can never be deployed again (a re-deploy always
 * captures a fresh snapshot), so they would just accumulate full page copies on disk.
 */
async function pruneSupersededSnapshots(LandingPageDeployment: any, current: any): Promise<void> {
  try {
    const stale = await LandingPageDeployment.find({
      landingPageId: current.landingPageId,
      _id: { $ne: current._id },
      // Never touch a deployment that is still waiting to run — its snapshot is the
      // frozen version it is about to publish.
      status: { $in: ['live', 'failed', 'cancelled'] },
      'snapshot.dir': { $nin: [null, ''] },
    }).select('_id');
    for (const old of stale) {
      deleteDeploymentSnapshot(String(old._id));
      await LandingPageDeployment.updateOne({ _id: old._id }, { $unset: { 'snapshot.dir': '' } });
    }
  } catch (err: any) {
    console.warn('[HostingDeployWorker] Could not prune superseded snapshots:', err?.message);
  }
}

async function executeDeployment(deployment: any): Promise<void> {
  const { getModels } = await import('../../models');
  const { HostingConnection, LandingPageContentOS, LandingPageDeployment } = getModels();

  // Resolve connection (with the encrypted secret) and the page.
  const connection = await HostingConnection.findById(deployment.connectionRef).select('+encryptedSecret');
  if (!connection) throw new Error('Hosting connection not found (it may have been removed).');
  // readConnectionSecret, not decrypt: a WordPress site connected from Settings →
  // Integrations stores its Application Password under a different cipher, and
  // decrypt() handed that back as ciphertext — a deploy through it failed as if
  // the password were wrong.
  const secret = readConnectionSecret(connection);

  const container = await LandingPageContentOS.findOne({ 'pages.id': deployment.landingPageId });
  const livePage = container?.pages?.find((p: any) => p.id === deployment.landingPageId);
  if (!livePage) throw new Error('Landing page not found.');

  // Publishing produces a STATIC version. Everything that goes live comes from the
  // snapshot frozen when this deployment was enqueued — the live page in the platform
  // may have been recoloured, re-generated or edited since, and none of that belongs
  // in this deploy. Deployments queued before snapshots existed fall back to the live
  // page so they still complete.
  const snapshot = deployment.snapshot;
  const useSnapshot = snapshotExists(snapshot?.dir);
  const page = useSnapshot && snapshot?.pageSettings
    ? { ...snapshot.pageSettings, id: snapshot.pageSettings.id || deployment.landingPageId }
    : livePage;
  if (!useSnapshot) {
    console.warn(`[HostingDeployWorker] Deployment ${deployment._id} has no usable snapshot — bundling the current page state.`);
  }

  const adapter = getHostingAdapter(deployment.provider);
  if (!adapter) throw new Error(`No adapter for provider "${deployment.provider}".`);

  // Guard against a silently-broken deploy: if the page asks for first-party ('app')
  // lead capture but there is no explicit endpoint AND no server base URL configured,
  // the bundler cannot rewire the <form> action — the page would go "live" with forms
  // pointing nowhere. Fail loudly with an actionable message instead.
  const base = appBaseUrl();
  if (page?.leadCapture?.mode === 'app' && !page?.leadCapture?.endpoint && !base) {
    throw new Error(
      'Lead capture is set to "app" but no lead endpoint is configured. Set the APP_BASE_URL environment variable (or a leadCapture.endpoint on the page) so the published form can post leads back.',
    );
  }

  // Bundle → standalone artifact.
  deployment.status = 'bundling';
  await deployment.save();
  const bundle = await bundleLandingPage(deployment.landingPageId, page, {
    appBaseUrl: base,
    sourceDir: useSnapshot ? snapshot.dir : undefined,
    artifactId: useSnapshot ? snapshot.artifactId : undefined,
  });

  // Honor a cancel that arrived while we were bundling.
  const fresh = await LandingPageDeployment.findById(deployment._id).select('status');
  if (fresh?.status === 'cancelled') {
    console.log(`[HostingDeployWorker] Deployment ${deployment._id} was cancelled during bundling — aborting.`);
    return;
  }

  // Deploy.
  deployment.status = 'deploying';
  await deployment.save();
  const result = await adapter.deploy({
    bundle,
    page,
    connection,
    secret,
    customDomain: deployment.customDomain,
  });

  // Persist any connection patch (e.g. Netlify site id created on first deploy).
  if (result.connectionPatch && Object.keys(result.connectionPatch).length) {
    Object.assign(connection, result.connectionPatch);
    // Mongoose Mixed types (like providerData) need explicit markModified
    if (result.connectionPatch.providerData) {
      connection.markModified('providerData');
    }
  }
  connection.lastUsedAt = new Date();
  connection.status = 'connected';
  await connection.save().catch(() => undefined);

  // Mark live.
  deployment.status = 'live';
  deployment.deployUrl = result.deployUrl || deployment.deployUrl || connection.baseUrl || '';
  deployment.providerDeployId = result.providerDeployId;
  deployment.bundleHash = bundle.hash;
  deployment.fileCount = bundle.fileCount;
  deployment.publishedAt = new Date();
  deployment.workerLockedAt = null;
  deployment.nextAttemptAt = null;
  await deployment.save();

  await writeDeploymentToPage(deployment, {
    provider: deployment.provider,
    status: 'live',
    deployUrl: deployment.deployUrl,
    connectionRef: String(deployment.connectionRef),
    deploymentId: String(deployment._id),
    customDomain: deployment.customDomain || '',
    publishedAt: deployment.publishedAt,
    // Fingerprint of the static version now serving, so the platform can tell the
    // user when the page has moved on and needs publishing again.
    publishedHash: deployment.snapshot?.contentHash || '',
  });

  // This snapshot is now the live version — every earlier one for this page is dead
  // weight on disk. Keep exactly the published copy.
  await pruneSupersededSnapshots(LandingPageDeployment, deployment);

  console.log(`[HostingDeployWorker] Deployed landing page ${deployment.landingPageId} → ${deployment.deployUrl} (${deployment.provider})`);

  // The deploy runs long after the request that queued it, so the person who
  // asked for it is no longer watching a response. `createdBy` is the owning
  // admin, which is why this single emit also covers the org-admin case.
  void notificationService.notifyUser(deployment.createdBy, {
    type: 'deploy.completed',
    title: 'Landing page published',
    message: `Your landing page is live at ${deployment.deployUrl} (${deployment.provider}).`,
    organizationId: deployment.companyId,
    entityType: 'landing_page_deployment',
    entityId: String(deployment._id),
    actionUrl: '/landing-page-os',
    notifyActor: true,
  });
}

/**
 * Atomically claim the next deployable job. Uses a single findOneAndUpdate so that two
 * server instances (or a fast restart) can never select and deploy the same record —
 * only the process that flips workerLockedAt wins. Returns null when nothing is claimable.
 */
async function claimNextDeployment(LandingPageDeployment: any): Promise<any | null> {
  const now = new Date();
  const staleBefore = new Date(now.getTime() - STALE_LOCK_MS);
  return LandingPageDeployment.findOneAndUpdate(
    {
      $or: [
        {
          status: 'queued',
          $and: [
            { $or: [{ nextAttemptAt: null }, { nextAttemptAt: { $lte: now } }] },
            { $or: [{ workerLockedAt: null }, { workerLockedAt: { $lte: staleBefore } }] },
          ],
        },
        // Re-claim jobs that stalled mid-bundle/deploy (crashed worker); count the
        // reclaim as an attempt so a perpetually-hanging job eventually fails instead
        // of being retried forever.
        { status: { $in: ['bundling', 'deploying'] }, workerLockedAt: { $lte: staleBefore } },
      ],
    },
    { $set: { workerLockedAt: new Date() }, $inc: { attemptCount: 1 } },
    { sort: { createdAt: 1 }, new: true },
  );
}

async function processQueuedDeployments(): Promise<void> {
  const { getModels } = await import('../../models');
  const { LandingPageDeployment } = getModels();

  for (let claimed = 0; claimed < MAX_DEPLOYS_PER_TICK; claimed++) {
    const deployment = await claimNextDeployment(LandingPageDeployment);
    if (!deployment) break;

    try {
      await executeDeployment(deployment);
    } catch (error: any) {
      const msg = (error?.message || 'Deployment failed').toString().slice(0, 500);
      console.error(`[HostingDeployWorker] Deployment ${deployment._id} failed:`, msg);
      try {
        // attemptCount was already incremented atomically at claim time.
        deployment.lastError = { code: 'deploy_failed', message: msg, at: new Date() };
        deployment.errorHistory = [...(deployment.errorHistory || []), deployment.lastError].slice(-10);
        deployment.workerLockedAt = null;
        if (deployment.attemptCount >= MAX_ATTEMPTS) {
          deployment.status = 'failed';
          deployment.nextAttemptAt = null;
          // Nothing from this snapshot ever went live and it can't be retried — drop
          // the frozen copy rather than leaving a full page artifact on disk.
          deleteDeploymentSnapshot(String(deployment._id));
          if (deployment.snapshot) deployment.set('snapshot.dir', undefined);
          await writeDeploymentToPage(deployment, { status: 'failed' });
          // Only once the retries are exhausted — a transient failure that the
          // backoff recovers from is not worth a notification.
          void notificationService.notifyUser(deployment.createdBy, {
            type: 'deploy.failed',
            title: 'Landing page deployment failed',
            message: `Publishing to ${deployment.provider} failed after ${MAX_ATTEMPTS} attempts: ${msg}`,
            organizationId: deployment.companyId,
            entityType: 'landing_page_deployment',
            entityId: String(deployment._id),
            actionUrl: '/landing-page-os',
            notifyActor: true,
          });
        } else {
          deployment.status = 'queued';
          deployment.nextAttemptAt = new Date(Date.now() + backoffMs(deployment.attemptCount));
        }
        await deployment.save();
      } catch (saveErr: any) {
        console.error('[HostingDeployWorker] Failed to record deployment error:', saveErr?.message);
      }
    }
  }
}

async function tick(): Promise<void> {
  if (tickRunning) return;
  tickRunning = true;
  try {
    await processQueuedDeployments();
  } catch (error: any) {
    console.error('[HostingDeployWorker] tick error:', error?.message);
  } finally {
    tickRunning = false;
  }
}

export function startHostingDeployWorker(): void {
  if (workerTimer) return;
  workerTimer = setInterval(tick, TICK_INTERVAL_MS);
  setTimeout(tick, 12 * 1000);
  console.log('🚀 Hosting deploy worker started (60s tick)');
}

/**
 * Run a tick right away instead of waiting up to 60s for the next one.
 *
 * Called when a deployment is enqueued so publishing visibly starts within a second.
 * Safe to call repeatedly: `tick()` no-ops while another tick is in flight, and job
 * claiming is a single atomic findOneAndUpdate.
 */
export function kickHostingDeployWorker(): void {
  setTimeout(() => { void tick(); }, 250);
}

export function stopHostingDeployWorker(): void {
  if (workerTimer) {
    clearInterval(workerTimer);
    workerTimer = null;
  }
}
