import {
  DEFAULT_GUILD_TIMEZONE,
  MessageComponentsV2Schema,
  WelcomeEmbedSchema,
  resolveScheduleRunAt,
  type MessageComponentsV2,
  type ScheduledMessageDashboard,
  type ScheduledMessageDashboardCreate,
  type WelcomeEmbed
} from '@nexumi/shared';
import { Prisma, type ScheduledMessage } from '@prisma/client';
import { prisma } from '../prisma';
import { getScheduleQueue } from '../queues';

export class SchedulerValidationError extends Error {
  constructor(public readonly code: string) {
    super(code);
  }
}

function parseEmbed(raw: unknown): WelcomeEmbed | null {
  const parsed = WelcomeEmbedSchema.safeParse(raw);
  return parsed.success ? parsed.data : null;
}

function parseComponents(raw: unknown): MessageComponentsV2 | null {
  const parsed = MessageComponentsV2Schema.safeParse(raw);
  return parsed.success ? parsed.data : null;
}

function toDashboard(schedule: ScheduledMessage): ScheduledMessageDashboard {
  return {
    id: schedule.id,
    channelId: schedule.channelId,
    creatorId: schedule.creatorId,
    content: schedule.content,
    embed: parseEmbed(schedule.embed),
    components: parseComponents(schedule.components),
    rolePingId: schedule.rolePingId,
    cron: schedule.cron,
    runAt: schedule.runAt?.toISOString() ?? null,
    enabled: schedule.enabled,
    createdAt: schedule.createdAt.toISOString()
  };
}

function scheduleJobId(scheduleId: string): string {
  return `schedule-${scheduleId}`;
}

/** BullMQ delayed instance ids look like `repeat:{schedulerId}:{millis}`. */
const REPEAT_INSTANCE_ID_RE = /^repeat:(.+):(\d+)$/;

function collectSchedulerIds(scheduleId: string, jobId: string | null | undefined): string[] {
  const ids = new Set<string>();
  ids.add(scheduleJobId(scheduleId));
  if (!jobId) {
    return [...ids];
  }

  const repeatMatch = jobId.match(REPEAT_INSTANCE_ID_RE);
  if (repeatMatch?.[1]) {
    ids.add(repeatMatch[1]);
  } else if (!jobId.startsWith('repeat:')) {
    ids.add(jobId);
  }
  return [...ids];
}

async function removeQueueSchedulersForSchedule(
  scheduleId: string,
  jobId: string | null | undefined
): Promise<void> {
  const queue = getScheduleQueue();

  for (const schedulerId of collectSchedulerIds(scheduleId, jobId)) {
    try {
      await queue.removeJobScheduler(schedulerId);
    } catch {
      // Already gone or legacy key — keep trying other candidates.
    }
  }

  try {
    const schedulers = await queue.getJobSchedulers();
    for (const scheduler of schedulers) {
      const data = scheduler.template?.data as { scheduleId?: string } | undefined;
      if (data?.scheduleId !== scheduleId) {
        continue;
      }
      const id = scheduler.id ?? scheduler.key;
      try {
        await queue.removeJobScheduler(id);
      } catch {
        // ignore
      }
    }
  } catch {
    // ignore scan failures
  }

  // Older BullMQ repeatables (pre job-scheduler API).
  try {
    const repeatables = await queue.getRepeatableJobs();
    for (const repeatable of repeatables) {
      const matchesId =
        repeatable.id === scheduleJobId(scheduleId) ||
        (jobId != null &&
          (repeatable.key === jobId ||
            (REPEAT_INSTANCE_ID_RE.test(jobId) &&
              repeatable.key.includes(jobId.match(REPEAT_INSTANCE_ID_RE)?.[1] ?? ''))));
      if (!matchesId) {
        continue;
      }
      try {
        await queue.removeRepeatableByKey(repeatable.key);
      } catch {
        // ignore
      }
    }
  } catch {
    // ignore
  }
}

export async function listScheduledMessagesDashboard(guildId: string): Promise<ScheduledMessageDashboard[]> {
  const schedules = await prisma.scheduledMessage.findMany({
    where: { guildId, enabled: true },
    orderBy: { createdAt: 'asc' },
    take: 50
  });
  return schedules.map(toDashboard);
}

function isValidCron(pattern: string): boolean {
  const parts = pattern.trim().split(/\s+/);
  return parts.length >= 5 && parts.length <= 6;
}

