/**
 * Blog AI Context Routes
 *
 * API endpoints for Blog Content OS AI generation.
 * POST /auto-fill — generate blog data from company context
 * POST /regenerate — regenerate blog data using existing context (avoids duplicate titles)
 * POST /generate-titles — generate blog titles in background
 * POST /generate-seo — generate SEO metadata for a title in background
 * POST /generate-title-seo — generate one SEO keyword set per blog title in background
 * POST /generate-content — generate blog post content in background
 * POST /generate-structure — generate blog structure from a title in background
 *
 * NOTE ON generateWithAI(): none of the calls in this file pass companyId.
 * generateWithAI() applies the subscription AI-model gate ONLY when a companyId is
 * supplied (aiProvider.ts — `if (companyId) { ... AI_MODEL_NOT_INCLUDED ... }`), and
 * every aiContext pipeline in this app (landing page, book, whatsapp, ads, ICP, …)
 * calls it without one. These routes used to pass it, which made the whole blog
 * module — titles, SEO, structures and every content section — fail with
 * AI_MODEL_NOT_INCLUDED while the rest of the product generated normally. The blog
 * pipeline now behaves the same as every other module.
 */

import express, { Request, Response } from 'express';
import { body, validationResult } from 'express-validator';
import { authenticate } from '../middleware/auth';
import { requirePermission } from '../middleware/permissions';
import { BlogPipeline } from '../services/aiContext/blogPipeline';
import { BlogPipelineInputs } from '../services/aiContext/blogPrompts';
import { aiContextService, computeBlogAutoFillMapping } from '../services/aiContext/aiContextService';
import { getModels } from '../models';
import { createJob, updateJobProgress, completeJob, failJob, getJob } from '../services/aiContext/aiJobManager';
import { generateWithAI, AIResult } from '../utils/aiProvider';
import { buildTocFromSections, injectTocIntoMarkdown } from '../services/aiContext/blogToc';

const router = express.Router();
router.use(authenticate);

// ============================================
// AI PROCESSING METADATA
// ============================================

/**
 * Persist an AiContext row for a blog background job so the AI Processing
 * dashboard can show its provider, model, token usage and duration.
 *
 * Only /auto-fill and /regenerate wrote one of these (they go through
 * BlogPipeline, which records its own). The title, SEO, structure and content
 * jobs call generateWithAI() directly, so nothing was ever persisted for them
 * and the dashboard could only fall back to the in-memory job — a row whose
 * provider/model/tokens/cost are all placeholders.
 *
 * Everything here is measured, never invented: values come from the AI calls
 * that actually ran, and a field the provider did not return stays undefined
 * rather than being defaulted. Failed jobs deliberately record nothing — the
 * live job entry already carries the error, and writing a row for it as well
 * would list the same generation twice.
 */
async function recordBlogAiGeneration(params: {
  companyId: string;
  analysisType: string;
  entityId?: string | null;
  inputs: Record<string, any>;
  analysis: Record<string, any>;
  /** Every successful generateWithAI() result that contributed to this job. */
  results: Array<AIResult | null | undefined>;
  /** Date.now() captured when the job started doing AI work. */
  startedAt: number;
}): Promise<void> {
  try {
    const results = params.results.filter(Boolean) as AIResult[];
    if (results.length === 0) return;

    // A job can span many calls (one per section). Tokens sum; provider, model
    // and key are taken from the calls that ran, and are only listed as more
    // than one value when they genuinely differed (provider failover mid-job).
    let inputTokens = 0;
    let outputTokens = 0;
    let totalTokens = 0;
    let sawTokenUsage = false;
    let latencyMs = 0;
    let sawLatency = false;
    for (const r of results) {
      if (r.tokenUsage) {
        sawTokenUsage = true;
        inputTokens += r.tokenUsage.inputTokens || 0;
        outputTokens += r.tokenUsage.outputTokens || 0;
        totalTokens += r.tokenUsage.totalTokens || 0;
      }
      if (typeof r.latencyMs === 'number') {
        sawLatency = true;
        latencyMs += r.latencyMs;
      }
    }

    const distinct = (values: Array<string | undefined>): string | undefined => {
      const set = new Set(values.filter((v): v is string => !!v));
      if (set.size === 0) return undefined;
      if (set.size === 1) return [...set][0];
      return [...set].join(', ');
    };

    const provider = distinct(results.map((r) => r.provider));
    const model = distinct(results.map((r) => r.model));
    const apiKeyMasked = distinct(results.map((r) => r.keyUsed));
    // Only meaningful when a single call produced the result; concatenating
    // stop reasons across sections would not describe anything real.
    const finishReason = results.length === 1 ? results[0].finishReason : undefined;

    const context = await aiContextService.create({
      companyId: params.companyId,
      moduleSource: 'blog',
      analysisType: params.analysisType,
      inputs: params.inputs as any,
      analysis: params.analysis as any,
      metadata: {
        pipelineVersion: '1.0',
        provider: provider || '',
        model: model || '',
        tokensUsed: sawTokenUsage ? (totalTokens || inputTokens + outputTokens) : 0,
        inputTokens: sawTokenUsage ? inputTokens : undefined,
        outputTokens: sawTokenUsage ? outputTokens : undefined,
        processingTimeMs: Date.now() - params.startedAt,
        latencyMs: sawLatency ? latencyMs : undefined,
        overallConfidence: 0,
        fieldConfidences: {},
        finishReason: finishReason ?? null,
        apiKeyMasked: apiKeyMasked ?? null,
      },
    });

    if (params.entityId) {
      await getModels().AiContext.updateOne({ _id: context.id }, { $set: { entityId: params.entityId } });
    }
    await aiContextService.updateStatus(context.id, 'approved');
  } catch (err) {
    // Tracking must never cost the user a generation that already succeeded.
    console.warn('[Blog-AI] Failed to record AI processing metadata:', err instanceof Error ? err.message : err);
  }
}

router.get(
  '/status/:jobId',
  async (req: Request, res: Response) => {
    const { jobId } = req.params;
    const job = getJob(jobId);

    if (!job) {
      res.status(404).json({ error: 'Job not found' });
      return;
    }

    res.json({
      jobId: job.jobId,
      status: job.status,
      progress: job.progress,
      step: job.step,
      result: job.result,
      error: job.error,
    });
  }
);

// ============================================
// POST /auto-fill
// ============================================

router.post(
  '/auto-fill',
  requirePermission('blog-content-os', 'ai-generate'),
  [
    body('companyId').notEmpty().withMessage('Company ID is required'),
  ],
  async (req: Request, res: Response) => {
    const errors = validationResult(req);
    if (!errors.isEmpty()) {
      res.status(400).json({ error: 'Validation failed', details: errors.array() });
      return;
    }

    // `customInstructions` carries the Prompt (plus author + data-source lines)
    // from the "Generate with AI" popup and the AI-Chat blog flow. It was
    // previously not read here at all, so a required user input was silently
    // discarded and every blog was generated from company context alone.
    const { companyId, language, startDate, endDate, frequency, numberOfPosts, customInstructions } = req.body;

    const job = createJob('blog', companyId, req.body._moduleId);

    // Return jobId immediately so the frontend can poll
    res.status(202).json({ jobId: job.jobId, status: 'processing' });

    // Run pipeline in background
    setImmediate(async () => {
      try {
        const { Company, BusinessProfile, ICP } = getModels();
        const company = await Company.findById(companyId);
        if (!company) {
          failJob(job.jobId, 'Company not found');
          return;
        }

        let businessProfile: any = null;
        try { businessProfile = await BusinessProfile.findOne({ companyId }); } catch {}

        const companyContexts = await aiContextService.getByCompany(companyId, 'company-creation');
        const latestCompanyContext = companyContexts.find((c: any) => {
          const a = c.analysis?.toObject?.() || c.analysis || {};
          return c.status === 'approved' || a.industryType || a.businessModel || a.businessSummary;
        });

        let icpData: any = null;
        try { icpData = await ICP.findOne({ companyId, isActive: true }).sort({ createdAt: -1 }); } catch {}

        // Load brand strategy context
        let brandStrategyData: any = null;
        try {
          const { ModuleData } = getModels();
          const brandStrategyDoc = await ModuleData.findOne({ moduleId: 'brand-strategy', companyId });
          if (brandStrategyDoc?.data) {
            brandStrategyData = brandStrategyDoc.data;
          }
        } catch {}

        // Load existing blog titles to avoid duplicates
        let existingBlogTitles: string[] = [];
        try {
          const { BlogContentOS } = getModels();
          const blogData = await BlogContentOS.findOne({ companyId });
          if (blogData?.titles && Array.isArray(blogData.titles)) {
            existingBlogTitles = blogData.titles.map((t: any) => t.title).filter(Boolean);
          }
        } catch {}

        // Build pipeline inputs
        const pipelineInputs: BlogPipelineInputs = {
          companyName: company.name,
          companyDescription: company.description || businessProfile?.description || undefined,
          companyIndustry: businessProfile?.primaryIndustry || undefined,
          companyBusinessModel: businessProfile?.businessModel || undefined,
          companyTargetAudience: undefined,
          companyTargetGeography: businessProfile?.targetGeography || undefined,
          companyCountry: businessProfile?.country || undefined,
          companyPrimaryOffering: undefined,
          companyUsps: undefined,
          targetBlogCount: numberOfPosts || 5,
          existingBlogTitles: existingBlogTitles.length > 0 ? existingBlogTitles : undefined,
          language: language || 'en',
          customInstructions:
            typeof customInstructions === 'string' && customInstructions.trim()
              ? customInstructions.trim()
              : undefined,
          // Scheduling parameters
          startDate: startDate || undefined,
          endDate: endDate || undefined,
          frequency: frequency || undefined,
          numberOfPosts: numberOfPosts || undefined,
        };

        if (latestCompanyContext) {
          const analysis = (latestCompanyContext as any).analysis?.toObject?.() || (latestCompanyContext as any).analysis || {};
          if (analysis.industryType && !pipelineInputs.companyIndustry) pipelineInputs.companyIndustry = analysis.industryType;
          if (analysis.businessModel && !pipelineInputs.companyBusinessModel) pipelineInputs.companyBusinessModel = analysis.businessModel;
          if (analysis.targetAudience?.primary) pipelineInputs.companyTargetAudience = analysis.targetAudience.primary;
          if (analysis.targetGeography && !pipelineInputs.companyTargetGeography) pipelineInputs.companyTargetGeography = analysis.targetGeography;
          if (analysis.primaryOffering) pipelineInputs.companyPrimaryOffering = analysis.primaryOffering;
          if (analysis.uspSuggestions?.length) pipelineInputs.companyUsps = analysis.uspSuggestions;
        }

        if (icpData) {
          pipelineInputs.icpName = icpData.name || undefined;
          pipelineInputs.icpIndustry = icpData.industry || undefined;
          pipelineInputs.icpCompanySize = icpData.companySize || undefined;
          pipelineInputs.icpLocation = icpData.location || undefined;
          pipelineInputs.icpPainPoints = icpData.painPoints || undefined;
          pipelineInputs.icpBusinessGoals = icpData.businessGoals || undefined;
        }

        if (brandStrategyData) {
          pipelineInputs.brandArchetype = brandStrategyData.brandArchetype || undefined;
          pipelineInputs.brandPersonality = brandStrategyData.brandPersonality || undefined;
          pipelineInputs.brandValues = brandStrategyData.brandValues || undefined;
          pipelineInputs.brandPositioning = brandStrategyData.brandPositioning || undefined;
          pipelineInputs.brandVoice = brandStrategyData.brandVoice || undefined;
        }

        updateJobProgress(job.jobId, 10, 'Preparing context...');
        const pipeline = new BlogPipeline(pipelineInputs, (progress, step) => { updateJobProgress(job.jobId, progress, step); });
        const result = await pipeline.run();

        const context = await aiContextService.create({
          companyId,
          moduleSource: 'blog',
          analysisType: 'full-analysis',
          inputs: { companyName: company.name, description: pipelineInputs.companyDescription },
          analysis: result.analysis,
          metadata: {
            pipelineVersion: result.pipelineVersion,
            provider: result.provider,
            model: result.aiModel,
            tokensUsed: result.tokensUsed,
            inputTokens: result.inputTokens,
            outputTokens: result.outputTokens,
            processingTimeMs: result.processingTimeMs,
            latencyMs: result.latencyMs,
            overallConfidence: result.overallConfidence,
            fieldConfidences: result.fieldConfidences,
            finishReason: result.finishReason,
            apiKeyMasked: result.apiKeyMasked,
          },
        });

        await aiContextService.updateStatus(context.id, 'approved');

        const autoFillData = computeBlogAutoFillMapping(result.analysis);
        completeJob(job.jobId, autoFillData, 'generated');

        console.log(`[Blog-AutoFill] Job ${job.jobId} completed. Source: generated`);
      } catch (err: any) {
        console.error(`[Blog-AutoFill] Job ${job.jobId} failed:`, err.message);
        failJob(job.jobId, err.message || 'AI generation failed');
      }
    });
  }
);

// ============================================
// POST /regenerate
// ============================================

