/**
 * Translation Jobs Routes
 *
 * Asynchronous translation job management (for content up to 50000 characters)
 */

import { Router, Request, Response } from 'express';
import { z } from 'zod';
import { LanguageCodeSchema, ToneSchema } from '../types';
import { authenticateApiKey } from '../middleware/auth';
import { apiRateLimiter } from '../middleware/rateLimiter';
import { validate, validateParams, validateQuery } from '../middleware/validator';
import { transformWordPressJobPayload } from '../middleware/payloadTransform';
import { PrismaClient } from '@prisma/client';
import { translationService } from '../services/translationService';
import { ExceptionRulesSchema } from '../services/exceptionService';
import { hasSufficientCredits, getCurrentBalance } from '../services/creditService';
import { countSourceCharacters } from '../utils/tokenCalculation';
import { StoredBulkQueuePayload, drainBulkQueue } from '../queue/bulkQueueDispatcher';
import { logger } from '../utils/logger';
import { config } from '../config';
import { mergeStructuredFields, countStructuredCharacters, STRUCTURED_FIELD_KEY_PATTERN } from '../utils/structuredFields';
import crypto from 'crypto';
import {
  SITE_CONTENT_CONTRACT_VERSION,
  SITE_CONTENT_MAX_SEGMENTS,
  validateSiteContentRequest,
} from '../services/siteContentTranslation';

const prisma = new PrismaClient();

const router = Router();

// Validation schemas
const legacyJobSubmitSchema = z.object({
  sourceLang: LanguageCodeSchema,
  targetLang: LanguageCodeSchema,
  title: z.string().optional(),
  excerpt: z.string().optional(),
  content: z.string().optional(),
  fields: z.record(
    z.string().regex(STRUCTURED_FIELD_KEY_PATTERN, 'Invalid structured field key'),
    z.string()
  ).optional(),
  tone: ToneSchema.optional(),
  exceptions: ExceptionRulesSchema,
  callbackUrl: z.string().url().optional(),
  callbackSecret: z.string().optional(),
  clientJobId: z.string().optional(),
  resourceType: z.never().optional(),
  segments: z.never().optional(),
}).superRefine((data, ctx) => {
  if (data.fields !== undefined && Object.keys(data.fields).length === 0 && !data.title && !data.excerpt && !data.content) {
    ctx.addIssue({ code: z.ZodIssueCode.custom, message: 'At least one translatable field must be provided' });
    return;
  }

  if (!data.title && !data.excerpt && !data.content && !data.fields) {
    ctx.addIssue({ code: z.ZodIssueCode.custom, message: 'At least one of title, excerpt, content, or fields must be provided' });
    return;
  }

  try {
    const mergedFields = mergeStructuredFields(
      { title: data.title, excerpt: data.excerpt, content: data.content },
      data.fields,
      config.maxAsyncChars
    );
    if (countStructuredCharacters(mergedFields) === 0) {
      ctx.addIssue({ code: z.ZodIssueCode.custom, message: 'Content cannot be empty.' });
    }
  } catch (error) {
    ctx.addIssue({
      code: z.ZodIssueCode.custom,
      path: ['fields'],
      message: error instanceof Error ? error.message : 'Invalid structured fields',
    });
  }
});

export const siteContentJobSubmitSchema = z.object({
  resourceType: z.literal('site_content'),
  contractVersion: z.literal(SITE_CONTENT_CONTRACT_VERSION),
  sourceLang: LanguageCodeSchema,
  targetLang: LanguageCodeSchema,
  sourceRevision: z.string().regex(/^[a-f0-9]{64}$/),
  attemptToken: z.string().regex(/^[a-f0-9]{64}$/),
  segments: z.array(z.object({
    id: z.string().regex(/^seg_[A-Za-z0-9_-]{32}$/),
    text: z.string().min(1),
    context: z.record(z.string(), z.unknown()),
  }).strict()).min(1).max(SITE_CONTENT_MAX_SEGMENTS),
  tone: ToneSchema.optional(),
  exceptions: ExceptionRulesSchema,
  callbackUrl: z.string().url(),
  callbackSecret: z.string().min(16).max(256),
  clientJobId: z.string().min(1).max(255),
}).strict().superRefine((data, ctx) => {
  try {
    validateSiteContentRequest(data, config.maxAsyncChars);
  } catch (error) {
    ctx.addIssue({
      code: 'custom',
      message: error instanceof Error ? error.message : 'Invalid segmented Site Content request',
    });
  }
});

