/**
 * Website Deploy Worker
 *
 * Durable queue drainer for Website Planner deployments — a direct clone of the
 * landing-page hostingDeployWorker. The WebsiteDeployment collection is the queue: this
 * worker ticks every 60s, claims `queued` deployments (respecting nextAttemptAt backoff +
 * a workerLockedAt claim), bundles the generated multi-page site 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 / WebsiteDeployment
 * collections and reads the existing website artifact + planner object (from ModuleData).
 * It never modifies any existing generator, route, or worker behaviour.
 */

import { readConnectionSecret } from './connectionSecret';
import { bundleWebsite } from './websiteBundler';
import { getHostingAdapter } from './adapters';
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
}

/** Resolve the planner object (inside ModuleData.data.websitePlanners[]) for a deployment. */
async function findWebsite(companyId: string, websiteId: string): Promise<any | null> {
  const { getModels } = await import('../../models');
  const { ModuleData } = getModels();
  const doc = await ModuleData.findOne({ moduleId: 'websitePlanners', companyId });
  const websites = doc?.data?.websitePlanners || [];
  return websites.find((w: any) => w.id === websiteId || w._id?.toString() === websiteId) || null;
}

/** Mirror the deploy status onto the planner object so the detail view shows the live URL. */
async function writeDeploymentToWebsite(deployment: any, patch: Record<string, any>): Promise<void> {
  try {
    const { getModels } = await import('../../models');
    const { ModuleData } = getModels();
    const set: Record<string, any> = {};
    for (const [k, v] of Object.entries(patch)) set[`data.websitePlanners.$.deployment.${k}`] = v;
    await ModuleData.updateOne(
      { moduleId: 'websitePlanners', companyId: deployment.companyId, 'data.websitePlanners.id': deployment.websiteId },
      { $set: set },
    );
  } catch (err: any) {
    console.error('[WebsiteDeployWorker] Failed to write deployment to website:', err?.message);
  }
}

async function executeDeployment(deployment: any): Promise<void> {
  const { getModels } = await import('../../models');
  const { HostingConnection, WebsiteDeployment } = getModels();

  // Resolve connection (with the encrypted secret) and the website.
  const connection = await HostingConnection.findById(deployment.connectionRef).select('+encryptedSecret');
  if (!connection) throw new Error('Hosting connection not found (it may have been removed).');
  // See hostingDeployWorker: both cipher shapes live in this collection, and
  // decrypt() only understands one of them.
  const secret = readConnectionSecret(connection);

  const website = await findWebsite(deployment.companyId, deployment.websiteId);
  if (!website) throw new Error('Website not found.');

  const adapter = getHostingAdapter(deployment.provider);
  if (!adapter) throw new Error(`No adapter for provider "${deployment.provider}".`);

  // Guard against a silently-broken deploy: if the site 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 (website?.leadCapture?.mode === 'app' && !website?.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 website) so the published form can post leads back.',
    );
  }

  // Bundle → standalone artifact.
  deployment.status = 'bundling';
  await deployment.save();
  const bundle = await bundleWebsite(deployment.websiteId, website, { appBaseUrl: base });

  // Honor a cancel that arrived while we were bundling.
  const fresh = await WebsiteDeployment.findById(deployment._id).select('status');
  if (fresh?.status === 'cancelled') {
    console.log(`[WebsiteDeployWorker] Deployment ${deployment._id} was cancelled during bundling — aborting.`);
    return;
  }

  // Deploy.
  deployment.status = 'deploying';
  await deployment.save();
  const result = await adapter.deploy({
    bundle,
    page: website,
    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 writeDeploymentToWebsite(deployment, {
    provider: deployment.provider,
    status: 'live',
    deployUrl: deployment.deployUrl,
    connectionRef: String(deployment.connectionRef),
    deploymentId: String(deployment._id),
    customDomain: deployment.customDomain || '',
    publishedAt: deployment.publishedAt,
  });

  console.log(`[WebsiteDeployWorker] Deployed website ${deployment.websiteId} → ${deployment.deployUrl} (${deployment.provider})`);

  // See hostingDeployWorker — same reasoning: the requester is long gone by the
  // time the queue drains, and `createdBy` is the owning admin.
  void notificationService.notifyUser(deployment.createdBy, {
    type: 'deploy.completed',
    title: 'Website published',
    message: `Your website is live at ${deployment.deployUrl} (${deployment.provider}).`,
    organizationId: deployment.companyId,
    entityType: 'website_deployment',
    entityId: String(deployment._id),
    actionUrl: '/website-planner',
    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(WebsiteDeployment: any): Promise<any | null> {
  const now = new Date();
  const staleBefore = new Date(now.getTime() - STALE_LOCK_MS);
  return WebsiteDeployment.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 { WebsiteDeployment } = getModels();

  for (let claimed = 0; claimed < MAX_DEPLOYS_PER_TICK; claimed++) {
    const deployment = await claimNextDeployment(WebsiteDeployment);
    if (!deployment) break;

    try {
      await executeDeployment(deployment);
    } catch (error: any) {
      const msg = (error?.message || 'Deployment failed').toString().slice(0, 500);
      console.error(`[WebsiteDeployWorker] 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;
          await writeDeploymentToWebsite(deployment, { status: 'failed' });
          // Only after the retries are exhausted, as in hostingDeployWorker.
          void notificationService.notifyUser(deployment.createdBy, {
            type: 'deploy.failed',
            title: 'Website deployment failed',
            message: `Publishing to ${deployment.provider} failed after ${MAX_ATTEMPTS} attempts: ${msg}`,
            organizationId: deployment.companyId,
            entityType: 'website_deployment',
            entityId: String(deployment._id),
            actionUrl: '/website-planner',
            notifyActor: true,
          });
        } else {
          deployment.status = 'queued';
          deployment.nextAttemptAt = new Date(Date.now() + backoffMs(deployment.attemptCount));
        }
        await deployment.save();
      } catch (saveErr: any) {
        console.error('[WebsiteDeployWorker] 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('[WebsiteDeployWorker] tick error:', error?.message);
  } finally {
    tickRunning = false;
  }
}

export function startWebsiteDeployWorker(): void {
  if (workerTimer) return;
  workerTimer = setInterval(tick, TICK_INTERVAL_MS);
  setTimeout(tick, 12 * 1000);
  console.log('🚀 Website 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. Safe to call repeatedly — `tick()` no-ops while one is in
 * flight and job claiming is a single atomic findOneAndUpdate.
 */
export function kickWebsiteDeployWorker(): void {
  setTimeout(() => { void tick(); }, 250);
}

export function stopWebsiteDeployWorker(): void {
  if (workerTimer) {
    clearInterval(workerTimer);
    workerTimer = null;
  }
}