async function getGuildTimezone(guildId: string): Promise<string> {
  const settings = await prisma.guildSettings.findUnique({
    where: { guildId },
    select: { timezone: true }
  });
  return settings?.timezone || DEFAULT_GUILD_TIMEZONE;
}

/**
 * Mirrors `apps/bot/src/modules/scheduler/service.ts#createScheduledMessage`
 * job scheduling exactly (same queue, same `scheduleSend` job name, same
 * `schedule-{id}` job id convention) so schedules created from the
 * dashboard are picked up by the bot's existing `scheduleWorker` without any
 * changes on the bot side. Channel permission checks are skipped here since
 * the WebUI has no discord.js Client - the bot validates the channel again
 * when the job fires and disables the schedule if it is unusable.
 */
export async function createScheduledMessageDashboard(
  guildId: string,
  creatorId: string,
  input: ScheduledMessageDashboardCreate
): Promise<ScheduledMessageDashboard> {
  await prisma.guild.upsert({ where: { id: guildId }, update: {}, create: { id: guildId } });

  const cron = input.cron?.trim() || null;
  if (cron && !isValidCron(cron)) {
    throw new SchedulerValidationError('invalid_cron');
  }

  const timeZone = await getGuildTimezone(guildId);
  let runAt: Date | null = null;
  if (!cron && input.runAt) {
    try {
      runAt = resolveScheduleRunAt(input.runAt, timeZone);
    } catch {
      throw new SchedulerValidationError('invalid_when');
    }
  }
  if (!cron && !runAt) {
    throw new SchedulerValidationError('missing_schedule_time');
  }

  const schedule = await prisma.scheduledMessage.create({
    data: {
      guildId,
      channelId: input.channelId,
      creatorId,
      content: input.content?.trim() || null,
      embed:
        input.embed === undefined || input.embed === null
          ? Prisma.JsonNull
          : (input.embed as Prisma.InputJsonValue),
      components:
        input.components === undefined || input.components === null
          ? Prisma.JsonNull
          : (input.components as Prisma.InputJsonValue),
      rolePingId: input.rolePingId || null,
      cron,
      runAt
    }
  });

  const jobId = scheduleJobId(schedule.id);
  const queue = getScheduleQueue();

  if (cron) {
    await queue.upsertJobScheduler(
      jobId,
      { pattern: cron, tz: timeZone },
      {
        name: 'scheduleSend',
        data: { scheduleId: schedule.id },
        opts: {
          removeOnComplete: true,
          removeOnFail: 50
        }
      }
    );
  } else if (runAt) {
    await queue.add(
      'scheduleSend',
      { scheduleId: schedule.id },
      {
        jobId,
        removeOnComplete: true,
        removeOnFail: 50,
        delay: Math.max(0, runAt.getTime() - Date.now())
      }
    );
  }

  const updated = await prisma.scheduledMessage.update({
    where: { id: schedule.id },
    data: { jobId }
  });

  return toDashboard(updated);
}

export async function deleteScheduledMessageDashboard(guildId: string, scheduleId: string): Promise<boolean> {
  const existing = await prisma.scheduledMessage.findFirst({ where: { id: scheduleId, guildId } });
  if (!existing) {
    return false;
  }

  const queue = getScheduleQueue();
  await removeQueueSchedulersForSchedule(existing.id, existing.jobId);

  // One-shot delayed jobs only — never job.remove() on repeat:* scheduler children
  // (BullMQ throws code -8: belongs to a job scheduler).
  const oneShotIds = new Set<string>();
  oneShotIds.add(scheduleJobId(existing.id));
  if (existing.jobId && !existing.jobId.startsWith('repeat:')) {
    oneShotIds.add(existing.jobId);
  }

  for (const id of oneShotIds) {
    const job = await queue.getJob(id);
    if (!job || (job.id && String(job.id).startsWith('repeat:'))) {
      continue;
    }
    try {
      await job.remove();
    } catch (error) {
      const message = error instanceof Error ? error.message : String(error);
      if (
        message.includes('locked') ||
        message.includes('could not be removed') ||
        message.includes('belongs to a job scheduler')
      ) {
        continue;
      }
      throw error;
    }
  }

  await prisma.scheduledMessage.delete({ where: { id: scheduleId } });
  return true;
}