router.post(
  '/regenerate',
  requirePermission('blog-content-os', 'ai-generate'),
  [
    body('companyId').notEmpty().withMessage('Company ID is required'),
  ],
  async (req: Request, res: Response) => {
    const errors = validationResult(req);
    if (!errors.isEmpty()) {
      res.status(400).json({ error: 'Validation failed', details: errors.array() });
      return;
    }

    const { companyId, existingBlogData, language, startDate, endDate, frequency, numberOfPosts } = req.body;

    const job = createJob('blog', companyId, req.body._moduleId);
    res.status(202).json({ jobId: job.jobId, status: 'processing' });

    // Run pipeline in background
    setImmediate(async () => {
      try {
        const { Company, BusinessProfile, ICP } = getModels();
        const company = await Company.findById(companyId);
        if (!company) {
          failJob(job.jobId, 'Company not found');
          return;
        }

        let businessProfile: any = null;
        try { businessProfile = await BusinessProfile.findOne({ companyId }); } catch {}

        const companyContexts = await aiContextService.getByCompany(companyId, 'company-creation');
        const latestCompanyContext = companyContexts.find((c: any) => {
          const a = c.analysis?.toObject?.() || c.analysis || {};
          return c.status === 'approved' || a.industryType || a.businessModel || a.businessSummary;
        });

        let icpData: any = null;
        try { icpData = await ICP.findOne({ companyId, isActive: true }).sort({ createdAt: -1 }); } catch {}

        // Load brand strategy context
        let brandStrategyData: any = null;
        try {
          const { ModuleData } = getModels();
          const brandStrategyDoc = await ModuleData.findOne({ moduleId: 'brand-strategy', companyId });
          if (brandStrategyDoc?.data) {
            brandStrategyData = brandStrategyDoc.data;
          }
        } catch {}

        // Load existing blog titles to avoid duplicates
        let existingBlogTitles: string[] = [];
        try {
          const { BlogContentOS } = getModels();
          const blogData = await BlogContentOS.findOne({ companyId });
          if (blogData?.titles && Array.isArray(blogData.titles)) {
            existingBlogTitles = blogData.titles.map((t: any) => t.title).filter(Boolean);
          }
        } catch {}

        // Also use titles passed from frontend for regeneration
        if (existingBlogData?.titles && Array.isArray(existingBlogData.titles)) {
          const frontendTitles = existingBlogData.titles.map((t: any) => typeof t === 'string' ? t : t.title).filter(Boolean);
          existingBlogTitles = [...new Set([...existingBlogTitles, ...frontendTitles])];
        }

        // Build pipeline inputs with existing data for regeneration
        const pipelineInputs: BlogPipelineInputs = {
          companyName: company.name,
          companyDescription: company.description || businessProfile?.description || undefined,
          companyIndustry: businessProfile?.primaryIndustry || undefined,
          companyBusinessModel: businessProfile?.businessModel || undefined,
          companyTargetAudience: undefined,
          companyTargetGeography: businessProfile?.targetGeography || undefined,
          companyCountry: businessProfile?.country || undefined,
          companyPrimaryOffering: undefined,
          companyUsps: undefined,
          targetBlogCount: numberOfPosts || 5,
          existingBlogTitles: existingBlogTitles.length > 0 ? existingBlogTitles : undefined,
          language: language || 'en',
          // Scheduling parameters
          startDate: startDate || undefined,
          endDate: endDate || undefined,
          frequency: frequency || undefined,
          numberOfPosts: numberOfPosts || undefined,
        };

        if (latestCompanyContext) {
          const analysis = (latestCompanyContext as any).analysis?.toObject?.() || (latestCompanyContext as any).analysis || {};
          if (analysis.industryType && !pipelineInputs.companyIndustry) pipelineInputs.companyIndustry = analysis.industryType;
          if (analysis.businessModel && !pipelineInputs.companyBusinessModel) pipelineInputs.companyBusinessModel = analysis.businessModel;
          if (analysis.targetAudience?.primary) pipelineInputs.companyTargetAudience = analysis.targetAudience.primary;
          if (analysis.targetGeography && !pipelineInputs.companyTargetGeography) pipelineInputs.companyTargetGeography = analysis.targetGeography;
          if (analysis.primaryOffering) pipelineInputs.companyPrimaryOffering = analysis.primaryOffering;
          if (analysis.uspSuggestions?.length) pipelineInputs.companyUsps = analysis.uspSuggestions;
        }

        if (icpData) {
          pipelineInputs.icpName = icpData.name || undefined;
          pipelineInputs.icpIndustry = icpData.industry || undefined;
          pipelineInputs.icpCompanySize = icpData.companySize || undefined;
          pipelineInputs.icpLocation = icpData.location || undefined;
          pipelineInputs.icpPainPoints = icpData.painPoints || undefined;
          pipelineInputs.icpBusinessGoals = icpData.businessGoals || undefined;
        }

        if (brandStrategyData) {
          pipelineInputs.brandArchetype = brandStrategyData.brandArchetype || undefined;
          pipelineInputs.brandPersonality = brandStrategyData.brandPersonality || undefined;
          pipelineInputs.brandValues = brandStrategyData.brandValues || undefined;
          pipelineInputs.brandPositioning = brandStrategyData.brandPositioning || undefined;
          pipelineInputs.brandVoice = brandStrategyData.brandVoice || undefined;
        }

        updateJobProgress(job.jobId, 10, 'Preparing context...');
        const pipeline = new BlogPipeline(pipelineInputs, (progress, step) => { updateJobProgress(job.jobId, progress, step); });
        const result = await pipeline.run();

        const context = await aiContextService.create({
          companyId,
          moduleSource: 'blog',
          analysisType: 'full-analysis',
          inputs: { companyName: company.name, description: pipelineInputs.companyDescription },
          analysis: result.analysis,
          metadata: {
            pipelineVersion: result.pipelineVersion,
            provider: result.provider,
            model: result.aiModel,
            tokensUsed: result.tokensUsed,
            inputTokens: result.inputTokens,
            outputTokens: result.outputTokens,
            processingTimeMs: result.processingTimeMs,
            latencyMs: result.latencyMs,
            overallConfidence: result.overallConfidence,
            fieldConfidences: result.fieldConfidences,
            finishReason: result.finishReason,
            apiKeyMasked: result.apiKeyMasked,
          },
        });

        await aiContextService.updateStatus(context.id, 'approved');

        const autoFillData = computeBlogAutoFillMapping(result.analysis);
        completeJob(job.jobId, autoFillData, 'regenerated');

        console.log(`[Blog-Regenerate] Job ${job.jobId} completed. Source: regenerated`);
      } catch (err: any) {
        console.error(`[Blog-Regenerate] Job ${job.jobId} failed:`, err.message);
        failJob(job.jobId, err.message || 'AI regeneration failed');
      }
    });
  }
);

// ============================================
// POST /generate-titles
// Generate blog titles in background (non-blocking)
// ============================================

router.post(
  '/generate-titles',
  requirePermission('blog-content-os', 'ai-generate'),
  [
    body('companyId').notEmpty().withMessage('Company ID is required'),
    body('strategyId').notEmpty().withMessage('Strategy ID is required'),
    body('count').isInt({ min: 1, max: 50 }).withMessage('Count must be between 1 and 50'),
    body('style').notEmpty().withMessage('Style is required'),
  ],
  async (req: Request, res: Response) => {
    const errors = validationResult(req);
    if (!errors.isEmpty()) {
      res.status(400).json({ error: 'Validation failed', details: errors.array() });
      return;
    }

    // `instructions` carries the free-text prompt from the Titles step's
    // Regenerate All dialog (and single-title regeneration).
    //
    // `targetTitleId` names an EXISTING blog whose title this call is writing.
    // The wizard always sends it: blogs are created up front on the Blog
    // Selection step and the Title step only names them, so the record must be
    // updated in place rather than a new one created. Without it the endpoint
    // falls back to its original batch behaviour and appends new titles.
    const { companyId, strategyId, count, style, language, context, instructions, targetTitleId } = req.body;
    const job = createJob('blog-titles', companyId, req.body._moduleId || 'blog-content-os');

    // Return jobId immediately
    res.status(202).json({ jobId: job.jobId, status: 'processing' });

    // Run in background
    setImmediate(async () => {
      const startedAt = Date.now();
      try {
        updateJobProgress(job.jobId, 5, 'Loading context data...');

        const { Company, BusinessProfile, ICP, Persona, Competitor, Product } = getModels();
        const company = await Company.findById(companyId);
        if (!company) {
          failJob(job.jobId, 'Company not found');
          return;
        }

        // Load context data
        let businessProfile: any = null;
        try { businessProfile = await BusinessProfile.findOne({ companyId }); } catch {}

        const icpContext = context?.icps || [];
        const personaContext = context?.personas || [];
        const competitorContext = context?.competitors || [];
        const productContext = context?.products || [];

        updateJobProgress(job.jobId, 15, 'Building AI prompt...');

        // Build prompt for title generation
        const brandContext = context?.brand ? `Brand voice: ${context.brand.voice || 'professional'}, Brand personality: ${context.brand.personality || 'friendly'}` : '';
        const businessContext = businessProfile ? `Industry: ${businessProfile.primaryIndustry || 'general'}, Company: ${businessProfile.name || 'our company'}` : '';

        const goalLabel = context?.goal || 'seo';
        const funnelLabel = context?.funnelStage || 'tofu';
        const audienceLabel = context?.targetAudience || 'general professionals';

        const typeNames = context?.contentTypes || 'Educational (tofu), How-To Guide (tofu), Case Study (mofu)';

        // SEO strategy context — the strategy generated on the SEO step drives
        // which keywords the titles must target and which to avoid.
        let seoStrategyStr = '';
        const seoStrategy = context?.seoStrategy;
        if (seoStrategy) {
          const kw = (arr: any) => (Array.isArray(arr) && arr.length ? arr.join(', ') : '');
          const lines: string[] = [];
          if (seoStrategy.searchIntent) lines.push(`- Primary search intent: ${seoStrategy.searchIntent}`);
          if (seoStrategy.primaryGoal) lines.push(`- Primary SEO goal: ${seoStrategy.primaryGoal}`);
          if (kw(seoStrategy.primaryKeywords)) lines.push(`- Primary keywords (each should be covered): ${kw(seoStrategy.primaryKeywords)}`);
          if (kw(seoStrategy.secondaryKeywords)) lines.push(`- Secondary keywords: ${kw(seoStrategy.secondaryKeywords)}`);
          if (kw(seoStrategy.additionalKeywords)) lines.push(`- Additional keywords: ${kw(seoStrategy.additionalKeywords)}`);
          if (kw(seoStrategy.longTailKeywords)) lines.push(`- Long-tail keywords: ${kw(seoStrategy.longTailKeywords)}`);
          if (kw(seoStrategy.competitorKeywords)) lines.push(`- Competitor keywords to contest: ${kw(seoStrategy.competitorKeywords)}`);
          if (kw(seoStrategy.negativeKeywords)) lines.push(`- NEGATIVE keywords — do NOT build titles around these: ${kw(seoStrategy.negativeKeywords)}`);
          if (lines.length) {
            seoStrategyStr = `SEO strategy these titles MUST follow:\n${lines.join('\n')}\nSpread the primary and secondary keywords across the titles rather than repeating one keyword. Populate each title's suggestedKeywords from this strategy.\n`;
          }
        }

        // Publishing plan context — titles are generated to fill the calendar.
        let scheduleStr = '';
        const schedule = context?.schedule;
        if (schedule) {
          const parts: string[] = [];
          if (schedule.frequency) parts.push(`- Publishing frequency: ${schedule.frequency}`);
          if (Array.isArray(schedule.publishingDays) && schedule.publishingDays.length) parts.push(`- Publishing days: ${schedule.publishingDays.join(', ')}`);
          if (schedule.startDate) parts.push(`- Schedule runs from ${String(schedule.startDate).slice(0, 10)}${schedule.endDate ? ` to ${String(schedule.endDate).slice(0, 10)}` : ''}`);
          if (schedule.totalPosts) parts.push(`- Total scheduled slots on the calendar: ${schedule.totalPosts}`);
          if (parts.length) {
            scheduleStr = `Publishing plan these titles fill:\n${parts.join('\n')}\nEach title is one scheduled post, so keep them distinct and sequenced sensibly for a reader following the blog over this period.\n`;
          }
        }

        // Build ICP context
        let icpContextStr = '';
        if (icpContext.length > 0) {
          icpContextStr = icpContext.map((icp: any, i: number) => {
            const pains = icp.painPoints?.slice(0, 3).join(', ') || 'No pain points defined';
            const goals = icp.goals?.slice(0, 3).join(', ') || 'No goals defined';
            return `ICP ${i + 1}: ${icp.name} - Pain Points: ${pains}. Goals: ${goals}`;
          }).join('\n');
        }

        // Build Persona context
        let personaContextStr = '';
        if (personaContext.length > 0) {
          personaContextStr = personaContext.map((p: any, i: number) => {
            const challenges = p.challenges?.slice(0, 3).join(', ') || 'No challenges defined';
            return `Persona ${i + 1}: ${p.name} (${p.jobTitle || 'No title'}) - Challenges: ${challenges}`;
          }).join('\n');
        }

        // Build Competitor context
        let competitorContextStr = '';
        if (competitorContext.length > 0) {
          competitorContextStr = competitorContext.map((c: any, i: number) => {
            const strengths = c.strengths?.slice(0, 2).join(', ') || 'No strengths defined';
            const weaknesses = c.weaknesses?.slice(0, 2).join(', ') || 'No weaknesses defined';
            return `Competitor ${i + 1}: ${c.name} - Strengths: ${strengths}. Weaknesses: ${weaknesses}`;
          }).join('\n');
        }

        // Build Product context
        let productContextStr = '';
        if (productContext.length > 0) {
          productContextStr = productContext.map((p: any, i: number) => {
            const features = p.features?.slice(0, 3).join(', ') || 'No features defined';
            return `Product ${i + 1}: ${p.name} - Key Features: ${features}`;
          }).join('\n');
        }

        const styleDescriptions: Record<string, string> = {
          'seo': 'Use keyword-rich, search-optimized titles that rank well.',
          'viral': 'Use emotional, curiosity-driven titles that encourage clicks and shares.',
          'authority': 'Use expert, credible positioning titles that establish thought leadership.',
          'technical': 'Use precise, industry-specific titles for technical audiences.',
          'emotional': 'Use feeling-driven, relatable titles that connect with readers.',
          'founder': 'Use personal, authentic founder-style titles.',
          'linkedin': 'Use professional, shareable titles optimized for LinkedIn.',
          'thought-leadership': 'Use visionary, perspective-based titles that showcase expertise.',
        };

        // Build language instruction if non-English
        const languageInstruction = (() => {
          const lang = language || 'en';
          if (lang === 'en') return '';
          const LANGUAGE_NAMES: Record<string, string> = {
            'hi': 'Hindi using Devanagari script (हिंदी देवनागरी लिपि)',
            'mr': 'Marathi using Devanagari script (मराठी देवनागरी लिपि)',
          };
          const langName = LANGUAGE_NAMES[lang] || lang;
          return `\n\nIMPORTANT LANGUAGE REQUIREMENT: Generate ALL content (titles, slugs for non-Latin scripts use transliteration, excerpts, keywords, CTAs) entirely in ${langName}. Do NOT use English unless it is a technical term or proper noun. All text must be natural, fluent, and appropriate for B2B business contexts in the specified language.`;
        })();

        // User-supplied prompt from the Regenerate dialog. Placed last so it takes
        // precedence over the generic guidance above it.
        const instructionStr = typeof instructions === 'string' && instructions.trim()
          ? `\nADDITIONAL INSTRUCTIONS FROM THE USER (these take priority):\n${instructions.trim()}\n`
          : '';

        const prompt = `You are an expert blog content strategist. Generate exactly ${count} blog post titles for a business blog.

Strategy context:
- Primary Goal: ${goalLabel}
- Target Audience: ${audienceLabel}
- Funnel Stage: ${funnelLabel}
${brandContext ? '- ' + brandContext : ''}
${businessContext ? '- ' + businessContext : ''}

Content types to cover: ${typeNames}

${seoStrategyStr}
${scheduleStr}
${icpContextStr ? `Target ICPs:\n${icpContextStr}\n` : ''}
${personaContextStr ? `Buyer Personas:\n${personaContextStr}\n` : ''}
${competitorContextStr ? `Competitor Landscape:\n${competitorContextStr}\n` : ''}
${productContextStr ? `Products/Services to Feature:\n${productContextStr}\n` : ''}

Title style: ${style}
${styleDescriptions[style] || ''}
${instructionStr}${languageInstruction}
You MUST respond with ONLY a valid JSON array. No markdown, no explanation, no code fences. Each element must be an object with exactly these fields:
- "title": string — the blog post title
- "slug": string — URL-friendly slug (lowercase, hyphens)
- "excerpt": string — brief summary (under 160 characters)
- "contentType": string — one of: educational, how-to-guide, industry-trends, case-study, comparison, product-focused, listicle, problem-solution, thought-leadership
- "funnelStage": string — one of: tofu, mofu, bofu
- "seoScore": number — estimated SEO score from 70-99
- "searchIntent": string — one of: informational, commercial, transactional
- "suggestedKeywords": array of 5 relevant keyword strings
- "suggestedCTA": string — a call-to-action phrase

Generate ${count} diverse, creative titles now:`;

        updateJobProgress(job.jobId, 25, 'Generating titles with AI...');

        const userId = req.user?._id?.toString() || req.user?.id;
        // No companyId — see the note at the top of this file.
        const result = await generateWithAI(prompt, 'You are an expert blog content strategist. Always respond with valid JSON.', 8000, undefined, undefined, undefined, undefined, userId);

        updateJobProgress(job.jobId, 70, 'Parsing AI response...');

        let parsed: any;
        const content = result.content || '';
        console.log(`[Blog-Titles] AI response length: ${content.length} chars, preview: ${content.substring(0, 200)}`);

        try {
          // Try to parse as JSON
          let jsonStr = content.replace(/^```(?:json)?\s*\n?/i, '').replace(/\n?```\s*$/i, '').trim();
          parsed = JSON.parse(jsonStr);
        } catch {
          // Try to extract JSON array or object
          const arrayMatch = content.match(/\[[\s\S]*\]/);
          const objectMatch = content.match(/\{[\s\S]*\}/);
          if (arrayMatch) {
            try {
              parsed = JSON.parse(arrayMatch[0]);
            } catch {
              // Try object if array parse fails
              if (objectMatch) {
                parsed = JSON.parse(objectMatch[0]);
              } else {
                throw new Error('Could not parse AI response as JSON');
              }
            }
          } else if (objectMatch) {
            parsed = JSON.parse(objectMatch[0]);
          } else {
            throw new Error('Could not parse AI response as JSON');
          }
        }

        // A title item is an object carrying a non-empty `title` string. Every
        // step below is judged against this, so a list of something-else can
        // never be mistaken for a list of titles.
        const isTitleItem = (v: any) =>
          !!v && typeof v === 'object' && !Array.isArray(v)
          && typeof v.title === 'string' && v.title.trim().length > 0;
        const isTitleArray = (v: any) => Array.isArray(v) && v.length > 0 && v.some(isTitleItem);

        // If the AI returned a JSON object instead of an array, extract the array from it
        if (!Array.isArray(parsed)) {
          if (typeof parsed === 'object' && parsed !== null) {
            let extractedArray: any[] | null = null;

            // FIRST: the object may BE the title. Asking for one title is the
            // normal case now (the Title step generates per blog), and the model
            // answers with a single object.
            //
            // This check has to come before any search for an array inside it,
            // because a title object contains arrays of its own — `suggestedKeywords`
            // above all. Hunting for "the first array-valued property" found that
            // keyword list and used the five KEYWORDS as five titles, throwing the
            // real title away. The job then failed with nothing usable and the
            // blog stayed named "Blog 1" even though the model had answered
            // perfectly.
            if (isTitleItem(parsed)) {
              console.log(`[Blog-Titles] AI returned a single title object; using it as-is.`);
              extractedArray = [parsed];
            }

            // Otherwise it is a wrapper — look for the list inside it, by the
            // usual property names first.
            if (!extractedArray) {
              const arrayKeys = ['titles', 'titleSuggestions', 'results', 'items', 'data', 'blogs', 'suggestions'];
              for (const key of arrayKeys) {
                const candidate = parsed[key];
                if (isTitleArray(candidate)) {
                  extractedArray = candidate;
                  console.log(`[Blog-Titles] Extracted titles array from object key "${key}" (${candidate.length} items)`);
                  break;
                }
              }
            }

            // Then any other property holding a list of title objects. Arrays of
            // plain strings are deliberately NOT accepted here — that is what let
            // a keyword list stand in for the titles.
            if (!extractedArray) {
              for (const [key, value] of Object.entries(parsed)) {
                if (isTitleArray(value)) {
                  extractedArray = value as any[];
                  console.log(`[Blog-Titles] Extracted titles array from object key "${key}" (${extractedArray.length} items)`);
                  break;
                }
              }
            }

            // If still no array found, wrap the single object in an array
            if (!extractedArray) {
              console.log(`[Blog-Titles] AI returned a single object, wrapping in array. Keys: ${Object.keys(parsed).join(', ')}`);
              extractedArray = [parsed];
            }

            parsed = extractedArray;
          } else {
            throw new Error('AI response is not an array or object');
          }
        }

        // The model can also answer with a bare array of title STRINGS. Promote
        // them to title objects rather than discarding the response.
        if (Array.isArray(parsed)) {
          parsed = parsed.map((v: any) =>
            typeof v === 'string' && v.trim() ? { title: v.trim() } : v
          );
        }

        console.log(`[Blog-Titles] Parsed ${Array.isArray(parsed) ? parsed.length : 0} title items from AI response`);

        updateJobProgress(job.jobId, 85, 'Saving titles...');

        const { BlogContentOS } = getModels();

        // ── Renaming an existing blog vs creating new ones ──────────────────
        // The wizard creates its blogs up front on the Blog Selection step
        // ("Blog 1", "Blog 2", …) and the Title step only fills in their names,
        // so it sends `targetTitleId`: the blog this title belongs to. In that
        // mode the existing record is UPDATED IN PLACE and nothing is added —
        // pushing a new record here is what produced a duplicate blog for every
        // generated title, leaving the original showing as an untitled slot
        // beside its own generated twin.
        let titles: any[] = [];

        if (targetTitleId) {
          // The first entry that actually carries a title — not simply the first
          // entry, which can be filler the model put ahead of the real one.
          const item = parsed.find(isTitleItem);
          // Renaming a blog to "Untitled Blog Post" is worse than not renaming
          // it — the slot keeps its name, the job fails, and the step offers a
          // retry instead of silently defacing the plan.
          if (!item) {
            console.warn(`[Blog-Titles] No usable title in the response. Parsed: ${JSON.stringify(parsed).slice(0, 300)}`);
            failJob(job.jobId, 'The AI did not return a usable title');
            return;
          }
          const generatedTitle = item.title.trim();
          // Only the fields the title generation actually produces. The slot's
          // own identity — id, order, status, calendarId, scheduledDate — is
          // never touched, so the blog keeps its place in the plan and stays
          // attached to the SEO record already generated for it.
          const updates: Record<string, any> = {
            'titles.$.title': generatedTitle,
            'titles.$.slug': item.slug
              || generatedTitle.toLowerCase().replace(/[^\w\s-]/g, '').replace(/\s+/g, '-').substring(0, 60)
              || 'untitled',
            'titles.$.excerpt': item.excerpt || '',
            'titles.$.contentType': item.contentType || 'educational',
            'titles.$.style': style,
            'titles.$.seoScore': typeof item.seoScore === 'number' ? item.seoScore : Math.floor(Math.random() * 30) + 70,
            'titles.$.searchIntent': item.searchIntent || 'informational',
            'titles.$.funnelStage': item.funnelStage || 'tofu',
            'titles.$.suggestedKeywords': Array.isArray(item.suggestedKeywords) ? item.suggestedKeywords : [],
            'titles.$.suggestedCTA': item.suggestedCTA || 'Learn more',
            // It has a real title now, so it is no longer an unnamed slot.
            'titles.$.isPlaceholder': false,
            'titles.$.aiModel': result.model || 'unknown',
            'titles.$.updatedAt': new Date().toISOString(),
          };
          try {
            // Positional $set writes only this element's fields. Read-modify-save
            // on the shared per-company document would have concurrent writers
            // rewriting the whole array and losing each other's titles.
            const res = await BlogContentOS.updateOne(
              { companyId, 'titles.id': targetTitleId },
              { $set: updates },
            );
            if (!res.matchedCount) {
              // The blog exists only in the browser's store so far (created
              // locally and not yet synced). Nothing to update server-side —
              // the client applies the result to its own record either way, and
              // deliberately NOT pushing keeps this from creating a duplicate.
              console.warn(`[Blog-Titles] No stored blog matched ${targetTitleId}; leaving the server copy alone.`);
            }
          } catch (dbErr) {
            console.error('[Blog-Titles] Failed to update the blog:', dbErr);
          }
          // Echo the record back under the id it belongs to, so the client
          // writes the title onto that same blog.
          titles = [{
            id: targetTitleId,
            strategyId,
            companyId,
            title: generatedTitle,
            slug: updates['titles.$.slug'],
            excerpt: updates['titles.$.excerpt'],
            contentType: updates['titles.$.contentType'],
            style,
            seoScore: updates['titles.$.seoScore'],
            searchIntent: updates['titles.$.searchIntent'],
            funnelStage: updates['titles.$.funnelStage'],
            suggestedKeywords: updates['titles.$.suggestedKeywords'],
            suggestedCTA: updates['titles.$.suggestedCTA'],
            isPlaceholder: false,
            aiModel: result.model || 'unknown',
          }];
        } else {
          // Legacy batch mode — no target blog, so the titles are new records.
          // Only entries that actually carry a title become records; anything
          // else the model wrapped in would otherwise land as "Untitled Blog Post".
          titles = parsed.filter(isTitleItem).slice(0, count).map((item: any, i: number) => ({
            id: `bt-${Date.now()}-${i}-${Math.random().toString(36).slice(2, 9)}`,
            strategyId,
            title: item.title || 'Untitled Blog Post',
            slug: item.slug || item.title?.toLowerCase().replace(/[^\w\s-]/g, '').replace(/\s+/g, '-').substring(0, 60) || 'untitled',
            excerpt: item.excerpt || '',
            contentType: item.contentType || 'educational',
            style,
            seoScore: typeof item.seoScore === 'number' ? item.seoScore : Math.floor(Math.random() * 30) + 70,
            searchIntent: item.searchIntent || 'informational',
            funnelStage: item.funnelStage || 'tofu',
            suggestedKeywords: Array.isArray(item.suggestedKeywords) ? item.suggestedKeywords : [],
            suggestedCTA: item.suggestedCTA || 'Learn more',
            status: 'generated',
            order: i,
            companyId,
            aiModel: result.model || 'unknown',
            createdAt: new Date().toISOString(),
          }));

          try {
            let blogData = await BlogContentOS.findOne({ companyId });
            if (!blogData) {
              blogData = new BlogContentOS({ companyId, titles: [] });
            }
            if (!blogData.titles) blogData.titles = [];
            blogData.titles.push(...titles);
            await blogData.save();
          } catch (dbErr) {
            console.error('[Blog-Titles] Failed to save to DB:', dbErr);
          }
        }

        await recordBlogAiGeneration({
          companyId,
          analysisType: 'title-generation',
          entityId: strategyId || null,
          inputs: { companyName: company.name, description: instructions || undefined },
          analysis: { titleCount: titles.length, titles: titles.map((t: any) => t.title) },
          results: [result],
          startedAt,
        });

        completeJob(job.jobId, { titles, count: titles.length }, 'ai-generated');
        console.log(`[Blog-Titles] Job ${job.jobId} completed. Generated ${titles.length} titles.`);
      } catch (err: any) {
        console.error(`[Blog-Titles] Job ${job.jobId} failed:`, err.message);
        failJob(job.jobId, err.message || 'Title generation failed');
      }
    });
  }
);

