/**
 * DealModerationKind — JobKind plugin for DEAL_MODERATION LLM jobs.
 *
 * Strangler-fig: registered in PR5. Behavior-faithful port of
 * processDealModerationJob (llm.ts) + approveDeal/flagDealForHumanReview
 * (dispatch.ts) + escalateDealToHumanReview (process-llm-jobs.ts).
 *
 * Verdict mapping (faithful to legacy):
 *   APPROVE → deal_state=ACTIVE, approvedAt, approvedBy=AI_AGENT; deal_images→APPROVED
 *   FLAG    → deal_state=PENDING_APPROVAL; vendors.dealViolationsCount++
 *   (LLM never returns REJECT — parseDecision maps non-APPROVE to FLAG)
 *
 * applyVerdict: sequential status-guarded writes (neon-http has no interactive tx).
 * onRetryExhausted: escalate to PENDING_APPROVAL + outbox event.
 *
 * NOTE: No separate DealModerationAgent class exists (legacy is inline in
 * llm.ts). Prompt-building + provider call live directly in runProvider.
 * Extract to agents/deal-moderation.ts in PR6 cleanup if desired.
 */
import { z } from 'zod';
import { eq, asc, sql } from 'drizzle-orm';
import type { DrizzleClient } from '@/server/db/client.js';
import { deals, dealImages } from '@/server/db/schema.js';
import * as dealQ from '@/server/db/queries/ai/deal-moderation.js';
import { getLlmQueue } from '@/server/db/queries/llm-queues.js';
import { QUEUE_DEFAULT_PROMPTS } from '@/server/ai/queue-defaults.js';
import type { JobKind, JobRunDeps, LlmJobRow, JobCompletionMeta } from './types.js';
import { formatShekelFloat } from '@/lib/money.js';
import { enqueueDealTranslation } from '@/server/translation/jobs/enqueue.js';
import { invalidateCatalog } from '@/server/cache/invalidate.js';
import { recordAdminAction } from '@/server/db/queries/admin-actions.js';
import {
  setDealImageApprovalStatusForIds,
  setDealImagePrimary,
} from '@/server/db/queries/deal-images.js';
import { insertOutboxRow } from '@/server/db/queries/outbox.js';

// ─── Input / Result / Verdict shapes ─────────────────────────────────────────

export interface DealModerationInput {
  dealId: string;
  vendorId: string;
  title: string;
  description: string;
  category: string | null;
  dealType: string;
  originalPrice: string;
  discountedPrice: string | null;
  discountPercent: number;
  primaryImageKey: string | null;
  /** System prompt: DB queue prompt, or shared default if empty. */
  systemPrompt: string;
}

export interface DealModerationResult {
  decision: 'APPROVE' | 'FLAG';
  flagReason: string | null;
  rawText: string;
  modelName: string;
  promptTokens: number | null;
  completionTokens: number | null;
  totalTokens: number | null;
  costUsd: number | null;
}

export type DealModerationVerdict = DealModerationResult;

const DealModerationResultSchema = z.object({
  decision: z.enum(['APPROVE', 'FLAG']),
  flagReason: z.string().nullable(),
  rawText: z.string(),
  modelName: z.string(),
  promptTokens: z.number().nullable().optional(),
  completionTokens: z.number().nullable().optional(),
  totalTokens: z.number().nullable().optional(),
  costUsd: z.number().nullable().optional(),
});

// ─── Helpers ──────────────────────────────────────────────────────────────────

function parseDecision(text: string): { decision: 'APPROVE' | 'FLAG'; flagReason: string | null } {
  const upper = text.toUpperCase();
  if (upper.includes('DECISION: APPROVE')) {
    return { decision: 'APPROVE', flagReason: null };
  }
  // Any non-APPROVE response routes to human review (faithful to legacy).
  const reasonMatch = text.match(/REASON:\s*(.+)/i);
  const flagReason = reasonMatch?.[1]?.trim().slice(0, 500) ?? 'Flagged by AI moderator';
  return { decision: 'FLAG', flagReason };
}

// ─── Image-set helpers ────────────────────────────────────────────────────────

/**
 * Auto-promote the lowest-sortOrder APPROVED image to isPrimary when the
 * current primary image is not APPROVED (e.g. it was left PENDING after a FLAG
 * verdict or never received an APPROVED status). Operates per image-set bucket:
 * null skuId = deal-level gallery; non-null = per-SKU gallery.
 *
 * Must be called AFTER the applyVerdict transaction commits so that the
 * approvalStatus values written inside the transaction are visible.
 */
