/**
 * Vendor Deal Draft effect interpreter.
 *
 * Only place where DB writes for the draft aggregate are invoked.
 *
 * Atomic-submit semantics:
 *   1. Cap check (platform_image_limits) — before tx to avoid connection waste
 *   2. Cap check (platform_image_limits) — before tx
 *   3. createDeal(UNDER_REVIEW) → deal row
 *   4. writeImageSet: delete existing + insert deal-level + per-SKU images + visual-axis flag
 *   5. setDealTags(tagIds)
 *   6. enqueueOutbox(deal.llm-validate)
 *   7. deleteDraft(draftId)
 *
 *   All inside a single Drizzle transaction. Caller fires OUTBOX_QUEUE.send
 *   after this function resolves.
 */

import { and, eq, inArray } from 'drizzle-orm';
import { replaceQtyTiersForSku } from '@/server/db/queries/sku-qty-tiers.js';
import type { DrizzleClient, TxDrizzleClient } from '@/server/db/client.js';
import {
  deleteDraft,
  insertDraft,
  setStarred,
  updateDraft,
  type getDraftById,
} from '@/server/db/queries/deal-drafts.js';
import { createDealWithSkus } from '@/server/domain/vendor-deal/create-deal.js';
import { setDealTags } from '@/server/db/queries/categories.js';
import { enqueueOutbox } from '@/server/db/queries/outbox.js';
import {
  dealImages,
  dealSkus,
  dealTranslations,
  dealVariantAxes,
  dealVariantOptions,
} from '@/server/db/schema.js';
import { slugify } from '@/server/translation/fields/_helpers.js';
import type { CreateDealBody, ImageSet } from '@/server/schemas/vendor.js';
import type { AtomicSubmitEffect, QueryOnlyDraftEffect, VendorDealDraftEffect } from './effects.js';
import type { DoClient } from '@/server/services/types.js';
import { getPlatformImageLimits } from '@/server/services/image-limits.js';
import { hashOptionIds } from '@/server/domain/variants/hash.js';

export interface ApplyDraftDeps {
  db: TxDrizzleClient;
  doClient: DoClient;
  env: { DATABASE_URL: string };
}

export interface ApplyDraftQueryDeps {
  db: DrizzleClient;
  doClient: DoClient;
  env: { DATABASE_URL: string };
}

export type { QueryOnlyDraftEffect } from './effects.js';

export interface ApplyDraftResult {
  insertedDraft?: Awaited<ReturnType<typeof insertDraft>>;
  updatedDraft?: Awaited<ReturnType<typeof updateDraft>>;
  starredDraft?: Awaited<ReturnType<typeof setStarred>>;
  /** Populated by atomic-submit. */
  dealId?: string;
  outboxId?: string;
  /** Populated when an update_requested resolved to delete-draft branch. */
  deleted?: boolean;
  /** Optional re-read of draft after update for callers that need it. */
  draftAfterUpdate?: Awaited<ReturnType<typeof getDraftById>>;
}

export async function applyEffects(
  deps: ApplyDraftQueryDeps,
  effects: QueryOnlyDraftEffect[],
): Promise<ApplyDraftResult> {
  const result: ApplyDraftResult = {};
  for (const eff of effects) {
    await applyQueryOnlyEffect(deps, eff, result);
  }
  return result;
}

export async function applyTransactionalEffects(
  deps: ApplyDraftDeps,
  effects: VendorDealDraftEffect[],
): Promise<ApplyDraftResult> {
  const result: ApplyDraftResult = {};
  for (const eff of effects) {
    if (eff.kind === 'atomic-submit') {
      const atomic = await applyAtomicSubmitEffect(deps, eff);
      result.dealId = atomic.dealId;
      result.outboxId = atomic.outboxId;
    } else {
      await applyQueryOnlyEffect(deps, eff, result);
    }
  }
  return result;
}

async function applyQueryOnlyEffect(
  deps: ApplyDraftQueryDeps,
  eff: QueryOnlyDraftEffect,
  result: ApplyDraftResult,
): Promise<void> {
  const { db } = deps;

  switch (eff.kind) {
    case 'insert-draft': {
      result.insertedDraft = await insertDraft(db, {
        vendorId: eff.vendorId,
        payload: eff.payload,
        title: eff.title,
      });
      break;
    }
    case 'update-draft': {
      result.updatedDraft = await updateDraft(db, eff.draftId, {
        payload: eff.payload,
        title: eff.title,
      });
      break;
    }
    case 'set-starred': {
      result.starredDraft = await setStarred(db, eff.draftId, eff.starred);
      break;
    }
    case 'delete-draft': {
      await deleteDraft(db, eff.draftId);
      result.deleted = true;
      break;
    }
  }
}