export const jobSubmitSchema = z.union([siteContentJobSubmitSchema, legacyJobSubmitSchema]);

const jobIdParamSchema = z.object({
  jobId: z.string().uuid('Invalid job ID format'),
});

const jobsListQuerySchema = z.object({
  status: z.string().optional(),
  clientJobId: z.string().min(1).max(255).optional(),
  limit: z.coerce.number().min(1).max(100).optional(),
  offset: z.coerce.number().min(0).optional(),
  sortBy: z.enum(['created_at', 'updated_at']).optional(),
  sortOrder: z.enum(['asc', 'desc']).optional(),
});

/**
 * POST /v1/jobs
 * Submit asynchronous translation job (up to 50000 characters)
 *
 * @requires API Key authentication
 * @requires Rate limiting based on subscription tier
 */
router.post(
  '/',
  transformWordPressJobPayload,
  authenticateApiKey,
  apiRateLimiter,
  validate(jobSubmitSchema),
  async (req: Request, res: Response) => {
    try {
      const userId = req.user!.userId;
      const jobRequest = req.body;

      logger.info('Async job submission request received', {
        userId,
        sourceLang: jobRequest.sourceLang,
        targetLang: jobRequest.targetLang,
        contentLength: jobRequest.content?.length || 0,
        fieldCount: jobRequest.fields ? Object.keys(jobRequest.fields).length : 0,
        resourceType: jobRequest.resourceType,
        segmentCount: Array.isArray(jobRequest.segments) ? jobRequest.segments.length : 0,
        hasCallback: !!jobRequest.callbackUrl,
      });

      // Call translation service
      const result = await translationService.translateAsync(
        userId,
        jobRequest,
        req.user!.plugin,
        req.siteUrl
      );

      return res.status(202).json({
        success: true,
        data: result,
        timestamp: new Date().toISOString(),
      });
    } catch (error) {
      logger.error('Async job submission error', {
        error,
        userId: req.user?.userId,
        requestId: req.requestId,
      });

      const errorMessage = error instanceof Error ? error.message : 'Unknown error occurred';

      // Handle insufficient credits error
      if (errorMessage.includes('Insufficient credits')) {
        return res.status(402).json({
          error: true,
          code: 'INSUFFICIENT_CREDITS',
          message: 'Translation job not found.',
          timestamp: new Date().toISOString(),
        });
      }

      // Handle content too long error
      if (errorMessage.includes('Content too long')) {
        return res.status(413).json({
          error: true,
          code: 'CONTENT_TOO_LONG',
          message: errorMessage,
          timestamp: new Date().toISOString(),
        });
      }

      // Generic error
      return res.status(500).json({
        error: true,
        code: 'INTERNAL_ERROR',
        message: 'An internal error occurred while submitting the job',
        timestamp: new Date().toISOString(),
      });
    }
  }
);

/**
 * GET /v1/jobs/:jobId
 * Get translation job status and result
 *
 * @requires API Key authentication
 */
router.get(
  '/:jobId',
  authenticateApiKey,
  validateParams(jobIdParamSchema),
  async (req: Request, res: Response) => {
    try {
      const userId = req.user!.userId;
      const { jobId } = req.params;

      logger.info('Job status request received', {
        userId,
        jobId,
      });

      // Get job status
      const result = await translationService.getJobStatus(userId, jobId);

      // For external API, expose customerCost as cost
      const { customerCost, cost: _internalCost, ...rest } = result;
      const externalResult = { ...rest, cost: customerCost ?? _internalCost };

      return res.status(200).json({
        success: true,
        data: externalResult,
        timestamp: new Date().toISOString(),
      });
    } catch (error) {
      logger.error('Job status retrieval error', {
        error,
        userId: req.user?.userId,
        jobId: req.params.jobId,
        requestId: req.requestId,
      });

      const errorMessage = error instanceof Error ? error.message : 'Unknown error occurred';

      // Handle not found error
      if (errorMessage.includes('not found')) {
        return res.status(404).json({
          error: true,
          code: 'JOB_NOT_FOUND',
          message: errorMessage,
          timestamp: new Date().toISOString(),
        });
      }

      // Handle access denied error
      if (errorMessage.includes('Access denied')) {
        return res.status(403).json({
          error: true,
          code: 'ACCESS_DENIED',
          message: errorMessage,
          timestamp: new Date().toISOString(),
        });
      }

      // Generic error
      return res.status(500).json({
        error: true,
        code: 'INTERNAL_ERROR',
        message: 'An internal error occurred while retrieving the job',
        timestamp: new Date().toISOString(),
      });
    }
  }
);