async function autoPromoteRejectedPrimary(deps: JobRunDeps, dealId: string): Promise<void> {
  const finalRows = await deps.db
    .select({
      id: dealImages.id,
      skuId: dealImages.skuId,
      isPrimary: dealImages.isPrimary,
      sortOrder: dealImages.sortOrder,
      approvalStatus: dealImages.approvalStatus,
    })
    .from(dealImages)
    .where(eq(dealImages.dealId, dealId))
    .orderBy(asc(dealImages.sortOrder));

  // Group by image-set bucket.
  const groups = new Map<string, typeof finalRows>();
  for (const r of finalRows) {
    const k = r.skuId ?? '__deal__';
    const arr = groups.get(k) ?? [];
    arr.push(r);
    groups.set(k, arr);
  }

  for (const group of groups.values()) {
    const primary = group.find((r) => r.isPrimary);
    // If there is no primary or it is already APPROVED, nothing to do.
    if (!primary || primary.approvalStatus === 'APPROVED') continue;

    // Promote the lowest sortOrder APPROVED non-primary row.
    const nextApproved = group.find((r) => !r.isPrimary && r.approvalStatus === 'APPROVED');
    if (!nextApproved) continue; // no approved fallback — leave as-is

    await setDealImagePrimary(deps.db, primary.id, false);
    await setDealImagePrimary(deps.db, nextApproved.id, true);
  }
}

// ─── Kind implementation ──────────────────────────────────────────────────────

export const dealModerationKind: JobKind<
  DealModerationInput,
  DealModerationResult,
  DealModerationVerdict