async function applyAtomicSubmitEffect(
  deps: ApplyDraftDeps,
  eff: AtomicSubmitEffect,
): Promise<{ dealId: string; outboxId: string }> {
  const { env } = deps;
  const validated = eff.validatedDeal as unknown as CreateDealBody;
  const imageSet = validated.imageSet;

  // Cap check before opening the transaction (avoids wasting a connection slot).
  const limits = await getPlatformImageLimits(env);
  if (imageSet.deal.length > limits.maxImagesPerDeal) {
    throw new Error(
      `deal image count ${imageSet.deal.length} exceeds platform cap ${limits.maxImagesPerDeal}`,
    );
  }
  for (const set of imageSet.skus ?? []) {
    if (set.images.length > limits.maxImagesPerSku) {
      throw new Error(
        `sku ${set.optionValueCodes.join('|')} image count ${set.images.length} exceeds platform cap ${limits.maxImagesPerSku}`,
      );
    }
  }

  return deps.db.transaction(async (tx) => {
    const { dealId: newDealId } = await createDealWithSkus(tx, {
      dealRow: {
        vendorId: eff.vendorId,
        dealType: validated.dealType,
        title: validated.title,
        description: validated.description ?? '',
        categoryId: validated.categoryId,
        isVoucher: validated.isVoucher,
        windowStart: validated.windowStart ? new Date(validated.windowStart) : undefined,
        windowEnd: validated.windowEnd ? new Date(validated.windowEnd) : undefined,
        pickupStart: validated.pickupStart,
        pickupEnd: validated.pickupEnd,
        pickupAddress: validated.pickupAddress ?? '',
        specialInstructions: validated.specialInstructions,
        dealState: 'UNDER_REVIEW',
      },
      mode: 'single',
      single: {
        originalPrice: validated.originalPrice,
        discountPercent: validated.discountPercent,
        discountedPrice: validated.discountedPrice,
        quantityTotal: validated.quantityTotal,
      },
    });

    await tx.insert(dealTranslations).values({
      dealId: newDealId,
      locale: 'he',
      slug: `${slugify(validated.title, newDealId)}-${newDealId.slice(0, 8)}`,
      title: validated.title,
      description: validated.description ?? '',
      specialInstructions: validated.specialInstructions,
      pickupAddress: validated.pickupAddress ?? '',
      status: 'OK',
    });

    // Persist quantity-discount tiers for the single default SKU.
    // Must run inside the same tx for atomicity.
    if (eff.singleQtyTiers.length > 0) {
      const defaultHash = hashOptionIds([]);
      const [singleSku] = await tx
        .select({ id: dealSkus.id })
        .from(dealSkus)
        .where(and(eq(dealSkus.dealId, newDealId), eq(dealSkus.optionIdsHash, defaultHash)));
      if (singleSku) {
        await replaceQtyTiersForSku(tx, singleSku.id, eff.singleQtyTiers);
      }
    }

    // Fan-out check (inside tx, reads rows created by createDealWithSkus).
    if (typeof imageSet.visualAxisOrder === 'number' && (imageSet.skus?.length ?? 0) > 0) {
      await assertVisualAxisFanout(tx, newDealId, imageSet);
    }

    await writeImageSet(tx, newDealId, imageSet, 'PENDING');

    await setDealTags(tx, newDealId, validated.tagIds ?? []);

    const { id: outboxId } = await enqueueOutbox(tx, {
      aggregateType: 'deal',
      aggregateId: newDealId,
      eventType: 'deal.llm-validate',
      payload: { dealId: newDealId, vendorId: eff.vendorId },
    });

    await deleteDraft(tx, eff.draftId);

    return { dealId: newDealId, outboxId };
  });
}

// ─── Shared image-set writer ──────────────────────────────────────────────────

