import { createScheduledDbService, type DoDbClient } from '@/server/services/db.js';
import { and, eq, inArray } from 'drizzle-orm';
import type { CacheInvalidateScope } from '@/server/cache/invalidate.js';
import { dealCategories, dealTranslations, deals } from '@/server/db/schema';
import type { MultidealEnv } from '../lib/env.js';
import { respondError } from '@/server/api/error-envelope.js';

const PREWARM_DEBOUNCE_MS = 8000;
const MAX_CONCURRENT_PREWARM = 4;
const DO_INNER_HEADER = 'x-md-do-inner';
const DO_INNER_HEADER_VALUE = '1';

/** Canonical always-warm routes — mirrors apps/web/src/server/cron/warm-routes.ts. */
const ALWAYS_ROUTES = ['/', '/deals', '/en/deals', '/deals/page-1'] as const;

const DEFAULT_FEED_BODY = '{}';

type PrewarmTarget = {
  url: string;
  method?: 'GET' | 'POST';
  body?: string;
};

/**
 * CacheEpochDO — single global monotonic catalog-cache epoch.
 *
 * One instance (idFromName('catalog')). Read on every catalog cache-key build
 * (heavily isolate-cached, ~1 read / 5s / isolate). Bumped inline on every
 * correctness mutation. The epoch rides in cache-entry METADATA, never the key
 * — readers serve-stale + async-regenerate on mismatch (see edge-cache.ts).
 *
 * POST /coalesce buffers invalidation scopes; alarm() drains them debounced
 * and prewarms affected public routes without storming Workers Free CPU.
 */
export class CacheEpochDO {
  private ctx: DurableObjectState;
  private env: MultidealEnv;

  constructor(ctx: DurableObjectState, env: MultidealEnv) {
    this.ctx = ctx;
    this.env = env;
  }

  async fetch(request: Request): Promise<Response> {
    const url = new URL(request.url);

    if (request.method === 'GET' && url.pathname === '/get') {
      const epoch = (await this.ctx.storage.get<number>('epoch')) ?? 0;
      return Response.json({ epoch });
    }

    if (request.method === 'POST' && url.pathname === '/bump') {
      const current = (await this.ctx.storage.get<number>('epoch')) ?? 0;
      const next = current + 1;
      await this.ctx.storage.put('epoch', next);
      return Response.json({ epoch: next });
    }

    if (request.method === 'POST' && url.pathname === '/coalesce') {
      const scope = (await request.json()) as CacheInvalidateScope;
      const pending = (await this.ctx.storage.get<CacheInvalidateScope[]>('pending')) ?? [];
      pending.push(scope);
      await this.ctx.storage.put('pending', pending);

      const alarmAt = await this.ctx.storage.getAlarm();
      if (alarmAt === null) {
        await this.ctx.storage.setAlarm(Date.now() + PREWARM_DEBOUNCE_MS);
      }

      return Response.json({ ok: true, pending: pending.length });
    }

    return respondError('NOT_FOUND', 'Not found');
  }

  async alarm(): Promise<void> {
    try {
      const pendingSnapshot = (await this.ctx.storage.get<CacheInvalidateScope[]>('pending')) ?? [];
      if (pendingSnapshot.length === 0) return;

      const targets = await this.resolveRoutes(pendingSnapshot);

      for (let i = 0; i < targets.length; i += MAX_CONCURRENT_PREWARM) {
        const batch = targets.slice(i, i + MAX_CONCURRENT_PREWARM);
        await Promise.all(batch.map((target) => this.prewarmFetch(target)));
      }

      await this.reconcilePending(pendingSnapshot);
    } catch (err) {
      console.error(
        JSON.stringify({
          event: 'prewarm_alarm_failed',
          error: err instanceof Error ? err.message : String(err),
        }),
      );
    }
  }

  private siteBase(): string {
    return this.env.PUBLIC_SITE_URL ?? 'https://dev.multi.deal';
  }