// ============================================
// POST /generate-seo
// Generate SEO metadata for a blog title in background
// ============================================

router.post(
  '/generate-seo',
  requirePermission('blog-content-os', 'ai-generate'),
  [
    body('companyId').notEmpty().withMessage('Company ID is required'),
    body('titleId').notEmpty().withMessage('Title ID is required'),
  ],
  async (req: Request, res: Response) => {
    const errors = validationResult(req);
    if (!errors.isEmpty()) {
      res.status(400).json({ error: 'Validation failed', details: errors.array() });
      return;
    }

    const { companyId, titleId, titleData, context, language } = req.body;
    const job = createJob('blog-seo', companyId, req.body._moduleId || 'blog-content-os');

    res.status(202).json({ jobId: job.jobId, status: 'processing' });

    setImmediate(async () => {
      const startedAt = Date.now();
      try {
        updateJobProgress(job.jobId, 10, 'Preparing SEO context...');

        const { BlogContentOS } = getModels();

        // Get title data - either from request or from DB
        let title = titleData;
        if (!title) {
          const blogData = await BlogContentOS.findOne({ companyId });
          if (blogData?.titles) {
            title = blogData.titles.find((t: any) => t.id === titleId || t._id?.toString() === titleId);
          }
        }

        if (!title) {
          failJob(job.jobId, 'Title not found');
          return;
        }

        updateJobProgress(job.jobId, 20, 'Building SEO prompt...');

        // Build context for SEO generation
        const businessContext = context?.businessProfile ? {
          name: context.businessProfile.name,
          industry: context.businessProfile.primaryIndustry,
          usp: context.businessProfile.usp,
          mission: context.businessProfile.mission,
        } : null;

        const brandContext = context?.brand ? {
          voice: context.brand.voice,
          personality: context.brand.personality,
          primaryColor: context.brand.primaryColor,
        } : null;

        // Build language instruction if non-English
        const seoLanguageInstruction = (() => {
          const lang = language || 'en';
          if (lang === 'en') return '';
          const LANGUAGE_NAMES: Record<string, string> = {
            'hi': 'Hindi using Devanagari script (हिंदी देवनागरी लिपि)',
            'mr': 'Marathi using Devanagari script (मराठी देवनागरी लिपि)',
          };
          const langName = LANGUAGE_NAMES[lang] || lang;
          return `\n\nIMPORTANT LANGUAGE REQUIREMENT: Generate ALL content (meta title, meta description, keywords, heading suggestions, content gap analysis) entirely in ${langName}. Do NOT use English unless it is a technical term or proper noun. All text must be natural, fluent, and appropriate for B2B business contexts in the specified language.`;
        })();

        const prompt = `You are an expert SEO content strategist. Generate comprehensive SEO metadata for a blog post.

BLOG TITLE: "${title.title}"
${title.excerpt ? `EXCERPT: ${title.excerpt}` : ''}
CONTENT TYPE: ${title.contentType || 'educational'}
SEARCH INTENT: ${title.searchIntent || 'informational'}
FUNNEL STAGE: ${title.funnelStage || 'tofu'}
EXISTING KEYWORDS: ${title.suggestedKeywords?.join(', ') || 'None'}

${businessContext ? `BUSINESS CONTEXT:
- Business: ${businessContext.name}
- Industry: ${businessContext.industry || 'General'}
${businessContext.usp ? `- USP: ${businessContext.usp}` : ''}
${businessContext.mission ? `- Mission: ${businessContext.mission}` : ''}` : ''}

${brandContext ? `BRAND CONTEXT:
- Voice: ${brandContext.voice || 'Professional'}
- Personality: ${brandContext.personality || 'Friendly'}` : ''}
${seoLanguageInstruction}
You MUST respond with ONLY valid JSON. No markdown, no explanation, Generate comprehensive SEO metadata:

{
  "metaTitle": "SEO-optimized title (50-60 characters)",
  "metaDescription": "Compelling description for search results (150-160 characters)",
  "focusKeyword": "Primary target keyword",
  "secondaryKeywords": ["secondary keyword 1", "secondary keyword 2", "secondary keyword 3"],
  "contentSuggestions": {
    "recommendedWordCount": 2000,
    "headingStructure": ["H1: Main Title", "H2: Introduction", "H2: Key Points", "H2: Conclusion"],
    "internalLinkSuggestions": ["Link to related article 1", "Link to related article 2"],
    "externalLinkSuggestions": ["Authority source 1", "Authority source 2"],
    "imageAltTextSuggestions": ["Descriptive alt text for featured image"]
  },
  "searchAnalysis": {
    "searchVolume": "estimated monthly search volume",
    "keywordDifficulty": "easy/medium/hard",
    "cpc": "estimated cost per click",
    "competition": "low/medium/high"
  },
  "contentGapAnalysis": {
    "missingTopics": ["topic 1", "topic 2"],
    "suggestedSections": ["section 1", "section 2"],
    "competitorInsights": "Brief insight from competitor analysis"
  }
}`;

        updateJobProgress(job.jobId, 40, 'Generating SEO metadata...');

        const userId = req.user?._id?.toString() || req.user?.id;
        const result = await generateWithAI(prompt, 'You are an expert SEO strategist. Always respond with valid JSON.', 4000, undefined, undefined, undefined, undefined, userId);

        updateJobProgress(job.jobId, 80, 'Parsing response...');

        let seoData;
        const content = result.content || '';

        try {
          let jsonStr = content.replace(/^```(?:json)?\s*\n?/i, '').replace(/\n?```\s*$/i, '').trim();
          seoData = JSON.parse(jsonStr);
        } catch {
          const match = content.match(/\{[\s\S]*\}/);
          if (match) {
            seoData = JSON.parse(match[0]);
          } else {
            throw new Error('Could not parse SEO data from AI response');
          }
        }

        updateJobProgress(job.jobId, 90, 'Saving SEO data...');

        // Create SEO record
        const seoRecord = {
          id: `seo-${Date.now()}-${Math.random().toString(36).slice(2, 9)}`,
          titleId,
          companyId,
          ...seoData,
          aiGenerated: true,
          createdAt: new Date().toISOString(),
        };

        // Save to database
        try {
          let blogData = await BlogContentOS.findOne({ companyId });
          if (!blogData) {
            blogData = new BlogContentOS({ companyId, seoConfigs: [] });
          }
          if (!blogData.seoConfigs) blogData.seoConfigs = [];
          blogData.seoConfigs.push(seoRecord);
          await blogData.save();
        } catch (dbErr) {
          console.error('[Blog-SEO] Failed to save to DB:', dbErr);
        }

        await recordBlogAiGeneration({
          companyId,
          analysisType: 'seo-generation',
          entityId: titleId || null,
          inputs: { description: title.title },
          analysis: { titleId, primaryKeywords: seoData?.primaryKeywords, searchIntent: seoData?.searchIntent },
          results: [result],
          startedAt,
        });

        completeJob(job.jobId, { seoRecord }, 'ai-generated');
        console.log(`[Blog-SEO] Job ${job.jobId} completed for title ${titleId}.`);
      } catch (err: any) {
        console.error(`[Blog-SEO] Job ${job.jobId} failed:`, err.message);
        failJob(job.jobId, err.message || 'SEO generation failed');
      }
    });
  }
);