/**
 * POST /v1/jobs/:jobId/cancel
 * Cancel a pending translation job
 *
 * @requires API Key authentication
 */
router.post(
  '/:jobId/cancel',
  authenticateApiKey,
  validateParams(jobIdParamSchema),
  async (req: Request, res: Response) => {
    try {
      const userId = req.user!.userId;
      const { jobId } = req.params;
      if (!req.siteUrl) {
        return res.status(403).json({
          error: true,
          code: 'SITE_SCOPE_REQUIRED',
          message: 'Authenticated site scope is required to cancel a job.',
          timestamp: new Date().toISOString(),
        });
      }

      logger.info('Job cancellation request received', {
        userId,
        jobId,
      });

      // Cancel job
      const result = await translationService.cancelJob(userId, jobId, {
        plugin: req.user!.plugin,
        siteUrl: req.siteUrl,
      });

      return res.status(200).json({
        success: true,
        data: result,
        message: 'Job cancelled successfully',
        timestamp: new Date().toISOString(),
      });
    } catch (error) {
      logger.error('Job cancellation error', {
        error,
        userId: req.user?.userId,
        jobId: req.params.jobId,
        requestId: req.requestId,
      });

      const errorMessage = error instanceof Error ? error.message : 'Unknown error occurred';

      // Handle not found error
      if (errorMessage.includes('not found')) {
        return res.status(404).json({
          error: true,
          code: 'JOB_NOT_FOUND',
          message: errorMessage,
          timestamp: new Date().toISOString(),
        });
      }

      // Handle access denied error
      if (errorMessage.includes('Access denied')) {
        return res.status(403).json({
          error: true,
          code: 'ACCESS_DENIED',
          message: errorMessage,
          timestamp: new Date().toISOString(),
        });
      }

      // Handle cannot cancel error
      if (errorMessage.includes('Cannot cancel')) {
        return res.status(400).json({
          error: true,
          code: 'CANNOT_CANCEL',
          message: errorMessage,
          timestamp: new Date().toISOString(),
        });
      }

      // Generic error
      return res.status(500).json({
        error: true,
        code: 'INTERNAL_ERROR',
        message: 'An internal error occurred while cancelling the job',
        timestamp: new Date().toISOString(),
      });
    }
  }
);

/**
 * GET /v1/jobs
 * List translation jobs for the authenticated user
 *
 * @requires API Key authentication
 */
router.get(
  '/',
  authenticateApiKey,
  validateQuery(jobsListQuerySchema),
  async (req: Request, res: Response) => {
    try {
      const userId = req.user!.userId;
      const { status, clientJobId, limit, offset, sortBy, sortOrder } = req.query as any;

      logger.info('Jobs list request received', {
        userId,
        status,
        clientJobId,
        limit,
        offset,
      });

      // Parse status (can be comma-separated)
      let statusFilter: any = undefined;
      if (status) {
        const statuses = status.split(',').map((s: string) => s.trim());
        statusFilter = statuses.length > 1 ? statuses : statuses[0];
      }

      // Get jobs
      const result = await translationService.getJobs(userId, {
        status: statusFilter,
        clientJobId,
        siteUrl: req.siteUrl,
        plugin: req.user!.plugin,
        limit: limit ? parseInt(limit) : undefined,
        offset: offset ? parseInt(offset) : undefined,
        sortBy: sortBy as any,
        sortOrder: sortOrder as any,
      });

      // For external API, expose customerCost as cost
      const externalJobs = result.jobs.map(({ customerCost, cost: internalCost, ...rest }) => ({
        ...rest,
        cost: customerCost ?? internalCost,
      }));

      return res.status(200).json({
        success: true,
        data: externalJobs,
        pagination: {
          total: result.total,
          limit: result.limit,
          offset: result.offset,
          hasMore: result.offset + result.jobs.length < result.total,
        },
        timestamp: new Date().toISOString(),
      });
    } catch (error) {
      logger.error('Jobs list retrieval error', {
        error,
        userId: req.user?.userId,
        requestId: req.requestId,
      });

      // Generic error
      return res.status(500).json({
        error: true,
        code: 'INTERNAL_ERROR',
        message: 'An internal error occurred while retrieving jobs',
        timestamp: new Date().toISOString(),
      });
    }
  }
);

