/**
 * ImageApprovalKind — JobKind plugin for IMAGE_APPROVAL LLM jobs.
 *
 * Delegates AI call to UploadImageModerationAgent (agents/image-approval.ts).
 * Delegates cascade writes to image-moderation-cascade.ts helpers.
 *
 * Strangler-fig: registered in PR4. DEAL_MODERATION + VENDOR_VIOLATION still
 * go through dispatch.ts until PR5 adds their kinds.
 */
import { z } from 'zod';
import { eq, and } from 'drizzle-orm';
import type { DrizzleClient } from '@/server/db/client.js';
import { imageUploads } from '@/server/db/schema.js';
import {
  UploadImageModerationAgent,
  type ImageModerationDecision,
  type ImageModerationResult,
} from '@/server/ai/agents/image-approval.js';
import {
  markImageApproved,
  markImageFlagged,
  rejectImageWithCascade,
} from '@/server/admin/resources/image-moderation/cascade.js';
import type { JobKind, JobRunDeps, LlmJobRow, JobCompletionMeta } from './types.js';
import { PURPOSES, type ImagePurpose } from '@/lib/imageVariants.js';
import { recordAdminAction } from '@/server/db/queries/admin-actions.js';
import { flagUploadForHumanReview } from '@/server/db/queries/image-uploads.js';
import { insertOutboxRow } from '@/server/db/queries/outbox.js';

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

/** Input loaded from image_uploads row + R2 bytes. */
export interface ImageApprovalInput {
  imageId: string;
  imageBytes: ArrayBuffer;
  mime: string;
}

/** Result stored in output_payload after provider call. */
export interface ImageApprovalResult {
  decision: ImageModerationDecision; // 'PASS' | 'FLAG' | 'REJECT'
  reason: string | null;
  score: number | null;
  rawText: string;
  modelName: string;
  promptTokens: number | null;
  completionTokens: number | null;
  totalTokens: number | null;
  costUsd: number | null;
}

const ImageApprovalResultSchema = z.object({
  decision: z.enum(['PASS', 'FLAG', 'REJECT']),
  reason: z.string().nullable(),
  score: z.number().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(),
});

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

const MAX_IMAGE_BYTES = 10 * 1024 * 1024;

export const imageApprovalKind: JobKind<
  ImageApprovalInput,
  ImageApprovalResult,
  ImageApprovalResult