/**
 * Replace the image set for a deal atomically:
 *   1. Delete all existing dealImages rows for this deal.
 *   2. Insert deal-level images (skuId = null).
 *   3. Resolve optionIdsHash → sku.id and insert per-SKU images.
 *   4. Clear isVisualAxis on all axes, then set the chosen one.
 *
 * `tx` may be either the outer DrizzleClient or an active transaction.
 * `approvalStatus` is caller-controlled: PENDING for new/draft submits,
 * APPROVED for VETERAN direct-publish.
 */
export async function writeImageSet(
  tx: DrizzleClient,
  dealId: string,
  imageSet: ImageSet,
  approvalStatus: 'PENDING' | 'APPROVED' | 'REJECTED',
): Promise<void> {
  // 1. Drop all existing images for this deal.
  await tx.delete(dealImages).where(eq(dealImages.dealId, dealId));

  // 2. Deal-level images (skuId = null).
  const dealRows = imageSet.deal.map((img) => ({
    id: crypto.randomUUID(),
    dealId,
    draftId: null,
    skuId: null,
    url: img.r2Key,
    isPrimary: img.isPrimary,
    sortOrder: img.sortOrder,
    approvalStatus,
  }));
  if (dealRows.length > 0) {
    await tx.insert(dealImages).values(dealRows);
  }

  // 3. Per-SKU images.
  // Wire format uses optionValueCodes (client-known). Resolve them → optionIds → hash.
  const skuSets = imageSet.skus ?? [];
  if (skuSets.length > 0) {
    // Build (axisOrder, valueCode) → optionId map for this deal.
    const axisRows = await tx
      .select({ id: dealVariantAxes.id, axisOrder: dealVariantAxes.axisOrder })
      .from(dealVariantAxes)
      .where(eq(dealVariantAxes.dealId, dealId));
    const axisById = new Map(axisRows.map((a) => [a.id, a.axisOrder]));

    const optionRows =
      axisRows.length > 0
        ? await tx
            .select({
              id: dealVariantOptions.id,
              axisId: dealVariantOptions.axisId,
              valueCode: dealVariantOptions.valueCode,
            })
            .from(dealVariantOptions)
            .where(
              inArray(
                dealVariantOptions.axisId,
                axisRows.map((a) => a.id),
              ),
            )
        : [];

    const optionIdByAxisAndCode = new Map<string, string>();
    for (const opt of optionRows) {
      const axisOrder = axisById.get(opt.axisId);
      if (axisOrder === undefined) continue;
      optionIdByAxisAndCode.set(`${axisOrder}|${opt.valueCode}`, opt.id);
    }

    // Resolve each image-set to a hash using the same logic as upsert-skus.ts.
    const setsWithHashes = skuSets
      .map((set) => {
        const optionIds = set.optionValueCodes.map((code, idx) =>
          optionIdByAxisAndCode.get(`${idx}|${code}`),
        );
        if (optionIds.some((id) => id === undefined)) return null;
        const hash = hashOptionIds(optionIds as string[]);
        return { hash, set };
      })
      .filter((x): x is { hash: string; set: (typeof skuSets)[number] } => x !== null);

    if (setsWithHashes.length > 0) {
      const hashes = setsWithHashes.map((x) => x.hash);
      const skuRowsLookup = await tx
        .select({ id: dealSkus.id, hash: dealSkus.optionIdsHash })
        .from(dealSkus)
        .where(and(eq(dealSkus.dealId, dealId), inArray(dealSkus.optionIdsHash, hashes)));
      const hashToSkuId = new Map(skuRowsLookup.map((r) => [r.hash, r.id]));

      const skuImageRows = setsWithHashes.flatMap(({ hash, set }) => {
        const skuId = hashToSkuId.get(hash);
        if (!skuId) return [];
        return set.images.map((img) => ({
          id: crypto.randomUUID(),
          dealId,
          draftId: null,
          skuId,
          url: img.r2Key,
          isPrimary: img.isPrimary,
          sortOrder: img.sortOrder,
          approvalStatus,
        }));
      });
      if (skuImageRows.length > 0) {
        await tx.insert(dealImages).values(skuImageRows);
      }
    }
  }

  // 4. Visual-axis flag: clear all, then set the chosen one.
  await tx
    .update(dealVariantAxes)
    .set({ isVisualAxis: false })
    .where(eq(dealVariantAxes.dealId, dealId));

  if (typeof imageSet.visualAxisOrder === 'number') {
    await tx
      .update(dealVariantAxes)
      .set({ isVisualAxis: true })
      .where(
        and(
          eq(dealVariantAxes.dealId, dealId),
          eq(dealVariantAxes.axisOrder, imageSet.visualAxisOrder),
        ),
      );
  }
}