  private async resolveRoutes(pending: CacheInvalidateScope[]): Promise<PrewarmTarget[]> {
    const base = this.siteBase();
    const pathSet = new Set<string>(ALWAYS_ROUTES);

    const deduped = new Map<string, CacheInvalidateScope>();
    for (const scope of pending) {
      deduped.set(JSON.stringify(scope), scope);
    }

    for (const scope of deduped.values()) {
      try {
        await this.addScopeRoutes(pathSet, scope);
      } catch (err) {
        console.error(
          JSON.stringify({
            event: 'prewarm_scope_resolve_failed',
            scope: scope.scope,
            error: err instanceof Error ? err.message : String(err),
          }),
        );
      }
    }

    const targets: PrewarmTarget[] = [...pathSet].map((route) => ({
      url: `${base}${route}`,
    }));

    targets.push({
      url: `${base}/api/feed`,
      method: 'POST',
      body: DEFAULT_FEED_BODY,
    });

    return targets;
  }

  private async addScopeRoutes(pathSet: Set<string>, scope: CacheInvalidateScope): Promise<void> {
    switch (scope.scope) {
      case 'global':
        return;

      case 'category':
        pathSet.add(`/deals/${scope.categorySlug}`);
        pathSet.add(`/en/deals/${scope.categorySlug}`);
        return;

      case 'vendor':
        pathSet.add(`/business/${scope.vendorSlug}`);
        return;

      case 'deal': {
        const db = createScheduledDbService(this.env);
        const slugRows = await db
          .select({ locale: dealTranslations.locale, slug: dealTranslations.slug })
          .from(dealTranslations)
          .where(
            and(
              eq(dealTranslations.dealId, scope.dealId),
              inArray(dealTranslations.locale, ['he', 'en']),
            ),
          );

        if (slugRows.length === 0) return;

        for (const row of slugRows) {
          if (row.locale === 'he') pathSet.add(`/deals/${row.slug}`);
          if (row.locale === 'en') pathSet.add(`/en/deals/${row.slug}`);
        }

        const categorySlug =
          scope.categorySlug ?? (await this.resolveCategorySlug(db, scope.dealId));
        if (categorySlug) {
          pathSet.add(`/deals/${categorySlug}`);
          pathSet.add(`/en/deals/${categorySlug}`);
        }
        return;
      }
    }
  }

  private async resolveCategorySlug(db: DoDbClient, dealId: string): Promise<string | null> {
    const rows = await db
      .select({ slug: dealCategories.slug })
      .from(deals)
      .innerJoin(dealCategories, eq(deals.categoryId, dealCategories.id))
      .where(eq(deals.id, dealId))
      .limit(1);
    return rows[0]?.slug ?? null;
  }

  private async prewarmFetch(target: PrewarmTarget): Promise<void> {
    try {
      const headers: Record<string, string> = {
        'x-prewarm': '1',
        [DO_INNER_HEADER]: DO_INNER_HEADER_VALUE,
      };
      if (target.method === 'POST') {
        headers['Content-Type'] = 'application/json';
      }
      await fetch(target.url, {
        method: target.method ?? 'GET',
        headers,
        body: target.body,
      });
    } catch (err) {
      console.error(
        JSON.stringify({
          event: 'prewarm_fetch_failed',
          url: target.url,
          error: err instanceof Error ? err.message : String(err),
        }),
      );
    }
  }

  private async reconcilePending(consumed: CacheInvalidateScope[]): Promise<void> {
    const latest = (await this.ctx.storage.get<CacheInvalidateScope[]>('pending')) ?? [];
    const remaining = this.subtractConsumed(latest, consumed);

    if (remaining.length === 0) {
      await this.ctx.storage.delete('pending');
      return;
    }

    await this.ctx.storage.put('pending', remaining);
    await this.ctx.storage.setAlarm(Date.now() + PREWARM_DEBOUNCE_MS);
  }

  private subtractConsumed(
    latest: CacheInvalidateScope[],
    consumed: CacheInvalidateScope[],
  ): CacheInvalidateScope[] {
    const counts = new Map<string, number>();

    for (const scope of consumed) {
      const key = JSON.stringify(scope);
      counts.set(key, (counts.get(key) ?? 0) + 1);
    }

    const remaining: CacheInvalidateScope[] = [];
    for (const scope of latest) {
      const key = JSON.stringify(scope);
      const available = counts.get(key) ?? 0;
      if (available > 0) {
        counts.set(key, available - 1);
        continue;
      }
      remaining.push(scope);
    }

    return remaining;
  }
}