/**
 * DELETE /v1/jobs/:jobId
 * Delete a translation job (only allowed for completed/failed/cancelled jobs)
 *
 * @requires API Key authentication
 */
router.delete(
  '/:jobId',
  authenticateApiKey,
  validateParams(jobIdParamSchema),
  async (req: Request, res: Response) => {
    try {
      const userId = req.user!.userId;
      const { jobId } = req.params;

      logger.info('Job deletion request received', {
        userId,
        jobId,
      });

      // Get job to check status
      const job = await translationService.getJobStatus(userId, jobId);

      // Only allow deletion of terminal state jobs
      if (job.status !== 'completed' && job.status !== 'failed' && job.status !== 'cancelled') {
        return res.status(400).json({
          error: true,
          code: 'CANNOT_DELETE',
          message: `Cannot delete job in ${job.status} status. Only completed, failed, or cancelled jobs can be deleted.`,
          timestamp: new Date().toISOString(),
        });
      }

      // Delete job (soft delete by updating status to 'deleted')
      // Note: Actual implementation would call a deleteJob method
      logger.warn('Job deletion not yet fully implemented - returning success for terminal state job', {
        userId,
        jobId,
        status: job.status,
      });

      return res.status(200).json({
        success: true,
        message: 'Job deleted successfully',
        timestamp: new Date().toISOString(),
      });
    } catch (error) {
      logger.error('Job deletion error', {
        error,
        userId: req.user?.userId,
        jobId: req.params.jobId,
        requestId: req.requestId,
      });

      const errorMessage = error instanceof Error ? error.message : 'Unknown error occurred';

      if (errorMessage.includes('not found')) {
        return res.status(404).json({
          error: true,
          code: 'JOB_NOT_FOUND',
          message: errorMessage,
          timestamp: new Date().toISOString(),
        });
      }

      if (errorMessage.includes('Access denied')) {
        return res.status(403).json({
          error: true,
          code: 'ACCESS_DENIED',
          message: errorMessage,
          timestamp: new Date().toISOString(),
        });
      }

      return res.status(500).json({
        error: true,
        code: 'INTERNAL_ERROR',
        message: 'An internal error occurred while deleting the job',
        timestamp: new Date().toISOString(),
      });
    }
  }
);

/**
 * GET /v1/jobs/:jobId/result
 * Get only the translation result for a completed job
 *
 * @requires API Key authentication
 */
router.get(
  '/:jobId/result',
  authenticateApiKey,
  validateParams(jobIdParamSchema),
  async (req: Request, res: Response) => {
    try {
      const userId = req.user!.userId;
      const { jobId } = req.params;

      logger.info('Job result request received', {
        userId,
        jobId,
      });

      // Get job status
      const job = await translationService.getJobStatus(userId, jobId);

      // Check if job is completed
      if (job.status !== 'completed') {
        return res.status(400).json({
          error: true,
          code: 'JOB_NOT_COMPLETED',
          message: `Job is in ${job.status} status. Only completed jobs have results.`,
          timestamp: new Date().toISOString(),
        });
      }

      // Return just the translation result
      return res.status(200).json({
        success: true,
        data: {
          jobId: job.jobId,
          translation: job.translation,
          translatedTitle: job.translatedTitle,
          translatedExcerpt: job.translatedExcerpt,
          translatedContent: job.translatedContent,
          translatedFields: job.translatedFields,
          sourceLang: job.sourceLang,
          targetLang: job.targetLang,
          charactersUsed: job.charactersUsed,
          cost: job.customerCost ?? job.cost,
          completedAt: job.completedAt,
        },
        timestamp: new Date().toISOString(),
      });
    } catch (error) {
      logger.error('Job result retrieval error', {
        error,
        userId: req.user?.userId,
        jobId: req.params.jobId,
        requestId: req.requestId,
      });

      const errorMessage = error instanceof Error ? error.message : 'Unknown error occurred';

      if (errorMessage.includes('not found')) {
        return res.status(404).json({
          error: true,
          code: 'JOB_NOT_FOUND',
          message: errorMessage,
          timestamp: new Date().toISOString(),
        });
      }

      if (errorMessage.includes('Access denied')) {
        return res.status(403).json({
          error: true,
          code: 'ACCESS_DENIED',
          message: errorMessage,
          timestamp: new Date().toISOString(),
        });
      }

      return res.status(500).json({
        error: true,
        code: 'INTERNAL_ERROR',
        message: 'An internal error occurred while retrieving the job result',
        timestamp: new Date().toISOString(),
      });
    }
  }
);