// ─── Visual-axis fan-out assertion ────────────────────────────────────────────

/**
 * Validates that SKUs sharing the same visual-axis option value have identical
 * image sets. Prevents divergent images for what is visually the "same" option.
 *
 * Must be called INSIDE the transaction, after createDealWithSkus has written
 * the axes/options/SKU rows.
 */
async function assertVisualAxisFanout(
  tx: DrizzleClient,
  dealId: string,
  imageSet: ImageSet,
): Promise<void> {
  if (typeof imageSet.visualAxisOrder !== 'number') return;

  const axes = await tx
    .select({ id: dealVariantAxes.id, axisOrder: dealVariantAxes.axisOrder })
    .from(dealVariantAxes)
    .where(eq(dealVariantAxes.dealId, dealId));

  const visualAxis = axes.find((a) => a.axisOrder === imageSet.visualAxisOrder);
  if (!visualAxis) throw new Error('visualAxisOrder does not match any axis');

  const skuRows = await tx
    .select({
      hash: dealSkus.optionIdsHash,
      optionIds: dealSkus.optionIds,
    })
    .from(dealSkus)
    .where(eq(dealSkus.dealId, dealId));

  const options = await tx
    .select({ id: dealVariantOptions.id, axisId: dealVariantOptions.axisId })
    .from(dealVariantOptions)
    .where(eq(dealVariantOptions.axisId, visualAxis.id));

  const visualOptionIds = new Set(options.map((o) => o.id));

  // Build hash → visual-option-id map from DB rows.
  const hashToVisualOption = new Map<string, string>();
  for (const sku of skuRows) {
    const optIds = sku.optionIds as string[];
    const opt = optIds.find((id) => visualOptionIds.has(id));
    if (opt) hashToVisualOption.set(sku.hash, opt);
  }

  // Build optionIdByAxisAndCode so we can resolve set.optionValueCodes → hash.
  const axesAll = await tx
    .select({ id: dealVariantAxes.id, axisOrder: dealVariantAxes.axisOrder })
    .from(dealVariantAxes)
    .where(eq(dealVariantAxes.dealId, dealId));
  const axisByIdAll = new Map(axesAll.map((a) => [a.id, a.axisOrder]));

  const optionRowsAll =
    axesAll.length > 0
      ? await tx
          .select({
            id: dealVariantOptions.id,
            axisId: dealVariantOptions.axisId,
            valueCode: dealVariantOptions.valueCode,
          })
          .from(dealVariantOptions)
          .where(
            inArray(
              dealVariantOptions.axisId,
              axesAll.map((a) => a.id),
            ),
          )
      : [];

  const optionIdByAxisAndCodeAll = new Map<string, string>();
  for (const opt of optionRowsAll) {
    const axisOrder = axisByIdAll.get(opt.axisId);
    if (axisOrder === undefined) continue;
    optionIdByAxisAndCodeAll.set(`${axisOrder}|${opt.valueCode}`, opt.id);
  }

  const bucketKeys = new Map<string, string[]>();
  for (const set of imageSet.skus ?? []) {
    // Resolve optionValueCodes → hash to look up visualOpt.
    const optionIds = set.optionValueCodes.map((code, idx) =>
      optionIdByAxisAndCodeAll.get(`${idx}|${code}`),
    );
    if (optionIds.some((id) => id === undefined)) continue;
    const hash = hashOptionIds(optionIds as string[]);
    const visualOpt = hashToVisualOption.get(hash);
    if (!visualOpt) continue;
    const fingerprint = set.images
      .map((i) => i.r2Key)
      .sort()
      .join(',');
    const prior = bucketKeys.get(visualOpt);
    if (prior === undefined) {
      bucketKeys.set(visualOpt, [fingerprint]);
    } else if (!prior.includes(fingerprint)) {
      throw new Error(
        `visual-axis fan-out violated: SKUs sharing option ${visualOpt} have divergent image sets`,
      );
    }
  }
}