> = {
  jobType: 'IMAGE_APPROVAL',

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

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

    if (!deps.r2Bucket) throw new Error('R2_BUCKET binding required for IMAGE_APPROVAL');

    // Load image_uploads row to get r2Key + mime
    const [row] = await deps.db
      .select({
        r2Key: imageUploads.r2Key,
        mime: imageUploads.mime,
        approvalStatus: imageUploads.approvalStatus,
        purpose: imageUploads.purpose,
      })
      .from(imageUploads)
      .where(eq(imageUploads.id, imageId))
      .limit(1);

    if (!row) throw new Error(`image_uploads row not found: ${imageId}`);

    // T3 uploads: r2Key = originals/<sha>.<ext> — original not stored; fetch first variant.
    // Old-path uploads: r2Key = {scope}/{id}/{purpose}/{uploadId} — original still in R2.
    let fetchKey = row.r2Key;
    let fetchMime = row.mime;
    if (row.r2Key.startsWith('originals/')) {
      const sha = row.r2Key.slice('originals/'.length).replace(/\.[^.]+$/, '');
      const variants = PURPOSES[row.purpose as ImagePurpose]?.variants ?? [];
      const fv = variants.find((v) => v.format === 'webp') ?? variants[0];
      if (fv) {
        fetchKey = `variants/${sha}/${fv.variant}-${fv.width}.${fv.format}`;
        fetchMime =
          fv.format === 'webp' ? 'image/webp' : fv.format === 'avif' ? 'image/avif' : row.mime;
      }
    }

    // Fetch bytes from R2
    const r2Object = await deps.r2Bucket.get(fetchKey);
    if (!r2Object) throw new Error(`R2 object not found: ${fetchKey}`);

    const imageBytes = await r2Object.arrayBuffer();
    if (imageBytes.byteLength > MAX_IMAGE_BYTES) {
      throw new Error(`Image too large: ${imageBytes.byteLength} bytes (max ${MAX_IMAGE_BYTES})`);
    }

    return { imageId, imageBytes, mime: fetchMime };
  },

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

  async runProvider(
    deps: JobRunDeps,
    _jobRow: LlmJobRow,
    input: ImageApprovalInput,
  ): Promise<ImageApprovalResult> {
    // Construct agent from injected provider (PR2 pattern)
    const agent = UploadImageModerationAgent.create(deps.provider, deps.creds, deps.model);
    const result: ImageModerationResult = await agent.evaluate(input.imageBytes, input.mime);
    const usage = agent.lastUsage;
    return {
      decision: result.decision,
      reason: result.reason,
      score: result.score,
      rawText: result.rawText,
      modelName: result.modelName,
      promptTokens: usage?.promptTokens ?? null,
      completionTokens: usage?.completionTokens ?? null,
      totalTokens: usage?.totalTokens ?? null,
      costUsd: usage?.costUsd ?? null,
    };
  },

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

  hydrateResult(jobRow: LlmJobRow): ImageApprovalResult {
    const parsed = ImageApprovalResultSchema.safeParse(jobRow.outputPayload);
    if (!parsed.success) {
      throw new Error(`IMAGE_APPROVAL: 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: ImageApprovalResult,
  ): Promise<{ applied: boolean }> {
    const imageId = jobRow.targetId;

    // Status-guarded gate: only act if row is still PENDING.
    // Fetch current status to implement gate — cascade helpers do their own
    // writes so we gate here before delegating.
    const [row] = await deps.db
      .select({ approvalStatus: imageUploads.approvalStatus })
      .from(imageUploads)
      .where(and(eq(imageUploads.id, imageId), eq(imageUploads.approvalStatus, 'PENDING')))
      .limit(1);

    if (!row) {
      // Already in terminal state (concurrent runner or prior partial success)
      return { applied: false };
    }

    // Delegate to existing cascade helpers (they own the DB writes + outbox)
    if (result.decision === 'PASS') {
      await markImageApproved(deps.db, imageId);
    } else if (result.decision === 'FLAG') {
      await markImageFlagged(deps.db, imageId, result.reason);
    } else {
      // REJECT
      await rejectImageWithCascade(deps.db, imageId, result.reason);
    }

    return { applied: true };
  },

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

  async onRetryExhausted(db: DrizzleClient, jobRow: LlmJobRow, reason: string): Promise<void> {
    const imageId = jobRow.targetId;
    const flagReason = `AI review failed after retries — deferred to human: ${reason}`;

    // Self-disarming guard: FLAG only while still PENDING *and* not yet AI-decided.
    // The write sets aiDecision='FLAG', so the `isNull(aiDecision)` predicate disarms
    // its own guard — a backstop self-heal re-run (runJob re-enters onRetryExhausted
    // after a config-error escalate-first) matches 0 rows and short-circuits below,
    // never re-inserting the adminActions/outbox rows (neither table is unique-keyed,
    // so a re-run without this guard would silently duplicate the review-required
    // notification). In this path the AI never produced a verdict, so aiDecision is NULL.
    const updated = await flagUploadForHumanReview(db, imageId, flagReason);
    if (!updated) return;

    // TODO(atomicity, tracked debt): the disarming UPDATE above auto-commits (neon-http,
    // no enclosing tx) BEFORE the two inserts below. If a later insert throws on a backstop
    // re-run, isNull(aiDecision) now matches 0 rows → these side effects are lost. Inert
    // today (image.review_required has no registered handler; the image still surfaces via
    // the admin inbox query approval-queue.ts on aiDecision IN ('FLAG','ERROR')). Codebase-
    // wide pattern — deal-moderation.onRetryExhausted shares it. Fix = per-jobId outbox
    // dedup (see vendor-violation.onRetryExhausted) or order the disarming write LAST.
    // Audit log
    await recordAdminAction(db, {
      adminId: null,
      action: 'REJECT',
      targetType: 'IMAGE',
      targetId: imageId,
      note: flagReason,
    });

    // Outbox event so outbox consumer can surface review_required banner
    // Load uploader context for outbox aggregateId
    const [uploadRow] = await db
      .select({
        uploaderVendorId: imageUploads.uploaderVendorId,
        uploaderUserId: imageUploads.uploaderUserId,
        entityType: imageUploads.entityType,
        entityId: imageUploads.entityId,
        purpose: imageUploads.purpose,
      })
      .from(imageUploads)
      .where(eq(imageUploads.id, imageId))
      .limit(1);

    const ownerId = uploadRow?.uploaderVendorId ?? uploadRow?.uploaderUserId;
    const ownerType = uploadRow?.uploaderVendorId ? 'vendor' : 'user';

    if (ownerId) {
      await insertOutboxRow(db, {
        aggregateType: ownerType,
        aggregateId: ownerId,
        eventType: 'image.review_required',
        payload: {
          imageId,
          ownerType,
          ownerId,
          entityType: uploadRow?.entityType ?? null,
          entityId: uploadRow?.entityId ?? null,
          purpose: uploadRow?.purpose ?? null,
          reason: flagReason,
        },
      });
    }
  },

  extractCompletionMeta(result: unknown): JobCompletionMeta {
    const r = result as ImageApprovalResult;
    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,
    };
  },
};