/**
 * POST /v1/jobs/:jobId/retry
 * Retry a failed translation job
 *
 * @requires API Key authentication
 */
router.post(
  '/:jobId/retry',
  authenticateApiKey,
  validateParams(jobIdParamSchema),
  async (req: Request, res: Response) => {
    try {
      const userId = req.user!.userId;
      const { jobId } = req.params;

      logger.info('Job retry request received', {
        userId,
        jobId,
      });

      // Retry job
      const result = await translationService.retryJob(userId, jobId);

      return res.status(200).json({
        success: true,
        data: result,
        message: 'Job queued for retry',
        timestamp: new Date().toISOString(),
      });
    } catch (error) {
      logger.error('Job retry error', {
        error,
        userId: req.user?.userId,
        jobId: req.params.jobId,
        requestId: req.requestId,
      });

      const errorMessage = error instanceof Error ? error.message : 'Unknown error occurred';

      // Handle not found error
      if (errorMessage.includes('not found')) {
        return res.status(404).json({
          error: true,
          code: 'JOB_NOT_FOUND',
          message: errorMessage,
          timestamp: new Date().toISOString(),
        });
      }

      // Handle access denied error
      if (errorMessage.includes('Access denied')) {
        return res.status(403).json({
          error: true,
          code: 'ACCESS_DENIED',
          message: errorMessage,
          timestamp: new Date().toISOString(),
        });
      }

      // Handle cannot retry error
      if (errorMessage.includes('Cannot retry')) {
        return res.status(400).json({
          error: true,
          code: 'CANNOT_RETRY',
          message: errorMessage,
          timestamp: new Date().toISOString(),
        });
      }

      // Generic error
      return res.status(500).json({
        error: true,
        code: 'INTERNAL_ERROR',
        message: 'An internal error occurred while retrying the job',
        timestamp: new Date().toISOString(),
      });
    }
  }
);

/**
 * GET /v1/jobs/:jobId/status/stream
 * Stream job status updates using Server-Sent Events (SSE)
 *
 * @requires API Key authentication
 */
router.get(
  '/:jobId/status/stream',
  authenticateApiKey,
  validateParams(jobIdParamSchema),
  async (req: Request, res: Response): Promise<void> => {
    try {
      const userId = req.user!.userId;
      const { jobId } = req.params;

      logger.info('Job status stream request received', {
        userId,
        jobId,
      });

      // Verify job exists and belongs to user
      const initialStatus = await translationService.getJobStatus(userId, jobId);

      // Set up SSE headers
      res.setHeader('Content-Type', 'text/event-stream');
      res.setHeader('Cache-Control', 'no-cache');
      res.setHeader('Connection', 'keep-alive');
      res.setHeader('X-Accel-Buffering', 'no'); // Disable nginx buffering

      // For external API, expose customerCost as cost
      const { customerCost: initCC, cost: initIC, ...initRest } = initialStatus;
      const externalInitial = { ...initRest, cost: initCC ?? initIC };

      // Send initial status
      res.write(`data: ${JSON.stringify(externalInitial)}\n\n`);

      // If job is already in a terminal state, close the connection
      if (
        initialStatus.status === 'completed' ||
        initialStatus.status === 'failed' ||
        initialStatus.status === 'cancelled'
      ) {
        logger.info('Job already in terminal state, closing stream', {
          userId,
          jobId,
          status: initialStatus.status,
        });
        res.end();
        return;
      }

      // Poll for updates every 2 seconds
      const pollInterval = setInterval(async () => {
        try {
          const currentStatus = await translationService.getJobStatus(userId, jobId);

          // For external API, expose customerCost as cost
          const { customerCost: curCC, cost: curIC, ...curRest } = currentStatus;
          const externalCurrent = { ...curRest, cost: curCC ?? curIC };

          // Send update
          res.write(`data: ${JSON.stringify(externalCurrent)}\n\n`);

          // If job reached terminal state, close connection
          if (
            currentStatus.status === 'completed' ||
            currentStatus.status === 'failed' ||
            currentStatus.status === 'cancelled'
          ) {
            logger.info('Job reached terminal state, closing stream', {
              userId,
              jobId,
              status: currentStatus.status,
            });
            clearInterval(pollInterval);
            res.end();
          }
        } catch (error) {
          logger.error('Error polling job status in stream', {
            error,
            userId,
            jobId,
          });
          clearInterval(pollInterval);
          res.write(`data: ${JSON.stringify({ error: 'Failed to poll job status' })}\n\n`);
          res.end();
        }
      }, 2000);

      // Clean up on client disconnect
      req.on('close', () => {
        logger.info('Client disconnected from job status stream', {
          userId,
          jobId,
        });
        clearInterval(pollInterval);
        res.end();
      });
    } catch (error) {
      logger.error('Job status stream error', {
        error,
        userId: req.user?.userId,
        jobId: req.params.jobId,
        requestId: req.requestId,
      });

      const errorMessage = error instanceof Error ? error.message : 'Unknown error occurred';

      // Handle not found error
      if (errorMessage.includes('not found')) {
        res.status(404).json({
          error: true,
          code: 'JOB_NOT_FOUND',
          message: errorMessage,
          timestamp: new Date().toISOString(),
        });
        return;
      }

      // Handle access denied error
      if (errorMessage.includes('Access denied')) {
        res.status(403).json({
          error: true,
          code: 'ACCESS_DENIED',
          message: errorMessage,
          timestamp: new Date().toISOString(),
        });
        return;
      }

      // Generic error
      res.status(500).json({
        error: true,
        code: 'INTERNAL_ERROR',
        message: 'An internal error occurred while streaming job status',
        timestamp: new Date().toISOString(),
      });
    }
  }
);

