// ABOUTME: BullMQ worker for daily morning scrape of all racing meetings
// ABOUTME: Fetches meetings + detailed race data (runners, opening odds) and creates Scrape records

import { Worker, Job } from 'bullmq';
import { PrismaClient } from '@prisma/client';
import { DateTime } from 'luxon';
import { TabApiClient } from '../api/tab';
import { MeetingService } from '../services/meeting-service';
import { RaceService } from '../services/race-service';
import logger from '../utils/logger';
import { moveToDeadLetter } from '../utils/dead-letter';
import { connection, QUEUE_NAMES } from './queue';
import { MORNING_SCRAPE_CONFIG } from '../schedulers/config';

// ============================================================================
// Types
// ============================================================================

interface MorningScrapeJobData {
  /** ISO date strings to scrape (defaults to today + tomorrow) */
  dates?: string[];
  /** Racing categories (defaults to ['T', 'H']) */
  categories?: Array<'T' | 'H'>;
  /** Countries (defaults to ['AUS', 'NZ']) */
  countries?: string[];
  /** Internal retry counter */
  retryCount?: number;
}

interface MorningScrapeJobResult {
  success: boolean;
  meetingsProcessed: number;
  racesProcessed: number;
  runnersProcessed: number;
  oddsSnapshotsCaptured: number;
  durationMs: number;
  errors: string[];
  metadata: {
    meetingsByDay: Record<string, number>;
    racesByDay: Record<string, number>;
    failedCombinations: Array<{ category: string; country: string; date: string; error: string }>;
  };
}

// ============================================================================
// Worker Factory
// ============================================================================

export function createMorningScrapeWorker(
  apiClient: TabApiClient,
  prisma: PrismaClient
): Worker {
  const meetingService = new MeetingService(apiClient, prisma);
  const raceService = new RaceService(apiClient, prisma);

  const worker = new Worker<MorningScrapeJobData, MorningScrapeJobResult>(
    QUEUE_NAMES.MORNING_SCRAPE,
    async (job: Job<MorningScrapeJobData>): Promise<MorningScrapeJobResult> => {
      const startTime = Date.now();

      logger.info(
        {
          jobId: job.id,
          dates: job.data.dates,
          categories: job.data.categories,
          countries: job.data.countries,
          retryCount: job.data.retryCount ?? 0,
        },
        'Morning scrape worker: job started'
      );

      const result: MorningScrapeJobResult = {
        success: true,
        meetingsProcessed: 0,
        racesProcessed: 0,
        runnersProcessed: 0,
        oddsSnapshotsCaptured: 0,
        durationMs: 0,
        errors: [],
        metadata: {
          meetingsByDay: {},
          racesByDay: {},
          failedCombinations: [],
        },
      };

      try {
        // Determine dates, categories, countries from job data or defaults
        const dates = resolveDates(job.data.dates);
        const categories = (job.data.categories ??
          MORNING_SCRAPE_CONFIG.categories) as Array<'T' | 'H'>;
        const countries = (job.data.countries ??
          MORNING_SCRAPE_CONFIG.countries) as string[];

        // Phase 1: Fetch meetings for each combination
        for (const category of categories) {
          for (const country of countries) {
            for (const date of dates) {
              try {
                logger.debug(
                  { category, country, date: date.toISOString().split('T')[0] },
                  'Morning scrape: fetching meetings'
                );

                const fetchResult = await meetingService.fetchAndStoreMeetings({
                  category,
                  country,
                  date,
                });

                const dateKey = date.toISOString().split('T')[0];
                result.metadata.meetingsByDay[dateKey] =
                  (result.metadata.meetingsByDay[dateKey] ?? 0) +
                  fetchResult.meetingsProcessed;
                result.metadata.racesByDay[dateKey] =
                  (result.metadata.racesByDay[dateKey] ?? 0) +
                  fetchResult.racesProcessed;

                result.meetingsProcessed += fetchResult.meetingsProcessed;
                result.racesProcessed += fetchResult.racesProcessed;

                if (fetchResult.errors.length > 0) {
                  for (const err of fetchResult.errors) {
                    result.errors.push(
                      `${category}/${country}/${dateKey}/${err.meetingId ?? 'unknown'}: ${err.error}`
                    );
                    result.metadata.failedCombinations.push({
                      category,
                      country,
                      date: dateKey,
                      error: err.error,
                    });
                  }
                }
              } catch (error) {
                const errorMessage =
                  error instanceof Error ? error.message : 'Unknown error';
                const dateKey = date.toISOString().split('T')[0];

                result.errors.push(
                  `${category}/${country}/${dateKey}: ${errorMessage}`
                );
                result.metadata.failedCombinations.push({
                  category,
                  country,
                  date: dateKey,
                  error: errorMessage,
                });

                logger.error(
                  { category, country, date: dateKey, error: errorMessage },
                  'Morning scrape: failed to fetch meetings for combination'
                );
              }
            }
          }
        }

        // Phase 2: Fetch detailed race data (runners, opening odds)
        logger.info(
          { dates: dates.map((d) => d.toISOString().split('T')[0]) },
          'Morning scrape: phase 2 - fetching detailed race data'
        );

        const raceDetailResult = await fetchDetailedRaceData(
          prisma,
          raceService,
          dates
        );

        result.runnersProcessed = raceDetailResult.runnersProcessed;
        result.oddsSnapshotsCaptured = raceDetailResult.oddsSnapshotsCaptured;
        result.errors.push(...raceDetailResult.errors);

        result.durationMs = Date.now() - startTime;

        if (result.errors.length > 0) {
          result.success = false;
        }

        // Create Scrape records for tracking
        await createScrapeRecords(prisma, job, result);

        logger.info(
          {
            jobId: job.id,
            meetingsProcessed: result.meetingsProcessed,
            racesProcessed: result.racesProcessed,
            runnersProcessed: result.runnersProcessed,
            oddsSnapshotsCaptured: result.oddsSnapshotsCaptured,
            errors: result.errors.length,
            durationMs: result.durationMs,
          },
          'Morning scrape worker: job completed'
        );

        return result;
      } catch (error) {
        const errorMessage =
          error instanceof Error ? error.message : 'Unknown error';

        logger.error(
          { jobId: job.id, error: errorMessage },
          'Morning scrape worker: job failed'
        );

        result.success = false;
        result.durationMs = Date.now() - startTime;
        result.errors.push(`Fatal error: ${errorMessage}`);

        throw error;
      }
    },
    {
      connection,
      concurrency: 1, // Only one morning scrape at a time
      lockDuration: 300_000, // 5 minute lock
    }
  );

  worker.on('completed', (job: Job) => {
    logger.info(
      { jobId: job.id, queue: QUEUE_NAMES.MORNING_SCRAPE },
      'Morning scrape worker: job completed event'
    );
  });

  worker.on('failed', async (job: Job | undefined, error: Error) => {
    logger.error(
      {
        jobId: job?.id,
        queue: QUEUE_NAMES.MORNING_SCRAPE,
        error: error.message,
        attemptsMade: job?.attemptsMade,
      },
      'Morning scrape worker: job failed event'
    );

    // Move to dead letter queue after exhausting all retries
    if (job && job.attemptsMade >= (job.opts.attempts || 3)) {
      await moveToDeadLetter(job, error);
    }
  });

  logger.info('Morning scrape worker created');
  return worker;
}