// ============================================
// POST /generate-content
// Generate blog post content in background
// Supports both structure-based and fallback (no-structure) generation
// ============================================

router.post(
  '/generate-content',
  requirePermission('blog-content-os', 'ai-generate'),
  [
    body('companyId').notEmpty().withMessage('Company ID is required'),
    body('titleId').notEmpty().withMessage('Title ID is required'),
  ],
  async (req: Request, res: Response) => {
    const errors = validationResult(req);
    if (!errors.isEmpty()) {
      res.status(400).json({ error: 'Validation failed', details: errors.array() });
      return;
    }

    const {
      companyId, strategyId, titleId, titleData, structureData, context,
      customInstructions, contentDepth, language, postId,
      seoConfig, // SEO keywords and rules
    } = req.body;
    const job = createJob('blog-content', companyId, req.body._moduleId || 'blog-content-os');

    res.status(202).json({ jobId: job.jobId, status: 'processing' });

    setImmediate(async () => {
      const startedAt = Date.now();
      // Every successful AI call in this job, so the AI Processing record can
      // report real provider/model/token totals for the whole article.
      const aiResults: AIResult[] = [];
      try {
        updateJobProgress(job.jobId, 5, 'Loading content context...');

        const { BlogContentOS } = getModels();

        // Get title and structure data from request or database
        let title = titleData;
        let structure = structureData;

        console.log(`[Blog-Content] Job ${job.jobId}: Received structureData=${structureData ? `${structureData.sections?.length || 0} sections` : 'none'}, titleData=${titleData ? titleData.title : 'none'}`);

        if (!title || !structure) {
          const blogData = await BlogContentOS.findOne({ companyId });
          if (blogData?.titles) {
            title = title || blogData.titles.find((t: any) => t.id === titleId || t._id?.toString() === titleId);
          }
          if (blogData?.structures) {
            structure = structure || blogData.structures.find((s: any) => s.titleId === titleId);
          }
          console.log(`[Blog-Content] Job ${job.jobId}: After DB lookup: structure=${structure ? `${structure.sections?.length || 0} sections` : 'none'}, title=${title ? title.title : 'none'}`);
        }

        if (!title) {
          failJob(job.jobId, 'Title not found');
          return;
        }

        // Build rich context from request payload
        const brandVoice = context?.brand?.voice || 'professional';
        const brandPersonality = context?.brand?.personality || 'helpful';
        const businessName = context?.businessProfile?.name || 'Our Company';
        const depth = contentDepth || 'standard';

        // Build context string from all available data
        const contextParts: string[] = [];
        if (context?.brand) {
          if (context.brand.voice) contextParts.push(`Brand Voice: ${context.brand.voice}`);
          if (context.brand.personality) contextParts.push(`Brand Personality: ${context.brand.personality}`);
          if (context.brand.tagline) contextParts.push(`Brand Tagline: ${context.brand.tagline}`);
          if (context.brand.purposeWhyExists) contextParts.push(`Brand Purpose: ${context.brand.purposeWhyExists}`);
        }
        if (context?.businessProfile) {
          if (context.businessProfile.primaryIndustry) contextParts.push(`Industry: ${context.businessProfile.primaryIndustry}`);
          if (context.businessProfile.name) contextParts.push(`Company: ${context.businessProfile.name}`);
          if (context.businessProfile.description) contextParts.push(`Business Description: ${context.businessProfile.description.slice(0, 200)}`);
        }
        if (context?.icps && context.icps.length > 0) {
          contextParts.push(`Target ICPs: ${context.icps.map((i: any) => `${i.name}${i.description ? ` (${i.description.slice(0, 50)}...)` : ''}`).join(', ')}`);
        }
        if (context?.personas && context.personas.length > 0) {
          contextParts.push(`Target Personas: ${context.personas.map((p: any) => `${p.name}${p.jobTitle ? ` (${p.jobTitle})` : ''}`).join(', ')}`);
        }
        if (context?.competitors && context.competitors.length > 0) {
          contextParts.push(`Key Competitors: ${context.competitors.map((c: any) => c.name).join(', ')}`);
        }
        if (context?.products && context.products.length > 0) {
          contextParts.push(`Products/Services: ${context.products.slice(0, 5).map((p: any) => p.name).join(', ')}`);
        }
        if (context?.goals) contextParts.push(`Goals: ${Array.isArray(context.goals) ? context.goals.join(', ') : context.goals}`);
        if (context?.targetAudience) contextParts.push(`Target Audience: ${Array.isArray(context.targetAudience) ? context.targetAudience.join(', ') : context.targetAudience}`);
        if (context?.funnelStage) contextParts.push(`Funnel Stage: ${context.funnelStage}`);

        // SEO context from config
        if (seoConfig?.primaryKeywords?.length) {
          contextParts.push(`Primary Keywords: ${seoConfig.primaryKeywords.join(', ')}`);
        }
        if (seoConfig?.secondaryKeywords?.length) {
          contextParts.push(`Secondary Keywords: ${seoConfig.secondaryKeywords.join(', ')}`);
        }
        // The SEO step is title-wise, so this config belongs to this article's own
        // title — the rest of its keyword set applies directly to this post.
        if (seoConfig?.additionalKeywords?.length) {
          contextParts.push(`Additional Keywords: ${seoConfig.additionalKeywords.join(', ')}`);
        }
        if (seoConfig?.longTailKeywords?.length) {
          contextParts.push(`Long-Tail Keywords: ${seoConfig.longTailKeywords.slice(0, 10).join(', ')}`);
        }
        if (seoConfig?.negativeKeywords?.length) {
          contextParts.push(`Negative Keywords (never optimise for these): ${seoConfig.negativeKeywords.join(', ')}`);
        }

        // Title-level SEO data
        const seoKeywords = title.suggestedKeywords || title.secondaryKeywords || [];
        const seoIntent = title.searchIntent || 'informational';
        if (seoKeywords.length > 0) {
          contextParts.push(`Title Keywords: ${seoKeywords.slice(0, 5).join(', ')}`);
        }
        if (seoIntent) {
          contextParts.push(`Search Intent: ${seoIntent}`);
        }

        const contextStr = contextParts.join('\n');

        // Build language instruction
        const lang = language || 'en';
        const languageInstruction = lang === 'en'
          ? ''
          : `\nIMPORTANT: Generate all content in ${lang === 'es' ? 'Spanish' : lang === 'fr' ? 'French' : lang === 'de' ? 'German' : lang === 'pt' ? 'Portuguese' : lang === 'hi' ? 'Hindi' : lang === 'ar' ? 'Arabic' : lang === 'zh' ? 'Chinese' : lang === 'ja' ? 'Japanese' : 'the specified language'}. All headings, descriptions, and text must be in ${lang === 'es' ? 'Spanish' : lang === 'fr' ? 'French' : lang === 'de' ? 'German' : lang === 'pt' ? 'Portuguese' : lang === 'hi' ? 'Hindi' : 'the specified language'}.`;

        // Depth-specific instructions
        const depthInstructions: Record<string, string> = {
          'brief': 'Be concise but complete. Cover key points thoroughly.',
          'standard': 'Provide a balanced, well-researched explanation with multiple examples, practical insights, and actionable takeaways.',
          'deep': 'Write a comprehensive, detailed analysis with multiple examples, data points, actionable insights, and nuanced explanations. Go deep into the topic.',
          'comprehensive': 'Create an exhaustive, authoritative treatment of the topic. Include expert perspectives, case studies, detailed explanations, practical frameworks, and thorough analysis.',
        };

        // Section-type-specific writing guidance
        const sectionTypeGuidance: Record<string, string> = {
          'intro': 'Start with a compelling hook that grabs attention. Provide context and preview what the reader will learn. End with a clear thesis statement.',
          'problem': 'Clearly define the problem with specific, relatable examples. Use data or statistics to underscore the severity. Paint a vivid picture of the consequences.',
          'explanation': 'Provide thorough background, key concepts, and foundational knowledge. Break down complex ideas into digestible parts. Use analogies and examples.',
          'benefits': 'Present benefits with specific examples and measurable outcomes. Use data where possible. Connect each benefit to the reader\'s needs.',
          'steps': 'Provide a clear, numbered sequence with detailed instructions for each step. Include tips, common mistakes to avoid, and practical examples.',
          'examples': 'Share detailed real-world examples or case studies. Include specific metrics, outcomes, and lessons learned. Make them relatable.',
          'conclusion': 'Summarize the key insights from each major section. Provide a clear call-to-action. End with a memorable closing statement.',
          'faq': 'Address common questions with thorough, helpful answers. Anticipate follow-up questions and address them proactively.',
        };

        // Helper: strip AI content wrapping
        const stripAiContentWrapping = (text: string): string => {
          return text
            .replace(/^```(?:markdown|text|json)?\s*\n?/i, '')
            .replace(/\n?```\s*$/i, '')
            .replace(/^```(?:markdown|text|json)?\s*\n?/gim, '')
            .trim();
        };

        // ============================================================
        // STRUCTURE-BASED CONTENT GENERATION (PARALLEL SECTIONS)
        // ============================================================
        if (structure && structure.sections && structure.sections.length > 0) {
          const generatableSections = structure.sections.filter((s: any) => s.generateContent !== false);
          console.log(`[Blog-Content] Job ${job.jobId}: Structure-based generation for "${title.title}" with ${structure.sections.length} total sections, ${generatableSections.length} generatable`);
          updateJobProgress(job.jobId, 10, 'Preparing structure-based content...');

          const sections = structure.sections;
          const totalSections = generatableSections.length;

          // Calculate total target words from structure
          const totalTargetWords = sections.reduce((sum: number, s: any) => sum + (s.targetWordCount || 0), 0);

          // Build full article structure context (shared across all sections)
          const fullStructure = sections.map((s: any, i: number) =>
            `${i + 1}. "${s.title}" (${s.type})${s.description ? ` - ${s.description}` : ''} [${s.targetWordCount || 200} words]`
          ).join('\n');

          // Pre-build static content for non-generatable sections
          const staticSections: any[] = [];
          let staticContent = '';
          for (const structureSection of sections) {
            if (!structureSection.generateContent) {
              if (structureSection.type === 'title') {
                staticContent += `\n\n# ${title.title}\n\n`;
                staticSections.push({
                  id: `sec-${Date.now()}-${staticSections.length}`,
                  type: 'heading',
                  content: title.title,
                  order: structureSection.order,
                  level: 1,
                });
              } else if (structureSection.type === 'intro') {
                // The intro never carries a heading of its own — same rule the
                // generated path below follows, and the rule the Structure step's
                // Table of Contents is built on. Emitting one here would put an
                // entry in the article that the outline's TOC does not list.
              } else {
                const headingPrefix = '#'.repeat(structureSection.headingLevel || 2);
                staticContent += `\n\n${headingPrefix} ${structureSection.title}\n\n`;
                staticSections.push({
                  id: `sec-${Date.now()}-${staticSections.length}`,
                  type: 'heading',
                  content: structureSection.title,
                  order: structureSection.order,
                  level: structureSection.headingLevel || 2,
                });
              }
            }
          }

          updateJobProgress(job.jobId, 15, `Generating ${totalSections} sections in parallel...`);

          // Generate all sections in parallel for massive speed improvement
          const userId = req.user?._id?.toString() || req.user?.id;
          const sectionPromises = generatableSections.map(async (structureSection: any) => {
            const targetWords = structureSection.targetWordCount || 500;
            // Reduced multiplier from 6x to 4x — 6x was over-allocating tokens and slowing down AI calls
            const maxTokens = Math.max(targetWords * 4, 3000);
            const sectionGuidance = sectionTypeGuidance[structureSection.type] || 'Provide comprehensive, detailed content on this topic.';

            // Handle imported data (FAQs, case studies)
            let importedDataContext = '';
            if (structureSection.importedData && structureSection.importedData.ids && structureSection.importedData.ids.length > 0) {
              const importedData = structureSection.importedData;
              if (importedData.type === 'faqs') {
                const faqContent = (importedData.manualItems || []).concat(importedData.aiItems || [])
                  .map((f: any) => `Q: ${f.question}\nA: ${f.answer}`)
                  .join('\n\n');
                if (faqContent) {
                  importedDataContext = `\nIMPORTED FAQs FOR THIS SECTION:\n${faqContent}\n\nUse the imported FAQs above as the foundation for this section. Incorporate the questions and answers naturally, expand on them where appropriate, and ensure they fit seamlessly into the overall content flow.`;
                }
              } else if (importedData.type === 'caseStudies') {
                const csContent = (importedData.manualItems || []).concat(importedData.aiItems || [])
                  .map((cs: any) => `${cs.title}${cs.description ? `: ${cs.description}` : ''}`)
                  .join('\n\n');
                if (csContent) {
                  importedDataContext = `\nIMPORTED CASE STUDIES FOR THIS SECTION:\n${csContent}\n\nUse the imported case studies above as real-world examples. Present them with specific details, metrics, and outcomes. Make them compelling and relevant to the reader.`;
                }
              }
            }

            const sectionPrompt = `You are an expert content writer creating HIGH-QUALITY, COMPREHENSIVE content for a blog post section.

ARTICLE TITLE: ${title.title}
ARTICLE TYPE: ${title.contentType || 'article'}
BRAND VOICE: ${brandVoice}
CONTENT DEPTH: ${depth} - ${depthInstructions[depth] || depthInstructions['standard']}

COMPLETE ARTICLE STRUCTURE:
${fullStructure}

THIS SECTION'S POSITION: Section ${structureSection.order} of ${sections.length}

${contextStr ? `BUSINESS & MARKETING CONTEXT:\n${contextStr}\n` : ''}${title.metaDescription ? `META DESCRIPTION: ${title.metaDescription}\n` : ''}${seoConfig?.primaryKeywords?.length ? `PRIMARY KEYWORDS FOR ARTICLE: ${seoConfig.primaryKeywords.slice(0, 3).join(', ')}\n` : ''}
SECTION TO WRITE:
- Section Type: ${structureSection.type}
- Section Title: ${structureSection.title}
${structureSection.description ? `- Section Purpose: ${structureSection.description}` : ''}
- Target Word Count: **${targetWords} words MINIMUM**
- Heading Level: H${structureSection.headingLevel || 2}
${structureSection.keywords?.length ? `- Keywords for this section: ${structureSection.keywords.join(', ')}` : ''}
${structureSection.aiInstructions ? `- Special Instructions: ${structureSection.aiInstructions}` : ''}
${importedDataContext}
SECTION WRITING GUIDANCE:
${sectionGuidance}

CRITICAL REQUIREMENTS:
1. **WORD COUNT**: Write AT LEAST ${targetWords} words. This is NON-NEGOTIABLE. If you write less, you FAIL.
2. **DEPTH**: Provide detailed, substantive content. Expand on ideas fully. Do not be superficial.
3. **FORMAT**: Use markdown - **bold** for emphasis, *italic* for terms, bullet points and numbered lists where appropriate.
3a. **TABLES**: Use a markdown table whenever the content is genuinely tabular — comparisons, feature or pricing breakdowns, pros vs cons, specifications, metrics, timelines, before/after. Do NOT force a table where prose or a list reads better, and do not restate the table in prose afterwards. Every table MUST use this exact syntax or it will not render: a header row, then a separator row, then the data rows, with a leading AND trailing pipe on every line and the same number of columns in each:
| Column A | Column B |
|---|---|
| Value A1 | Value B1 |
| Value A2 | Value B2 |
Keep cell text short (a few words), give every column a clear header, and put a blank line before and after the table.
4. **EXAMPLES**: Include specific examples, statistics, case studies, or data points to support claims.
5. **ACTIONABLE**: Give readers clear takeaways and practical guidance they can apply.
6. **FLOW**: Write content that flows naturally from the previous section and sets up the next section.
7. **BRAND VOICE**: Maintain a ${brandVoice} tone throughout - professional, engaging, and authoritative.
${structureSection.keywords?.length ? `8. **KEYWORDS**: Naturally incorporate these keywords: ${structureSection.keywords.join(', ')}` : ''}
${languageInstruction}
Write the COMPLETE content for this section now. Focus on quality, depth, and providing genuine value.
Write ONLY the section content - no heading (that will be added separately).
Do NOT wrap your response in JSON or fenced code blocks - return the raw markdown body only. Inline markdown (bold, italic, lists, and the tables described above) IS expected.
Start immediately with the content. No introductions or explanations.`;

            try {
              const result = await generateWithAI(sectionPrompt, `You are a ${brandVoice} content writer. Write detailed, comprehensive content. Never use placeholder text.`, maxTokens, undefined, 'text', undefined, undefined, userId);
              aiResults.push(result);
              const sectionContent = stripAiContentWrapping(result.content || '');
              const actualWordCount = sectionContent.split(/\s+/).filter(Boolean).length;
              console.log(`[Blog-Content] Section "${structureSection.title}": ${actualWordCount} words (target: ${targetWords})`);
              return { structureSection, content: sectionContent, wordCount: actualWordCount, error: null };
            } catch (sectionErr: any) {
              console.error(`[Blog-Content] Section "${structureSection.title}" failed:`, sectionErr);
              return { structureSection, content: '', wordCount: 0, error: sectionErr?.message || 'Section generation failed' };
            }
          });

          // Wait for all sections to complete in parallel
          const sectionResults = await Promise.allSettled(sectionPromises);

          // Process results and build the final content in order
          const allSections: any[] = [...staticSections];
          let combinedContent = staticContent;
          let totalWordCount = 0;

          for (const result of sectionResults) {
            if (result.status !== 'fulfilled' || !result.value) continue;
            const { structureSection: section, content: sectionContent, wordCount } = result.value;
            if (!sectionContent) continue;

            if (section.type === 'title') {
              combinedContent += `\n\n# ${title.title}\n\n`;
              allSections.push({
                id: `sec-${Date.now()}-${allSections.length}`,
                type: 'heading',
                content: title.title,
                order: section.order,
                level: 1,
              });
            } else if (section.type === 'intro') {
              combinedContent += `\n\n${sectionContent}`;
              allSections.push({
                id: `sec-${Date.now()}-${allSections.length}`,
                type: 'paragraph',
                content: sectionContent,
                order: section.order,
              });
            } else {
              const headingPrefix = '#'.repeat(section.headingLevel || 2);
              combinedContent += `\n\n${headingPrefix} ${section.title}\n\n${sectionContent}`;
              allSections.push({
                id: `sec-${Date.now()}-${allSections.length}`,
                type: 'heading',
                content: section.title,
                order: section.order,
                level: section.headingLevel || 2,
              });
              allSections.push({
                id: `sec-${Date.now()}-${allSections.length}`,
                type: 'paragraph',
                content: sectionContent,
                order: section.order + 0.5,
              });
            }
            totalWordCount += wordCount;
          }

          updateJobProgress(job.jobId, 85, 'Assembling content...');

          // EXPANSION: If total word count is significantly below target, expand short sections
          const expansionThreshold = Math.max(totalTargetWords * 0.8, 2000);
          if (totalWordCount < expansionThreshold && totalTargetWords >= 1500) {
            console.log(`[Blog-Content] Total (${totalWordCount} words) below threshold (${expansionThreshold}). Expanding...`);
            updateJobProgress(job.jobId, 88, 'Expanding short sections...');

            const paragraphSections = allSections.filter((s: any) => s.type === 'paragraph');
            for (const section of paragraphSections) {
              if (totalWordCount >= totalTargetWords) break;
              const sectionWords = section.content.split(/\s+/).filter(Boolean).length;
              const sectionTarget = Math.max(Math.ceil(totalTargetWords / Math.max(paragraphSections.length, 3)), sectionWords * 3);
              if (sectionWords >= sectionTarget) continue;

              const expandPrompt = `You are an expert content writer. EXPAND the following blog section to be much more detailed and comprehensive. Add specific examples, data points, actionable tips, expert insights, and thorough analysis. The target is ${sectionTarget} words for this section.

CRITICAL RULES:
1. NEVER use placeholders like [Contact us], [Your Name], or any bracketed text
2. Write REAL, detailed content that provides genuine value
3. Maintain the same heading and structure but add much more depth
4. Include specific examples, statistics, quotes from experts, and actionable advice

ORIGINAL SECTION:
${section.content}

Write the EXPANDED section now (at least ${sectionTarget} words). Output ONLY the expanded content as plain text - do NOT wrap in JSON or code blocks. No heading or preamble:`;

              try {
                const userId = req.user?._id?.toString() || req.user?.id;
                const expandRes = await generateWithAI(expandPrompt, 'You are an expert content writer. Write detailed, comprehensive content. Never use placeholder text. Respond with ONLY plain text - no JSON, no code blocks.', Math.max(sectionTarget * 4, 4000), 0.8, 'text', undefined, undefined, userId);
                aiResults.push(expandRes);
                const expandedContent = stripAiContentWrapping(expandRes.content || '');
                const expandedWords = expandedContent.split(/\s+/).filter(Boolean).length;
                if (expandedContent && expandedWords > sectionWords) {
                  const diff = expandedWords - sectionWords;
                  section.content = expandedContent;
                  totalWordCount += diff;
                }
              } catch (expandErr) {
                console.warn(`[Blog-Content] Section expansion failed, keeping original:`, expandErr);
              }
            }
            console.log(`[Blog-Content] After expansion: ${totalWordCount} words`);
          }

          const readingTime = Math.ceil(totalWordCount / 200);

          // If every section failed, fail the job WITHOUT writing anything. This
          // check used to sit after the save below, which persisted a 0-word post
          // as status 'draft' — the post then left the 'planning' state that the
          // Content step's Generate action keys off, and had no content for the
          // Regenerate action either, so a failed generation could never be retried.
          // Leaving the existing post untouched keeps it retryable.
          if (totalWordCount === 0) {
            console.error(`[Blog-Content] Job ${job.jobId} failed: All sections generated 0 words (AI providers likely failed).`);
            failJob(job.jobId, 'Content generation produced no content. All AI providers may have failed — please check your API keys and credits, then try again.');
            return;
          }

          // Embed the Table of Contents. It is read back off the assembled
          // markdown rather than off the structure, so every entry points at a
          // heading that is really in the article — including any sub-headings a
          // section wrote for itself, which the outline could not know about.
          const { content: contentWithToc, tableOfContents } = injectTocIntoMarkdown(combinedContent, lang);

          // Build result post object
          const resultPost = {
            id: postId || `bp-${Date.now()}-${Math.random().toString(36).slice(2, 9)}`,
            titleId,
            // Carry the strategy through — a post without it is invisible to the
            // Content step, which lists posts by strategyId.
            strategyId,
            companyId,
            title: title.title,
            slug: title.slug || title.title.toLowerCase().replace(/[^\w\s-]/g, '').replace(/\s+/g, '-').substring(0, 60),
            contentType: title.contentType || 'article',
            content: contentWithToc,
            tableOfContents,
            sections: allSections,
            wordCount: totalWordCount,
            metaTitle: (title.title || '').slice(0, 60),
            metaDescription: title.metaDescription || '',
            primaryKeyword: title.suggestedKeywords?.[0] || seoConfig?.primaryKeywords?.[0] || '',
            secondaryKeywords: title.suggestedKeywords?.slice(1) || seoConfig?.secondaryKeywords || [],
            seoAnalysis: {
              readabilityScore: 72,
              readingTime,
              keywordDensity: {},
              headingStructureValid: true,
              internalLinkCount: 0,
              externalLinkCount: 0,
              suggestedSchema: ['Article'],
              wordCount: totalWordCount,
              paragraphCount: allSections.filter((s: any) => s.type === 'paragraph').length,
              avgSentenceLength: 18,
              passiveVoicePercentage: 12,
            },
            status: 'draft',
            aiGenerated: true,
            createdAt: new Date().toISOString(),
          };

          // Save to database with ATOMIC operators. Content jobs can overlap, and
          // read-modify-save on the shared per-company document lets concurrent
          // writers clobber each other (the loser fails the version check and the
          // article is dropped). Replace the matching post in place if it exists,
          // otherwise push it.
          try {
            // MERGE the generated fields into the existing post instead of
            // replacing the element. A whole-element replace wiped every field the
            // client owns — strategyId, scheduledDate, calendarId, contentTypeId,
            // order — so after a refresh the post no longer belonged to the
            // strategy, vanished from the Content step, and was regenerated.
            const postFields: Record<string, any> = {};
            for (const [key, value] of Object.entries(resultPost)) {
              if (key === 'id' || value === undefined) continue;
              postFields[`posts.$.${key}`] = value;
            }
            postFields['posts.$.updatedAt'] = new Date().toISOString();

            // Match by post id first, then by titleId — kept as separate queries
            // because the positional $ operator is unreliable inside $or.
            let res = await BlogContentOS.updateOne(
              { companyId, 'posts.id': resultPost.id },
              { $set: postFields },
            );
            if (!res.matchedCount) {
              res = await BlogContentOS.updateOne(
                { companyId, 'posts.titleId': titleId },
                { $set: postFields },
              );
            }
            if (!res.matchedCount) {
              await BlogContentOS.updateOne(
                { companyId },
                { $push: { posts: resultPost } },
                { upsert: true },
              );
            }
          } catch (dbErr) {
            console.error('[Blog-Content] Failed to save to DB:', dbErr);
          }

          await recordBlogAiGeneration({
            companyId,
            analysisType: 'content-generation',
            entityId: resultPost.id,
            inputs: { description: title.title },
            analysis: { title: title.title, wordCount: totalWordCount, sectionsGenerated: allSections.length },
            results: aiResults,
            startedAt,
          });

          updateJobProgress(job.jobId, 95, 'Content generation complete');
          completeJob(job.jobId, { post: resultPost, sectionsGenerated: allSections.length, totalWords: totalWordCount }, 'ai-generated');
          console.log(`[Blog-Content] Job ${job.jobId} completed. Generated ${allSections.length} sections, ${totalWordCount} words.`);

        } else {
          // ============================================================
          // FALLBACK: FULL CONTENT GENERATION (NO STRUCTURE)
          // ============================================================
          console.log(`[Blog-Content] Job ${job.jobId}: Fallback generation (no structure) for "${title.title}" — structure was ${structure ? 'empty' : 'missing'}`);
          updateJobProgress(job.jobId, 10, 'Preparing content outline...');

          // Calculate target word count based on content depth
          const depthWordCounts: Record<string, number> = {
            'brief': 1500,
            'standard': 3000,
            'deep': 6000,
            'comprehensive': 10000,
          };
          const targetWordCount = seoConfig?.seoRules?.minWordCount || depthWordCounts[depth] || 3000;
          const sectionCount = Math.max(Math.ceil(targetWordCount / 1500), 5);
          const wordsPerSection = Math.ceil(targetWordCount / sectionCount);

          // STEP 1: Generate article outline
          const outlinePrompt = `You are an expert content strategist. Create a detailed article outline for the following blog post.

BLOG POST TITLE: ${title.title}
${title.contentType ? `CONTENT TYPE: ${title.contentType}` : ''}
${title.excerpt ? `\nBRIEF/EXCERPT: ${title.excerpt}` : ''}
CONTENT DEPTH: ${depth} - ${depthInstructions[depth] || depthInstructions['standard']}
${contextStr ? `\nBUSINESS & MARKETING CONTEXT:\n${contextStr}\n` : ''}
${seoKeywords.length > 0 ? `PRIMARY KEYWORDS: ${seoKeywords.slice(0, 3).join(', ')}\n` : ''}
${seoIntent ? `SEARCH INTENT: ${seoIntent}` : ''}

TARGET TOTAL WORD COUNT: ${targetWordCount} words
NUMBER OF SECTIONS: ${sectionCount}
WORDS PER SECTION: ${wordsPerSection}

Create an outline with exactly ${sectionCount} sections. Each section should have a title, type, description, and target word count.

RESPOND WITH ONLY a valid JSON object in this exact format (no markdown, no explanation):
{
  "metaTitle": "SEO-optimised title (50-60 characters)",
  "metaDescription": "Compelling description for search results (150-160 characters)",
  "slug": "url-friendly-slug",
  "primaryKeyword": "main keyword",
  "secondaryKeywords": ["keyword1", "keyword2", "keyword3"],
  "outline": [
    {"order": 1, "type": "intro", "title": "Introduction", "description": "Hook the reader and introduce the topic", "targetWordCount": ${Math.ceil(wordsPerSection * 0.6)}},
    {"order": 2, "type": "explanation", "title": "Section title", "description": "Section purpose", "targetWordCount": ${wordsPerSection}},
    ...
    {"order": ${sectionCount}, "type": "conclusion", "title": "Conclusion", "description": "Summary and CTA", "targetWordCount": ${Math.ceil(wordsPerSection * 0.5)}}
  ]
}

The sum of all targetWordCount values MUST equal approximately ${targetWordCount}.${languageInstruction}`;

          let outlineData: any = null;
          try {
            const userId = req.user?._id?.toString() || req.user?.id;
            const outlineResult = await generateWithAI(outlinePrompt, `You are a content strategist. Respond with ONLY valid JSON, no markdown or explanation.`, 4000, undefined, undefined, undefined, undefined, userId);
            aiResults.push(outlineResult);
            const outlineContent = outlineResult.content || '';
            try {
              let cleaned = outlineContent.replace(/^```(?:json)?\s*\n?/i, '').replace(/\n?```\s*$/i, '').trim();
              const jsonMatch = cleaned.match(/\{[\s\S]*\}/);
              if (jsonMatch) outlineData = JSON.parse(jsonMatch[0]);
              else outlineData = JSON.parse(cleaned);
            } catch (parseErr) {
              console.error('[Blog-Content] Failed to parse outline:', parseErr);
            }
          } catch (outlineErr) {
            console.error('[Blog-Content] Outline generation failed:', outlineErr);
          }

          // Use the AI outline or fall back to a default structure
          const outline = outlineData?.outline || [];
          const metaTitle = outlineData?.metaTitle || (title.title || '').slice(0, 60);
          const metaDescription = outlineData?.metaDescription || '';
          const slug = outlineData?.slug || (title.title || '').toLowerCase().replace(/[^\w\s-]/g, '').replace(/\s+/g, '-').substring(0, 60);
          const primaryKeyword = outlineData?.primaryKeyword || seoKeywords[0] || '';
          const secondaryKeywords = outlineData?.secondaryKeywords || seoKeywords.slice(1) || [];

          // If outline generation failed, create a default outline
          const finalOutline = outline.length > 0 ? outline : [
            { order: 1, type: 'intro', title: 'Introduction', description: 'Hook the reader and introduce the topic', targetWordCount: Math.ceil(wordsPerSection * 0.6) },
            ...Array.from({ length: sectionCount - 2 }, (_, i) => ({
              order: i + 2,
              type: 'explanation',
              title: `Section ${i + 2}`,
              description: 'Detailed content section',
              targetWordCount: wordsPerSection,
            })),
            { order: sectionCount, type: 'conclusion', title: 'Conclusion', description: 'Summary and call-to-action', targetWordCount: Math.ceil(wordsPerSection * 0.5) },
          ];

          console.log(`[Blog-Content] Generated outline with ${finalOutline.length} sections. Target: ${targetWordCount} words`);

          // STEP 2: Generate content for each section IN PARALLEL
          let combinedContent = `\n\n# ${title.title}\n\n`;
          const allSections: any[] = [{
            id: `sec-${Date.now()}-0`,
            type: 'heading',
            content: title.title,
            order: 0,
            level: 1,
          }];

          // Build shared structure context
          const fallbackStructure = finalOutline.map((s: any) =>
            `${s.order}. "${s.title}" (${s.type})${s.description ? ` - ${s.description}` : ''} [${s.targetWordCount || wordsPerSection} words]`
          ).join('\n');

          updateJobProgress(job.jobId, 20, `Generating ${finalOutline.length} sections in parallel...`);

          // Generate all sections in parallel for massive speed improvement
          const fallbackUserId = req.user?._id?.toString() || req.user?.id;
          const fallbackSectionPromises = finalOutline.map(async (section: any) => {
            const sectionTarget = section.targetWordCount || wordsPerSection;
            // Reduced multiplier from 6x to 4x for faster generation
            const sectionMaxTokens = Math.max(sectionTarget * 4, 3000);
            const guidance = sectionTypeGuidance[section.type] || 'Provide comprehensive, detailed content on this topic.';

            const sectionPrompt = `You are an expert content writer creating HIGH-QUALITY, COMPREHENSIVE content for a blog post section.

ARTICLE TITLE: ${title.title}
ARTICLE TYPE: ${title.contentType || 'article'}
BRAND VOICE: ${brandVoice}
CONTENT DEPTH: ${depth} - ${depthInstructions[depth] || depthInstructions['standard']}

COMPLETE ARTICLE STRUCTURE:
${fallbackStructure}

THIS SECTION'S POSITION: Section ${section.order} of ${finalOutline.length}

${contextStr ? `BUSINESS & MARKETING CONTEXT:\n${contextStr}\n` : ''}${seoKeywords.length > 0 ? `PRIMARY KEYWORDS: ${seoKeywords.slice(0, 3).join(', ')}\n` : ''}
SECTION TO WRITE:
- Section Type: ${section.type}
- Section Title: ${section.title}
${section.description ? `- Section Purpose: ${section.description}` : ''}
- Target Word Count: **${sectionTarget} words MINIMUM**

SECTION WRITING GUIDANCE:
${guidance}

CRITICAL REQUIREMENTS:
1. **WORD COUNT**: Write AT LEAST ${sectionTarget} words. This is NON-NEGOTIABLE.
2. **DEPTH**: Provide detailed, substantive content. Expand on ideas fully. Do not be superficial.
3. **FORMAT**: Use markdown - **bold** for emphasis, *italic* for terms, bullet points and numbered lists where appropriate.
3a. **TABLES**: Use a markdown table whenever the content is genuinely tabular — comparisons, feature or pricing breakdowns, pros vs cons, specifications, metrics, timelines, before/after. Do NOT force a table where prose or a list reads better, and do not restate the table in prose afterwards. Every table MUST use this exact syntax or it will not render: a header row, then a separator row, then the data rows, with a leading AND trailing pipe on every line and the same number of columns in each:
| Column A | Column B |
|---|---|
| Value A1 | Value B1 |
| Value A2 | Value B2 |
Keep cell text short (a few words), give every column a clear header, and put a blank line before and after the table.
4. **EXAMPLES**: Include specific examples, statistics, case studies, or data points to support claims.
5. **ACTIONABLE**: Give readers clear takeaways and practical guidance they can apply.
6. **BRAND VOICE**: Maintain a ${brandVoice} tone throughout.
7. **NO PLACEHOLDERS**: Never use text like "[Contact us]", "[Your Name]", "[Insert]", "TODO", or "AI-generated".
${languageInstruction}
Write the COMPLETE content for this section now. Output ONLY the section content - no heading (that will be added separately). Do NOT wrap your response in JSON or fenced code blocks - return the raw markdown body only; inline markdown (bold, italic, lists, and the tables described above) IS expected. Start immediately with the content.`;

            try {
              const sectionResult = await generateWithAI(sectionPrompt, `You are a ${brandVoice} content writer. Write detailed, comprehensive content. Never use placeholder text.`, sectionMaxTokens, undefined, 'text', undefined, undefined, fallbackUserId);
              aiResults.push(sectionResult);
              const sectionContent = stripAiContentWrapping(sectionResult.content || '');
              const actualWordCount = sectionContent.split(/\s+/).filter(Boolean).length;
              return { section, content: sectionContent, wordCount: actualWordCount, error: null };
            } catch (sectionErr: any) {
              console.error(`[Blog-Content] Fallback section "${section.title}" failed:`, sectionErr);
              return { section, content: '', wordCount: 0, error: sectionErr?.message || 'Section generation failed' };
            }
          });

          // Wait for all sections in parallel
          const fallbackResults = await Promise.allSettled(fallbackSectionPromises);
          let totalWordCount = 0;

          for (const result of fallbackResults) {
            if (result.status !== 'fulfilled' || !result.value) continue;
            const { section, content: sectionContent, wordCount } = result.value;
            if (!sectionContent) continue;

            if (section.type === 'intro') {
              combinedContent += `\n\n${sectionContent}`;
              allSections.push({
                id: `sec-${Date.now()}-${allSections.length}`,
                type: 'paragraph',
                content: sectionContent,
                order: section.order,
              });
            } else {
              combinedContent += `\n\n## ${section.title}\n\n${sectionContent}`;
              allSections.push({
                id: `sec-${Date.now()}-${allSections.length}`,
                type: 'heading',
                content: section.title,
                order: section.order,
                level: 2,
              });
              allSections.push({
                id: `sec-${Date.now()}-${allSections.length}`,
                type: 'paragraph',
                content: sectionContent,
                order: section.order + 0.5,
              });
            }
            totalWordCount += wordCount;
          }

          updateJobProgress(job.jobId, 85, 'Assembling content...');

          // EXPANSION: If total is below target, expand short sections
          const expansionThreshold = Math.max(targetWordCount * 0.8, 2000);
          if (totalWordCount < expansionThreshold && targetWordCount >= 1500) {
            console.log(`[Blog-Content] Fallback total (${totalWordCount} words) below threshold (${expansionThreshold}). Expanding...`);
            updateJobProgress(job.jobId, 88, 'Expanding short sections...');

            const paragraphSections = allSections.filter((s: any) => s.type === 'paragraph');
            for (const section of paragraphSections) {
              if (totalWordCount >= targetWordCount) break;
              const sectionWords = section.content.split(/\s+/).filter(Boolean).length;
              const sectionTarget = Math.max(Math.ceil(targetWordCount / Math.max(paragraphSections.length, 3)), sectionWords * 3);
              if (sectionWords >= sectionTarget) continue;

              const expandPrompt = `You are an expert content writer. EXPAND the following blog section to be much more detailed and comprehensive. Add specific examples, data points, actionable tips, expert insights, and thorough analysis. The target is ${sectionTarget} words.

CRITICAL RULES:
1. NEVER use placeholders like [Contact us], [Your Name], or any bracketed text
2. Write REAL, detailed content that provides genuine value
3. Maintain the same heading and structure but add much more depth
4. Include specific examples, statistics, quotes from experts, and actionable advice

ORIGINAL SECTION:
${section.content}

Write the EXPANDED section now (at least ${sectionTarget} words). Output ONLY the expanded content as plain text - do NOT wrap in JSON or code blocks:`;

              try {
                const userId = req.user?._id?.toString() || req.user?.id;
                const expandRes = await generateWithAI(expandPrompt, 'You are an expert content writer. Write detailed, comprehensive content. Never use placeholder text. Respond with ONLY plain text - no JSON, no code blocks.', Math.max(sectionTarget * 4, 4000), 0.8, 'text', undefined, undefined, userId);
                aiResults.push(expandRes);
                const expandedContent = stripAiContentWrapping(expandRes.content || '');
                const expandedWords = expandedContent.split(/\s+/).filter(Boolean).length;
                if (expandedContent && expandedWords > sectionWords) {
                  const diff = expandedWords - sectionWords;
                  section.content = expandedContent;
                  totalWordCount += diff;
                }
              } catch (expandErr) {
                console.warn(`[Blog-Content] Fallback section expansion failed:`, expandErr);
              }
            }
          }

          const readingTime = Math.ceil(totalWordCount / 200);

          // Same rule as the structure-based path above: a generation that
          // produced nothing must not be persisted, or the post is left as an
          // empty 'draft' that can neither be generated nor regenerated.
          if (totalWordCount === 0) {
            console.error(`[Blog-Content] Job ${job.jobId} failed: All fallback sections generated 0 words (AI providers likely failed).`);
            failJob(job.jobId, 'Content generation produced no content. All AI providers may have failed — please check your API keys and credits, then try again.');
            return;
          }

          // Same Table of Contents treatment as the structure-based path above —
          // built from the headings the article actually ended up with.
          const { content: contentWithToc, tableOfContents } = injectTocIntoMarkdown(combinedContent, lang);

          // Build result post object
          const resultPost = {
            id: postId || `bp-${Date.now()}-${Math.random().toString(36).slice(2, 9)}`,
            titleId,
            // Carry the strategy through — a post without it is invisible to the
            // Content step, which lists posts by strategyId.
            strategyId,
            companyId,
            title: title.title,
            slug,
            contentType: title.contentType || 'article',
            content: contentWithToc,
            tableOfContents,
            sections: allSections,
            wordCount: totalWordCount,
            metaTitle,
            metaDescription,
            primaryKeyword,
            secondaryKeywords,
            seoAnalysis: {
              readabilityScore: 72,
              readingTime,
              keywordDensity: { [primaryKeyword || 'marketing']: 2.4 },
              headingStructureValid: true,
              internalLinkCount: 0,
              externalLinkCount: 0,
              suggestedSchema: ['Article', 'FAQPage'],
              wordCount: totalWordCount,
              paragraphCount: allSections.filter((s: any) => s.type === 'paragraph').length,
              avgSentenceLength: 18,
              passiveVoicePercentage: 12,
            },
            status: 'draft',
            aiGenerated: true,
            createdAt: new Date().toISOString(),
          };

          // Save to database with ATOMIC operators (same reason as the
          // structure-based path above — overlapping content jobs must not
          // clobber each other's posts).
          try {
            // MERGE the generated fields into the existing post instead of
            // replacing the element. A whole-element replace wiped every field the
            // client owns — strategyId, scheduledDate, calendarId, contentTypeId,
            // order — so after a refresh the post no longer belonged to the
            // strategy, vanished from the Content step, and was regenerated.
            const postFields: Record<string, any> = {};
            for (const [key, value] of Object.entries(resultPost)) {
              if (key === 'id' || value === undefined) continue;
              postFields[`posts.$.${key}`] = value;
            }
            postFields['posts.$.updatedAt'] = new Date().toISOString();

            // Match by post id first, then by titleId — kept as separate queries
            // because the positional $ operator is unreliable inside $or.
            let res = await BlogContentOS.updateOne(
              { companyId, 'posts.id': resultPost.id },
              { $set: postFields },
            );
            if (!res.matchedCount) {
              res = await BlogContentOS.updateOne(
                { companyId, 'posts.titleId': titleId },
                { $set: postFields },
              );
            }
            if (!res.matchedCount) {
              await BlogContentOS.updateOne(
                { companyId },
                { $push: { posts: resultPost } },
                { upsert: true },
              );
            }
          } catch (dbErr) {
            console.error('[Blog-Content] Failed to save to DB:', dbErr);
          }

          await recordBlogAiGeneration({
            companyId,
            analysisType: 'content-generation',
            entityId: resultPost.id,
            inputs: { description: title.title },
            analysis: { title: title.title, wordCount: totalWordCount, sectionsGenerated: allSections.length },
            results: aiResults,
            startedAt,
          });

          updateJobProgress(job.jobId, 95, 'Content generation complete');
          completeJob(job.jobId, { post: resultPost, sectionsGenerated: allSections.length, totalWords: totalWordCount }, 'ai-generated');
          console.log(`[Blog-Content] Job ${job.jobId} completed (fallback). Generated ${allSections.length} sections, ${totalWordCount} words.`);
        }
      } catch (err: any) {
        console.error(`[Blog-Content] Job ${job.jobId} failed:`, err.message);
        failJob(job.jobId, err.message || 'Content generation failed');
      }
    });
  }
);