// Validation schema for async bulk-strings job
export const bulkStringsJobSchema = z.object({
  sourceLang: LanguageCodeSchema,
  targetLangs: z.array(LanguageCodeSchema).min(1).max(20),
  strings: z.array(z.object({
    id: z.string().min(1),
    content: z.string().min(1),
  })).min(1).max(10000, 'Bulk strings job supports at most 10,000 strings'),
  callbackUrl: z.string().url(),
  callbackSecret: z.string().min(16),
  tone: ToneSchema.optional(),
  exceptions: ExceptionRulesSchema,
  clientJobId: z.string().optional(),
});

type BulkStringsJobRequest = z.infer<typeof bulkStringsJobSchema>;

type BulkStringsSubmission = BulkStringsJobRequest & {
  userId: string;
  plugin: string;
  siteUrl?: string;
};

function bulkStringsIdentity(submission: BulkStringsSubmission): { content: string; contentHash: string; identity: string } {
  const strings = submission.strings.map(({ id, content }) => ({
    id: id.normalize('NFC'),
    content: content.normalize('NFC'),
  }));
  const targetLangs = submission.targetLangs.map((targetLang) => targetLang.trim().toLowerCase());
  const tone = submission.tone || 'neutral';
  const content = JSON.stringify(strings);
  const callbackSecretFingerprint = crypto.createHash('sha256')
    .update(submission.callbackSecret)
    .digest('hex');
  const identity = JSON.stringify({
    callback_secret_fingerprint: callbackSecretFingerprint,
    callback_url: submission.callbackUrl,
    client_job_id: submission.clientJobId ?? null,
    site_url: submission.siteUrl ?? null,
    input: strings,
    model: 'gemini-3.1-flash-lite',
    plugin: submission.plugin,
    source_lang: submission.sourceLang.trim().toLowerCase(),
    target_langs: targetLangs,
    tone,
    user_id: submission.userId,
  });
  return {
    content,
    contentHash: crypto.createHash('sha256').update(identity).digest('hex'),
    identity: crypto.createHash('sha256').update(identity).digest('hex'),
  };
}

function bulkQueuePayload(jobId: string, submission: BulkStringsSubmission): StoredBulkQueuePayload {
  return {
    type: 'bulk-strings',
    jobId,
    clientJobId: submission.clientJobId ?? null,
    userId: submission.userId,
    plugin: submission.plugin,
    strings: submission.strings.map(({ id, content }) => ({ id, content })),
    sourceLang: submission.sourceLang,
    targetLangs: [...submission.targetLangs],
    tone: submission.tone || 'neutral',
    exceptions: submission.exceptions ?? [],
    callbackUrl: submission.callbackUrl,
  };
}