// ============================================================================
// Helpers
// ============================================================================

/**
 * Resolve dates from job data or default to today and tomorrow in UTC
 */
function resolveDates(dateStrings?: string[]): Date[] {
  if (dateStrings && dateStrings.length > 0) {
    return dateStrings.map((ds) => {
      const d = new Date(ds);
      if (isNaN(d.getTime())) {
        throw new Error(`Invalid date string: ${ds}`);
      }
      return d;
    });
  }

  const now = DateTime.now().toUTC();
  const dates: Date[] = [];
  for (let i = 0; i < MORNING_SCRAPE_CONFIG.daysToFetch; i++) {
    dates.push(now.plus({ days: i }).startOf('day').toJSDate());
  }
  return dates;
}

/**
 * Fetch detailed race data for all races within the given date range
 */
async function fetchDetailedRaceData(
  prisma: PrismaClient,
  raceService: RaceService,
  dates: Date[]
): Promise<{
  runnersProcessed: number;
  oddsSnapshotsCaptured: number;
  errors: string[];
}> {
  const results = {
    runnersProcessed: 0,
    oddsSnapshotsCaptured: 0,
    errors: [] as string[],
  };

  const dateStart = DateTime.fromJSDate(dates[0]).startOf('day').toJSDate();
  const dateEnd = DateTime.fromJSDate(dates[dates.length - 1])
    .endOf('day')
    .toJSDate();

  const races = await prisma.race.findMany({
    where: {
      startTime: {
        gte: dateStart,
        lte: dateEnd,
      },
    },
    include: { meeting: true },
    orderBy: { startTime: 'asc' },
  });

  logger.info(
    { raceCount: races.length },
    'Morning scrape: fetching detailed data for races'
  );

  for (const race of races) {
    try {
      const raceResult = await raceService.fetchAndStoreRaceDetails(
        race.id,
        'morning_scrape'
      );

      if (raceResult.success) {
        results.runnersProcessed += raceResult.runnersProcessed;
        results.oddsSnapshotsCaptured += raceResult.oddsSnapshotsCaptured;
      } else {
        results.errors.push(
          ...raceResult.errors.map((e) => `${race.id}: ${e}`)
        );
      }
    } catch (error) {
      const errorMessage =
        error instanceof Error ? error.message : 'Unknown error';
      results.errors.push(`${race.id}: ${errorMessage}`);

      logger.error(
        {
          raceId: race.id,
          meetingId: race.meetingId,
          error: errorMessage,
        },
        'Morning scrape: failed to fetch race details'
      );
    }
  }

  return results;
}

/**
 * Create Scrape records to track what was fetched in this job
 */
async function createScrapeRecords(
  prisma: PrismaClient,
  job: Job<MorningScrapeJobData>,
  result: MorningScrapeJobResult
): Promise<void> {
  try {
    await prisma.scrape.create({
      data: {
        raceId: `morning-scrape-batch-${job.id}`, // Aggregated batch record
        scrapeType: 'morning_scrape',
        success: result.success,
        changesDetected: {
          meetingsProcessed: result.meetingsProcessed,
          racesProcessed: result.racesProcessed,
          runnersProcessed: result.runnersProcessed,
          errors: result.errors.length,
        },
        errorMessage:
          result.errors.length > 0 ? result.errors.join('; ') : null,
      },
    });
  } catch (error) {
    logger.error(
      { error: error instanceof Error ? error.message : 'Unknown error' },
      'Morning scrape: failed to create batch scrape record'
    );
  }
}