// POST /generate-structure
// Generate blog structure from a title in background
// ============================================

router.post(
  '/generate-structure',
  requirePermission('blog-content-os', 'ai-generate'),
  [
    body('companyId').notEmpty().withMessage('Company ID is required'),
    body('strategyId').notEmpty().withMessage('Strategy ID is required'),
    body('titleId').notEmpty().withMessage('Title ID is required'),
  ],
  async (req: Request, res: Response) => {
    const errors = validationResult(req);
    if (!errors.isEmpty()) {
      res.status(400).json({ error: 'Validation failed', details: errors.array() });
      return;
    }

    const { companyId, strategyId, titleId, titleData, context, contentDepth, language } = req.body;
    const job = createJob('blog-structure', companyId, req.body._moduleId || 'blog-structure');

    // Return jobId immediately
    res.status(202).json({ jobId: job.jobId, status: 'processing' });

    // Run in background
    setImmediate(async () => {
      const startedAt = Date.now();
      try {
        updateJobProgress(job.jobId, 5, 'Loading context data...');

        const { BlogContentOS } = getModels();

        // Get title data from request or database
        let title = titleData;
        if (!title) {
          const blogData = await BlogContentOS.findOne({ companyId });
          if (blogData?.titles) {
            title = blogData.titles.find((t: any) => t.id === titleId || t._id?.toString() === titleId);
          }
        }

        if (!title) {
          failJob(job.jobId, 'Title not found');
          return;
        }

        updateJobProgress(job.jobId, 15, 'Building structure context...');

        // Build context string
        const contextParts: string[] = [];
        if (context) {
          // Brand context
          if (context.brand) {
            if (context.brand.voice) contextParts.push(`Brand Voice: ${context.brand.voice}`);
            if (context.brand.personality) contextParts.push(`Brand Personality: ${context.brand.personality}`);
            if (context.brand.tagline) contextParts.push(`Brand Tagline: ${context.brand.tagline}`);
            if (context.brand.purposeWhyExists) contextParts.push(`Brand Purpose: ${context.brand.purposeWhyExists}`);
          }
          // Business profile context
          if (context.businessProfile) {
            if (context.businessProfile.primaryIndustry) contextParts.push(`Industry: ${context.businessProfile.primaryIndustry}`);
            if (context.businessProfile.name) contextParts.push(`Company: ${context.businessProfile.name}`);
            if (context.businessProfile.description) contextParts.push(`Business Description: ${context.businessProfile.description?.slice(0, 200)}`);
          }
          // ICP context
          if (context.icps && context.icps.length > 0) {
            contextParts.push(`Target ICPs: ${context.icps.map((i: any) => `${i.name}${i.description ? ` (${i.description.slice(0, 50)}...)` : ''}`).join(', ')}`);
          }
          // Persona context
          if (context.personas && context.personas.length > 0) {
            contextParts.push(`Target Personas: ${context.personas.map((p: any) => `${p.name}${p.jobTitle ? ` (${p.jobTitle})` : ''}`).join(', ')}`);
          }
          // Competitor context
          if (context.competitors && context.competitors.length > 0) {
            contextParts.push(`Key Competitors: ${context.competitors.map((c: any) => c.name).join(', ')}`);
          }
          // SEO context
          if (context.primaryKeywords && context.primaryKeywords.length > 0) {
            contextParts.push(`Primary Keywords: ${context.primaryKeywords.join(', ')}`);
          }
          if (context.secondaryKeywords && context.secondaryKeywords.length > 0) {
            contextParts.push(`Secondary Keywords: ${context.secondaryKeywords.join(', ')}`);
          }
          // The structure is built FROM the SEO step's keyword set, so the rest of
          // it (additional / long-tail / negative) is part of the brief too.
          if (context.additionalKeywords && context.additionalKeywords.length > 0) {
            contextParts.push(`Additional Keywords: ${context.additionalKeywords.join(', ')}`);
          }
          if (context.longTailKeywords && context.longTailKeywords.length > 0) {
            contextParts.push(`Long-Tail Keywords (use as section angles): ${context.longTailKeywords.join(', ')}`);
          }
          if (context.negativeKeywords && context.negativeKeywords.length > 0) {
            contextParts.push(`Negative Keywords (do NOT build sections around these): ${context.negativeKeywords.join(', ')}`);
          }
          if (context.searchIntent) contextParts.push(`SEO Search Intent: ${context.searchIntent}`);
          // Strategy context
          if (context.goals) contextParts.push(`Goals: ${Array.isArray(context.goals) ? context.goals.join(', ') : context.goals}`);
          if (context.targetAudience) contextParts.push(`Target Audience: ${Array.isArray(context.targetAudience) ? context.targetAudience.join(', ') : context.targetAudience}`);
          if (context.funnelStage) contextParts.push(`Funnel Stage: ${context.funnelStage}`);
          if (context.contentDepth) contextParts.push(`Content Depth: ${context.contentDepth}`);
        }

        const contextStr = contextParts.join('\n');

        // Content depth settings
        const depth = contentDepth || 'standard';
        const depthConfig: Record<string, { totalWords: string; perSection: string; sections: string }> = {
          'brief': { totalWords: '300-600 words total', perSection: '100-200 words per section', sections: '3-4 essential sections' },
          'standard': { totalWords: '800-1200 words total', perSection: '200-350 words per section', sections: '4-6 sections' },
          'deep': { totalWords: '1500-2500 words total', perSection: '300-500 words per section', sections: '5-7 sections' },
          'comprehensive': { totalWords: '3000+ words total', perSection: '400-700 words per section', sections: '7-10 sections' },
        };
        const depthSettings = depthConfig[depth] || depthConfig['standard'];

        // Build language instruction
        const lang = language || 'en';
        const languageInstruction = lang === 'en'
          ? ''
          : `\nIMPORTANT: Generate all content in ${lang === 'es' ? 'Spanish' : lang === 'fr' ? 'French' : lang === 'de' ? 'German' : lang === 'pt' ? 'Portuguese' : lang === 'hi' ? 'Hindi' : lang === 'ar' ? 'Arabic' : lang === 'zh' ? 'Chinese' : lang === 'ja' ? 'Japanese' : 'the specified language'}. All headings, descriptions, and text must be in ${lang === 'es' ? 'Spanish' : lang === 'fr' ? 'French' : lang === 'de' ? 'German' : lang === 'pt' ? 'Portuguese' : lang === 'hi' ? 'Hindi' : 'the specified language'}.`;

        updateJobProgress(job.jobId, 30, 'Generating structure...');

        const prompt = `You are an expert content strategist creating blog article structures.

BLOG TITLE: ${title.title}
CONTENT TYPE: ${title.contentType || 'educational'}
SEARCH INTENT: ${title.searchIntent || 'informational'}
FUNNEL STAGE: ${title.funnelStage || 'tofu'}
CONTENT DEPTH: ${depth} - Target ${depthSettings.totalWords}

${contextStr ? `CONTEXT:\n${contextStr}` : ''}

Create an optimal article structure for the blog title "${title.title}". Generate UNIQUE, CONTEXTUAL section titles that are specific to the topic - NOT generic placeholder titles.

EXAMPLE: If the blog is about "10 Digital Marketing Strategies for Startups", sections should be:
- "Understanding Digital Marketing for Startups" (not "Main Section 1")
- "Top 10 Marketing Strategies" (not "Main Section 2")
- "Implementation Roadmap" (not "Main Section 3")

You MUST respond with ONLY a valid JSON array. No markdown, no explanation, no code fences. Each element must be an object with:
- "type": one of "title", "intro", "explanation", "benefits", "steps", "challenges", "case-study", "trends", "faq", "conclusion", "cta", "custom"
- "title": string (UNIQUE, CONTEXTUAL heading specific to the blog topic - NOT generic like "Main Section 1" or "Blog Title")
- "description": string (2-3 words MAXIMUM describing the section focus)
- "headingLevel": number (1 for H1 title, 2 for H2 sections)
- "targetWordCount": number (MUST respect the content depth - ${depthSettings.perSection})
- "keywords": array of 3-5 relevant keyword strings for this section
- "generateContent": boolean (true for sections to auto-generate)
- "required": boolean (true for essential sections)

REQUIRED STRUCTURE (include ALL of these):
1. Title (H1) - Use the actual blog title: "${title.title}"
2. Introduction (H2) - Hook, problem, preview
3. Main Section 1 (H2) - Key concepts/explanation
4. Main Section 2 (H2) - Benefits/applications
5. Main Section 3 (H2) - Steps/how-to
6. Challenges (H2) - Optional but recommended for ${depth === 'comprehensive' || depth === 'deep' ? 'comprehensive/deep content' : 'optional'}
7. Case Studies (H2) - Optional
8. Future Trends (H2) - Optional
9. FAQs (H2) - 3-5 questions
10. Conclusion (H2) - Summary
11. CTA (H2) - Next steps

CRITICAL WORD COUNT GUIDELINES:
- Content Depth "${depth}" requires ${depthSettings.totalWords}
- Each section should have ${depthSettings.perSection}
- The sum of ALL targetWordCount values MUST equal approximately the target total words

IMPORTANT TITLE GUIDELINES:
- Title section MUST use the exact blog title: "${title.title}"
- Other sections MUST have unique, topic-specific titles (NOT "Introduction", "Main Section 1", etc.)
- Titles should be engaging and specific to the blog topic
- Keep descriptions SHORT (2-3 words maximum)
${languageInstruction}
Generate the optimal structure now:`;

        // Default fallback structure used when AI generation fails or returns invalid JSON
        const defaultStructureSections = [
          { type: 'title', title: title.title, description: 'Main title', headingLevel: 1, targetWordCount: 50, keywords: title.suggestedKeywords?.slice(0, 3) || [], generateContent: true, required: true },
          { type: 'intro', title: 'Introduction', description: 'Hook, problem', headingLevel: 2, targetWordCount: 200, keywords: [], generateContent: true, required: true },
          { type: 'explanation', title: 'Key Concepts', description: 'Core concepts', headingLevel: 2, targetWordCount: 300, keywords: [], generateContent: true, required: true },
          { type: 'steps', title: 'How-To Guide', description: 'Step by step', headingLevel: 2, targetWordCount: 400, keywords: [], generateContent: true, required: true },
          { type: 'conclusion', title: 'Conclusion', description: 'Summary, CTA', headingLevel: 2, targetWordCount: 150, keywords: [], generateContent: true, required: true },
        ];

        let response: any = null;
        try {
          response = await generateWithAI(
            prompt,
            'You are an expert content strategist. Always respond with valid JSON arrays.',
            4000,
            undefined,
            'json',
            undefined,
            undefined,
            req.user?._id?.toString() || req.user?.id,
          );
        } catch (aiErr: any) {
          console.warn(`[Blog-Structure] Job ${job.jobId}: AI generation failed (${aiErr.message}), using default structure`);
          response = null;
        }

        updateJobProgress(job.jobId, 70, 'Processing sections...');

        const content = response?.content || '';
        let parsed: any[] = [];

        if (!content || content.trim().length === 0) {
          console.warn(`[Blog-Structure] Job ${job.jobId}: AI returned empty content, using default structure`);
          parsed = defaultStructureSections;
        } else {
          // Parse JSON response — try multiple extraction methods
          try {
            let cleaned = content.replace(/^```(?:json)?\s*\n?/i, '').replace(/\n?```\s*$/i, '').trim();
            const jsonMatch = cleaned.match(/\[[\s\S]*\]/);
            if (jsonMatch) {
              parsed = JSON.parse(jsonMatch[0]);
            } else {
              // Try extracting a JSON object that might contain an array
              const objectMatch = cleaned.match(/\{[\s\S]*\}/);
              if (objectMatch) {
                try {
                  const obj = JSON.parse(objectMatch[0]);
                  // The response might be wrapped in an object with a "sections" or "outline" key
                  parsed = obj.sections || obj.outline || obj.structure || obj.data || [];
                } catch {
                  parsed = defaultStructureSections;
                }
              } else {
                parsed = defaultStructureSections;
              }
            }
          } catch (parseError) {
            console.warn(`[Blog-Structure] Job ${job.jobId}: Failed to parse AI response, using default structure`);
            parsed = defaultStructureSections;
          }
        }

        // Validate parsed result — ensure it's a non-empty array
        if (!Array.isArray(parsed) || parsed.length === 0) {
          console.warn(`[Blog-Structure] Job ${job.jobId}: Parsed result is not a valid array, using default structure`);
          parsed = defaultStructureSections;
        }

        // Map parsed sections to structure format
        const sections = parsed.map((s: any, i: number) => {
          let sectionTitle = s.title || `Section ${i + 1}`;
          if (s.type === 'title' && (sectionTitle === 'Blog Title' || sectionTitle === 'Title')) {
            sectionTitle = title.title;
          }

          return {
            id: `sec-${Date.now()}-${i}-${Math.random().toString(36).slice(2, 9)}`,
            order: i + 1,
            type: s.type || 'custom',
            title: sectionTitle,
            description: s.description || '',
            headingLevel: s.headingLevel || (s.type === 'title' ? 1 : 2),
            targetWordCount: s.targetWordCount || 200,
            keywords: Array.isArray(s.keywords) ? s.keywords : [],
            aiInstructions: s.aiInstructions || '',
            generateContent: s.generateContent !== false,
            required: s.required !== false,
            contentGenerated: false,
          };
        });

        const totalWordCount = sections.reduce((sum: number, s: any) => sum + (s.targetWordCount || 0), 0);

        // The outline's Table of Contents, built from the headings that were just
        // generated. The Content step embeds the same list in the finished article.
        const tableOfContents = buildTocFromSections(sections);

        const structure = {
          strategyId,
          titleId,
          name: title.title,
          type: (title.contentType || 'seo'),
          aiGenerated: true,
          editable: true,
          totalWordCount,
          status: 'generated',
          sections,
          tableOfContents,
          companyId,
        };

        updateJobProgress(job.jobId, 90, 'Saving structure...');

        // Save to database with an ATOMIC $push. One structure job runs per title
        // and they run in parallel, so read-push-save on the shared per-company
        // document would have concurrent writers rewriting the same array — the
        // losers fail the Mongoose version check and their structure is dropped.
        try {
          await BlogContentOS.updateOne(
            { companyId },
            { $push: { structures: structure } },
            { upsert: true },
          );
        } catch (dbErr) {
          console.error('[Blog-Structure] Failed to save to DB:', dbErr);
          // Continue even if DB save fails - the structure data is still in the job result
        }

        // `response` is null when the AI call failed and the default structure was
        // used — nothing was generated, so nothing is recorded.
        await recordBlogAiGeneration({
          companyId,
          analysisType: 'structure-generation',
          entityId: titleId || null,
          inputs: { description: title.title },
          analysis: { titleId, sectionCount: sections.length, tocEntries: tableOfContents.length },
          results: [response],
          startedAt,
        });

        completeJob(job.jobId, { structure }, 'ai-generated');
        console.log(`[Blog-Structure] Job ${job.jobId} completed. Generated ${sections.length} sections.`);
      } catch (err: any) {
        console.error(`[Blog-Structure] Job ${job.jobId} failed:`, err.message);
        failJob(job.jobId, err.message || 'Structure generation failed');
      }
    });
  }
);