export async function submitBulkStringsJob(submission: BulkStringsSubmission): Promise<any> {
  const { content, contentHash, identity } = bulkStringsIdentity(submission);
  const jobClaim = await prisma.$transaction(async (tx) => {
    await tx.$executeRaw`SELECT pg_advisory_xact_lock(hashtextextended(${identity}, 0))`;
    const existing = await tx.translationJob.findFirst({
      where: {
        user_id: submission.userId,
        plugin: submission.plugin,
        content_hash: contentHash,
        source_lang: submission.sourceLang,
        target_lang: submission.targetLangs[0] || 'xx',
        tone: submission.tone || 'neutral',
        status: { in: ['pending', 'processing'] },
        client_job_id: submission.clientJobId ?? null,
        callback_url: submission.callbackUrl,
        callback_secret: submission.callbackSecret,
        site_url: submission.siteUrl ?? null,
      },
    });
    if (existing) {
      return { job: existing, created: false };
    }
    const jobId = crypto.randomUUID();
    const job = await tx.translationJob.create({
      data: {
        id: jobId,
        user_id: submission.userId,
        client_job_id: submission.clientJobId,
        plugin: submission.plugin,
        status: 'pending',
        source_lang: submission.sourceLang,
        target_lang: submission.targetLangs[0] || 'xx',
        tone: submission.tone || 'neutral',
        content,
        content_hash: contentHash,
        callback_url: submission.callbackUrl,
        callback_secret: submission.callbackSecret,
        site_url: submission.siteUrl ?? null,
        queue_payload: bulkQueuePayload(jobId, submission),
      },
    });
    return { job, created: true };
  });

  try {
    await drainBulkQueue(jobClaim.job.id);
  } catch (error) {
    logger.error('Immediate bulk queue dispatch failed', { error, jobId: jobClaim.job.id });
  }
  return jobClaim.job;
}

/**
 * POST /v1/jobs/bulk-strings
 * Queue an async bulk-strings translation job.
 * The worker processes all strings in batches of 50 per Gemini call, once per target language,
 * then delivers a single webhook with full results.
 *
 * @requires API Key authentication
 */
router.post(
  '/bulk-strings',
  authenticateApiKey,
  apiRateLimiter,
  validate(bulkStringsJobSchema),
  async (req: Request, res: Response) => {
    try {
      const userId = req.user!.userId;
      const { strings, sourceLang, targetLangs, callbackUrl, callbackSecret, tone } = req.body;

      // Total characters = per-string chars × number of target languages
      const totalCharacters = strings.reduce(
        (sum: number, s: { content: string }) => sum + countSourceCharacters(s.content),
        0
      ) * targetLangs.length;

      logger.info('Async bulk-strings job submission received', {
        userId,
        stringCount: strings.length,
        targetLangs,
        totalCharacters,
      });

      // Fail fast if insufficient credits
      const sufficient = await hasSufficientCredits(userId, totalCharacters);
      const currentBalance = await getCurrentBalance(userId);

      if (!sufficient) {
        return res.status(402).json({
          error: true,
          code: 'INSUFFICIENT_CREDITS',
          message:
            `Insufficient credits. You need ${totalCharacters} credits but only have ${currentBalance}. ` +
            `Please upgrade your plan or wait for your monthly allocation.`,
          timestamp: new Date().toISOString(),
        });
      }

      const job = await submitBulkStringsJob({
        userId,
        plugin: req.user!.plugin,
        strings,
        sourceLang,
        targetLangs,
        callbackUrl,
        callbackSecret,
        tone,
        clientJobId: req.body.clientJobId,
        siteUrl: req.siteUrl,
      });

      logger.info('Async bulk-strings job queued', { userId, jobId: job.id, stringCount: strings.length });

      return res.status(202).json({
        success: true,
        job_id: job.id,
        status: 'queued',
        total_strings: strings.length,
        target_langs: targetLangs,
        timestamp: new Date().toISOString(),
      });
    } catch (error) {
      logger.error('Async bulk-strings job submission error', {
        error,
        userId: req.user?.userId,
        requestId: req.requestId,
      });

      return res.status(500).json({
        error: true,
        code: 'INTERNAL_ERROR',
        message: 'An internal error occurred while submitting the bulk-strings job',
        timestamp: new Date().toISOString(),
      });
    }
  }
);

export default router;