> = {
  jobType: 'DEAL_MODERATION',

  // ── loadInput ─────────────────────────────────────────────────────────────

  async loadInput(deps: JobRunDeps, jobRow: LlmJobRow): Promise<DealModerationInput> {
    const dealId = jobRow.targetId;
    if (!dealId) throw new Error('DEAL_MODERATION job missing targetId');

    // Load deal row for authoritative field values
    const [deal] = await deps.db
      .select({
        id: deals.id,
        vendorId: deals.vendorId,
        title: deals.title,
        description: deals.description,
        dealType: deals.dealType,
        originalPrice: sql<string>`(SELECT original_price FROM deal_skus WHERE deal_id = ${deals.id} AND option_ids_hash = 'default' LIMIT 1)`,
        discountPercent: sql<number>`(SELECT discount_percent FROM deal_skus WHERE deal_id = ${deals.id} AND option_ids_hash = 'default' LIMIT 1)`,
        dealState: deals.dealState,
      })
      .from(deals)
      .where(eq(deals.id, dealId))
      .limit(1);

    if (!deal) throw new Error(`DEAL_MODERATION: deal ${dealId} not found`);
    if (deal.dealState !== 'UNDER_REVIEW') {
      throw new Error(
        `DEAL_MODERATION: deal ${dealId} not in UNDER_REVIEW state (is ${deal.dealState})`,
      );
    }

    // Payload may carry pre-computed fields (primaryImageKey, category, discountedPrice)
    const payload = (jobRow.inputPayload ?? {}) as Record<string, unknown>;

    const queue = await getLlmQueue(deps.db, 'moderation');
    const systemPrompt = queue?.prompt || QUEUE_DEFAULT_PROMPTS.moderation;

    return {
      dealId: deal.id,
      vendorId: deal.vendorId,
      title: deal.title,
      description: deal.description,
      category: (payload.category as string | null | undefined) ?? null,
      dealType: deal.dealType,
      originalPrice: deal.originalPrice,
      discountedPrice: (payload.discountedPrice as string | null | undefined) ?? null,
      discountPercent: deal.discountPercent,
      primaryImageKey: (payload.primaryImageKey as string | null | undefined) ?? null,
      systemPrompt,
    };
  },

  // ── runProvider ───────────────────────────────────────────────────────────

  async runProvider(
    deps: JobRunDeps,
    _jobRow: LlmJobRow,
    input: DealModerationInput,
  ): Promise<DealModerationResult> {
    // Build content prompt (faithful to legacy processDealModerationJob)
    let contentPrompt = input.systemPrompt + '\n\n--- DEAL CONTENT ---\n';
    contentPrompt += `Title: ${input.title}\n`;
    if (input.description) contentPrompt += `Description: ${input.description}\n`;
    if (input.category) contentPrompt += `Category: ${input.category}\n`;
    contentPrompt += `Deal Type: ${input.dealType}\n`;
    contentPrompt += `Original Price: ${formatShekelFloat(input.originalPrice)}\n`;
    if (input.discountedPrice)
      contentPrompt += `Discounted Price: ${formatShekelFloat(input.discountedPrice)}\n`;
    contentPrompt += `Discount: ${input.discountPercent}%\n`;

    const modelId = deps.model;
    const {
      text: rawText,
      usage,
      costUsd,
      model,
    } = await deps.provider.generateText(deps.creds, {
      model: modelId,
      prompt: contentPrompt,
      kind: 'DEAL_MODERATION',
    });

    const { decision, flagReason } = parseDecision(rawText);
    const resolvedModel = model ?? modelId;

    return {
      decision,
      flagReason,
      rawText,
      modelName: resolvedModel,
      promptTokens: usage.promptTokens,
      completionTokens: usage.completionTokens,
      totalTokens: usage.totalTokens,
      costUsd,
    };
  },

  // ── hydrateResult ─────────────────────────────────────────────────────────

  hydrateResult(jobRow: LlmJobRow): DealModerationVerdict {
    const parsed = DealModerationResultSchema.safeParse(jobRow.outputPayload);
    if (!parsed.success) {
      throw new Error(`DEAL_MODERATION: cannot hydrate output_payload: ${parsed.error.message}`);
    }
    const data = parsed.data;
    return {
      ...data,
      promptTokens: data.promptTokens ?? null,
      completionTokens: data.completionTokens ?? null,
      totalTokens: data.totalTokens ?? null,
      costUsd: data.costUsd ?? null,
    };
  },

  // ── applyVerdict ──────────────────────────────────────────────────────────

  async applyVerdict(
    deps: JobRunDeps,
    jobRow: LlmJobRow,
    result: DealModerationVerdict,
  ): Promise<{ applied: boolean }> {
    const dealId = jobRow.targetId;

    // Load all deal_images rows for this deal, deduplicated by URL so that
    // shared R2 URLs (visual-axis fan-out) are updated atomically in one pass.
    const allImageRows = await deps.db
      .select({
        id: dealImages.id,
        url: dealImages.url,
        skuId: dealImages.skuId,
      })
      .from(dealImages)
      .where(eq(dealImages.dealId, dealId));

    // Group row IDs by URL — one verdict fans out to every duplicate row.
    const byUrl = new Map<string, { rowIds: string[]; sampleSkuId: string | null }>();
    for (const r of allImageRows) {
      const prior = byUrl.get(r.url);
      if (prior) {
        prior.rowIds.push(r.id);
      } else {
        byUrl.set(r.url, { rowIds: [r.id], sampleSkuId: r.skuId });
      }
    }

    let outcome: { applied: boolean };

    if (result.decision === 'APPROVE') {
      // Status-guarded UPDATE: only flip from UNDER_REVIEW
      const updated = await dealQ.approve(deps.db, dealId);

      if (updated.length === 0) {
        outcome = { applied: false };
      } else {
        await invalidateCatalog(deps.db, { scope: 'deal', dealId });
        // Approve all distinct-URL groups (fan-out: one UPDATE per unique URL
        // covers all duplicate rows sharing that URL in a single WHERE … IN).
        for (const { rowIds } of byUrl.values()) {
          await setDealImageApprovalStatusForIds(deps.db, rowIds, 'APPROVED');
        }

        await recordAdminAction(deps.db, {
          adminId: null,
          action: 'APPROVE',
          targetType: 'DEAL',
          targetId: dealId,
          note: 'AI_AGENT approved deal after moderation',
        });

        await insertOutboxRow(deps.db, {
          aggregateType: 'deal',
          aggregateId: dealId,
          eventType: 'deal.approved',
          payload: { dealId, approvedBy: 'AI_AGENT' },
        });

        outcome = { applied: true };

        // After state writes — enqueue translation (non-fatal; no nested tx)
        await enqueueDealTranslation(deps.db, dealId).catch((e: unknown) =>
          console.error('[deal-moderation] enqueueDealTranslation failed:', e),
        );
      }
    } else {
      // FLAG → PENDING_APPROVAL (human review queue)
      const updated = await dealQ.flag(deps.db, dealId);

      if (updated.length === 0) {
        outcome = { applied: false };
      } else {
        const vendorId = updated[0]!.vendorId;

        await dealQ.incrementVendorViolations(deps.db, vendorId);

        await recordAdminAction(deps.db, {
          adminId: null,
          action: 'REJECT',
          targetType: 'DEAL',
          targetId: dealId,
          note: `AI_AGENT flagged deal for human review: ${result.flagReason ?? 'no reason'}`,
        });

        await insertOutboxRow(deps.db, {
          aggregateType: 'deal',
          aggregateId: dealId,
          eventType: 'deal.flagged',
          payload: { dealId, vendorId, reason: result.flagReason },
        });

        outcome = { applied: true };
      }
    }

    // Post-write: auto-promote rejected primary images.
    if (outcome.applied) {
      await autoPromoteRejectedPrimary(deps, dealId);
    }

    return outcome;
  },

  // ── onRetryExhausted ──────────────────────────────────────────────────────

  async onRetryExhausted(db: DrizzleClient, jobRow: LlmJobRow, reason: string): Promise<void> {
    const dealId = jobRow.targetId;

    // Status-guarded escalation (idempotent)
    const updated = await dealQ.escalate(db, dealId);

    if (updated.length === 0) return; // already actioned

    const vendorId = updated[0]!.vendorId;

    // Outbox event so admin is notified
    await insertOutboxRow(db, {
      aggregateType: 'deal',
      aggregateId: dealId,
      eventType: 'deal.review_required',
      payload: { dealId, vendorId, reason },
    });
  },

  extractCompletionMeta(result: unknown): JobCompletionMeta {
    const r = result as DealModerationResult;
    return {
      modelName: r.modelName,
      promptTokens: r.promptTokens ?? undefined,
      completionTokens: r.completionTokens ?? undefined,
      totalTokens: r.totalTokens ?? undefined,
      costUsd: r.costUsd != null ? r.costUsd.toFixed(6) : undefined,
    };
  },
};