// ============================================
// POST /generate-title-seo
// Generate ONE SEO keyword set for ONE blog title, in the background.
//
// This is the wizard's SEO step. It mirrors /generate-structure exactly: one job
// per blog, dispatched in parallel, each producing a record that carries the
// titleId it belongs to. Every blog therefore gets its own dedicated SEO —
// 1 primary keyword, 1 secondary keyword, 2 additional keywords, plus negative
// and long-tail keywords.
//
// SEO now runs BEFORE the title: the wizard order is
//   Content Calendar → Blog Selection → SEO → Title → Structure → Content
// so the caller usually sends `isPlaceholder: true` plus `slotContext`, and the
// model picks the blog's topic as well as its keywords. The Title step then
// writes the title from that keyword set, and the Structure step builds the
// outline from the title. Regenerating SEO for a blog that already has a title
// keeps the original behaviour — the title is sent and drives the keywords.
//
// Not to be confused with /generate-seo, which produces the per-title SEO
// *metadata* (meta title/description, heading suggestions) shown in the Titles
// step. The two are independent and both are still used.
// ============================================

router.post(
  '/generate-title-seo',
  requirePermission('blog-content-os', 'ai-generate'),
  [
    body('companyId').notEmpty().withMessage('Company ID is required'),
    body('strategyId').notEmpty().withMessage('Strategy ID is required'),
    body('titleId').notEmpty().withMessage('Title ID is required'),
  ],
  async (req: Request, res: Response) => {
    const errors = validationResult(req);
    if (!errors.isEmpty()) {
      res.status(400).json({ error: 'Validation failed', details: errors.array() });
      return;
    }

    const {
      companyId, strategyId, titleId, titleData, context, language,
      languageInstruction: clientLanguageInstruction,
      // The wizard now runs SEO BEFORE the title exists: `isPlaceholder` marks a
      // blog that is still just a slot ("Blog 2"), and `slotContext` carries what
      // there is to go on — its position in the plan, its publishing date, the
      // calendar's priority topics, and the head terms the sibling blogs already
      // took. In that mode the model picks this blog's topic as well as its
      // keywords, and the Title step then writes the title from them.
      isPlaceholder, slotContext,
    } = req.body;
    const job = createJob('blog-title-seo', companyId, req.body._moduleId || 'blog-title-seo');

    // Return jobId immediately
    res.status(202).json({ jobId: job.jobId, status: 'processing' });

    // Run in background
    setImmediate(async () => {
      const startedAt = Date.now();
      try {
        // A blog with no title yet is the normal case now — the SEO step runs
        // before the Title step.
        const isSlot = !!isPlaceholder;
        updateJobProgress(job.jobId, 5, isSlot ? 'Loading blog...' : 'Loading title...');

        const { BlogContentOS } = getModels();

        // Get title data from request or database
        let title = titleData;
        if (!title) {
          const blogData = await BlogContentOS.findOne({ companyId });
          if (blogData?.titles) {
            title = blogData.titles.find((t: any) => t.id === titleId || t._id?.toString() === titleId);
          }
        }

        // A slot legitimately has no title text, so only the no-record case is a
        // failure there.
        if (!title || (!isSlot && !title.title)) {
          failJob(job.jobId, 'Title not found');
          return;
        }

        updateJobProgress(job.jobId, 15, 'Building SEO context...');

        const contextParts: string[] = [];
        if (context) {
          if (context.brand) {
            if (context.brand.voice) contextParts.push(`Brand Voice: ${context.brand.voice}`);
            if (context.brand.personality) contextParts.push(`Brand Personality: ${context.brand.personality}`);
          }
          if (context.businessProfile) {
            if (context.businessProfile.name) contextParts.push(`Company: ${context.businessProfile.name}`);
            if (context.businessProfile.primaryIndustry) contextParts.push(`Industry: ${context.businessProfile.primaryIndustry}`);
            if (context.businessProfile.description) contextParts.push(`Business Description: ${context.businessProfile.description?.slice(0, 200)}`);
          }
          if (context.icps?.length) {
            contextParts.push(`Target ICPs: ${context.icps.map((i: any) => i.name).join(', ')}`);
          }
          if (context.personas?.length) {
            contextParts.push(`Target Personas: ${context.personas.map((p: any) => `${p.name}${p.jobTitle ? ` (${p.jobTitle})` : ''}`).join(', ')}`);
          }
          if (context.competitors?.length) {
            contextParts.push(`Key Competitors: ${context.competitors.map((c: any) => c.name).join(', ')}`);
          }
          if (context.products?.length) {
            contextParts.push(`Products: ${context.products.map((p: any) => p.name).join(', ')}`);
          }
          if (context.targetAudience) {
            contextParts.push(`Target Audience: ${Array.isArray(context.targetAudience) ? context.targetAudience.join(', ') : context.targetAudience}`);
          }
          if (context.funnelStage) contextParts.push(`Funnel Stage: ${context.funnelStage}`);
          if (context.goals) contextParts.push(`Goals: ${Array.isArray(context.goals) ? context.goals.join(', ') : context.goals}`);
        }
        const contextStr = contextParts.join('\n');

        // Sibling titles let the model keep each title's keyword set distinct
        // instead of every title landing on the same head term.
        const siblingStr = Array.isArray(context?.otherTitles) && context.otherTitles.length
          ? `\nOTHER TITLES IN THIS BLOG PLAN (choose keywords that do NOT overlap with these):\n${context.otherTitles.slice(0, 25).map((t: string, i: number) => `${i + 1}. ${t}`).join('\n')}\n`
          : '';

        // The client sends a ready-built instruction using the real language name
        // (it owns the language list); the code-only fallback is for callers that
        // don't.
        const lang = language || 'en';
        const languageInstruction = typeof clientLanguageInstruction === 'string' && clientLanguageInstruction.trim()
          ? `\n${clientLanguageInstruction.trim()}`
          : lang === 'en'
            ? ''
            : `\n\nIMPORTANT LANGUAGE REQUIREMENT: Generate ALL keywords entirely in ${lang}. Do NOT use English unless it is a technical term or proper noun.`;

        updateJobProgress(job.jobId, 30, 'Generating SEO keywords...');

        // Slot mode: the blog has no title yet, so the brief describes its place
        // in the plan instead. The head terms the sibling blogs already took are
        // listed so this one lands on a different topic.
        const blogNumber = Number(slotContext?.blogNumber) || 1;
        const totalBlogs = Number(slotContext?.totalBlogs) || 1;
        const priorityTopics: string[] = Array.isArray(slotContext?.priorityTopics)
          ? slotContext.priorityTopics.filter((t: any) => typeof t === 'string' && t.trim())
          : [];
        // Each blog is steered to its own priority topic where the calendar lists
        // enough of them, so a parallel batch doesn't converge on topic one.
        const assignedTopic = priorityTopics.length ? priorityTopics[(blogNumber - 1) % priorityTopics.length] : '';
        const takenKeywords: string[] = Array.isArray(slotContext?.otherKeywords)
          ? slotContext.otherKeywords.filter((k: any) => typeof k === 'string' && k.trim())
          : [];

        const slotBrief = isSlot
          ? `THIS IS BLOG ${blogNumber} OF ${totalBlogs} in the publishing plan. It has no title yet — you are choosing what it should be about.
${slotContext?.scheduledDate ? `SCHEDULED TO PUBLISH: ${String(slotContext.scheduledDate).slice(0, 10)}` : ''}
${assignedTopic ? `TOPIC THIS BLOG MUST COVER: ${assignedTopic}` : ''}
${priorityTopics.length ? `PRIORITY TOPICS FOR THE WHOLE PLAN: ${priorityTopics.join(', ')}` : ''}
${takenKeywords.length ? `HEAD KEYWORDS ALREADY TAKEN BY OTHER BLOGS (pick a different one):\n${takenKeywords.map((k, i) => `${i + 1}. ${k}`).join('\n')}` : ''}
CONTENT TYPE: ${title.contentType || 'educational'}
FUNNEL STAGE: ${title.funnelStage || 'tofu'}`
          : `BLOG TITLE: ${title.title}
${title.excerpt ? `EXCERPT: ${title.excerpt}` : ''}
CONTENT TYPE: ${title.contentType || 'educational'}
SEARCH INTENT: ${title.searchIntent || 'informational'}
FUNNEL STAGE: ${title.funnelStage || 'tofu'}
${Array.isArray(title.suggestedKeywords) && title.suggestedKeywords.length ? `KEYWORDS ALREADY SUGGESTED FOR THIS TITLE: ${title.suggestedKeywords.join(', ')}` : ''}`;

        const prompt = `You are an expert SEO strategist. Produce the SEO keyword set for ONE specific blog post.

${slotBrief}

${contextStr ? `BUSINESS CONTEXT:\n${contextStr}\n` : ''}${siblingStr}
Every keyword must be specific to THIS blog post — not to the blog programme as a whole.
${isSlot ? 'The title for this post will be written from the keyword set you return, so the primary keyword must be a topic a full article can be built around.\n' : ''}
Respond with ONLY valid JSON in this exact shape:
{
  "searchIntent": "informational|commercial|transactional|navigational",
  "primaryGoal": "traffic|rankings|leads|authority",
  "primaryKeyword": "EXACTLY ONE head keyword this post should rank for",
  "secondaryKeyword": "EXACTLY ONE supporting keyword",
  "additionalKeywords": ["EXACTLY TWO further supporting keywords"],
  "negativeKeywords": ["5 keywords this post should deliberately avoid (wrong intent or audience)"],
  "longTailKeywords": ["8-10 long-tail, low-competition phrases specific to this post"],
  "metaTitleTemplate": "Meta title pattern using {title} and {brand} placeholders",
  "metaDescriptionTemplate": "Meta description pattern using {excerpt} and {brand} placeholders",
  "minWordCount": 1200,
  "maxWordCount": 2200,
  "keywordDensityTarget": 1.5
}

RULES:
- "primaryKeyword" and "secondaryKeyword" must each be a SINGLE string
- "additionalKeywords" must contain EXACTLY 2 items
- Keywords must be realistic search phrases a person would actually type${isSlot ? '' : ', not restatements of the title'}
- searchIntent must match how someone searching for this post would behave
- No markdown, no code fences, no explanation — JSON only${languageInstruction}`;

        let response: any = null;
        try {
          response = await generateWithAI(
            prompt,
            'You are an expert SEO strategist. Always respond with valid JSON.',
            1500,
            undefined,
            'json',
            undefined,
            undefined,
            req.user?._id?.toString() || req.user?.id,
          );
        } catch (aiErr: any) {
          console.warn(`[Blog-TitleSEO] Job ${job.jobId}: AI generation failed (${aiErr.message})`);
          failJob(job.jobId, aiErr.message || 'SEO generation failed');
          return;
        }

        updateJobProgress(job.jobId, 70, 'Processing keywords...');

        let parsed: any = null;
        const content = response?.content || '';
        try {
          const cleaned = content.replace(/^```(?:json)?\s*\n?/i, '').replace(/\n?```\s*$/i, '').trim();
          try {
            parsed = JSON.parse(cleaned);
          } catch {
            const objectMatch = cleaned.match(/\{[\s\S]*\}/);
            if (objectMatch) parsed = JSON.parse(objectMatch[0]);
          }
        } catch {
          parsed = null;
        }

        if (!parsed || typeof parsed !== 'object') {
          failJob(job.jobId, 'The SEO keywords could not be read from the AI response');
          return;
        }

        // Normalise — the counts are fixed by the wizard, so anything extra the
        // model returns is dropped rather than silently widening the set.
        const asArray = (v: any): string[] =>
          Array.isArray(v) ? v.filter((x: any) => typeof x === 'string' && x.trim()).map((x: string) => x.trim()) : [];
        const asOne = (v: any): string[] =>
          typeof v === 'string' && v.trim() ? [v.trim()] : asArray(v).slice(0, 1);
        const asNumber = (v: any, fallback: number): number => {
          const n = Number(v);
          return Number.isFinite(n) && n > 0 ? n : fallback;
        };
        const intents = ['informational', 'commercial', 'transactional', 'navigational'];
        const goals = ['traffic', 'rankings', 'leads', 'authority'];

        const primaryKeywords = asOne(parsed.primaryKeyword ?? parsed.primaryKeywords);
        // A blog with no title yet is named after the keyword it was given, so the
        // record never reads "Blog 2 — SEO".
        const seoName = isSlot
          ? `${primaryKeywords[0] || `Blog ${blogNumber}`} — SEO`
          : `${title.title} — SEO`;

        const seoConfig = {
          id: `seo-${titleId}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
          companyId,
          strategyId,
          // The link that makes this SEO record belong to one blog.
          titleId,
          seoName,
          searchIntent: intents.includes(parsed.searchIntent) ? parsed.searchIntent : (title.searchIntent || 'informational'),
          primaryGoal: goals.includes(parsed.primaryGoal) ? parsed.primaryGoal : 'traffic',
          targetAudience: Array.isArray(context?.targetAudience)
            ? context.targetAudience.join(', ')
            : (context?.targetAudience || ''),
          primaryKeywords,
          secondaryKeywords: asOne(parsed.secondaryKeyword ?? parsed.secondaryKeywords),
          additionalKeywords: asArray(parsed.additionalKeywords).slice(0, 2),
          negativeKeywords: asArray(parsed.negativeKeywords).slice(0, 10),
          longTailKeywords: asArray(parsed.longTailKeywords).slice(0, 15),
          aiSeoSettings: {
            autoGenerateMetaTitle: true,
            autoGenerateMetaDescription: true,
            autoGenerateSlug: true,
            autoGenerateTOC: true,
            autoGenerateAltText: true,
            autoGenerateInternalLinks: true,
          },
          metaSettings: {
            metaTitleTemplate: typeof parsed.metaTitleTemplate === 'string' && parsed.metaTitleTemplate.trim()
              ? parsed.metaTitleTemplate.trim()
              : '{title} | {brand}',
            metaDescriptionTemplate: typeof parsed.metaDescriptionTemplate === 'string' && parsed.metaDescriptionTemplate.trim()
              ? parsed.metaDescriptionTemplate.trim()
              : '{excerpt}',
            titleMaxLength: 60,
            descriptionMaxLength: 160,
          },
          seoRules: {
            minWordCount: asNumber(parsed.minWordCount, 1200),
            maxWordCount: asNumber(parsed.maxWordCount, 2200),
            keywordDensityTarget: asNumber(parsed.keywordDensityTarget, 1.5),
            includeTOC: true,
            includeConclusion: true,
            includeCTA: true,
          },
          internalLinking: { enabled: true, maxLinksPerPost: 4, pillarPages: [] },
          schemaSettings: { enabled: true, schemaTypes: ['article'] },
          seoCompetitors: { competitorDomains: [], competitorKeywords: [] },
          createdAt: new Date().toISOString(),
          updatedAt: new Date().toISOString(),
        };

        updateJobProgress(job.jobId, 90, 'Saving SEO keywords...');

        // ATOMIC $push — one job runs per title and they run in parallel, so a
        // read-modify-save on the shared per-company document would have
        // concurrent writers rewriting the same array and losing each other's
        // records to the Mongoose version check. Same reasoning as structures.
        try {
          await BlogContentOS.updateOne(
            { companyId },
            { $push: { seoConfigs: seoConfig } },
            { upsert: true },
          );
        } catch (dbErr) {
          console.error('[Blog-TitleSEO] Failed to save to DB:', dbErr);
          // Continue — the record is still returned in the job result.
        }

        await recordBlogAiGeneration({
          companyId,
          analysisType: 'seo-generation',
          entityId: titleId || null,
          inputs: { description: title.title || seoName },
          analysis: {
            titleId,
            primaryKeywords: seoConfig.primaryKeywords,
            searchIntent: seoConfig.searchIntent,
          },
          results: [response],
          startedAt,
        });

        completeJob(job.jobId, { seoConfig }, 'ai-generated');
        console.log(`[Blog-TitleSEO] Job ${job.jobId} completed for title ${titleId}.`);
      } catch (err: any) {
        console.error(`[Blog-TitleSEO] Job ${job.jobId} failed:`, err.message);
        failJob(job.jobId, err.message || 'SEO generation failed');
      }
    });
  }
);

export default router;