import {
  PermissionFlagsBits,
  type GuildTextBasedChannel,
  type MessageCreateOptions,
  type TextChannel
} from 'discord.js';
import {
  DEFAULT_GUILD_TIMEZONE,
  WelcomeEmbedSchema,
  expandBracketChannelMentions,
  parseMessageComponentsV2,
  resolveScheduleRunAt,
  t,
  tf
} from '@nexumi/shared';
import type { ScheduledMessage } from '@prisma/client';
import { ensureGuild } from '../../guild.js';
import { buildEmbedFromPayload } from '../../lib/embed-payload.js';
import { buildComponentsV2Payload } from '../../lib/components-v2-payload.js';
import { safeRemoveBullJob } from '../../lib/safe-remove-job.js';
import { logger } from '../../logger.js';
import { scheduleQueue } from '../../queues.js';
import type { BotContext } from '../../types.js';
import { parseDuration } from '../moderation/duration.js';

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

export type AnnouncementInput = {
  guildId: string;
  channelId: string;
  creatorId: string;
  content: string;
  embedTitle?: string | null;
  embedDescription?: string | null;
  embedColor?: number | null;
  rolePingId?: string | null;
};

export type ScheduleCreateInput = AnnouncementInput & {
  whenInput?: string | null;
  cron?: string | null;
};

function parseScheduleWhen(input: string, timeZone: string): Date {
  const trimmed = input.trim();
  if (/^\d+[smhd]$/i.test(trimmed)) {
    const ms = parseDuration(trimmed);
    if (ms <= 0) {
      throw new SchedulerError('invalid_when');
    }
    return new Date(Date.now() + ms);
  }

  try {
    const parsed = resolveScheduleRunAt(trimmed, timeZone);
    if (parsed.getTime() <= Date.now()) {
      throw new SchedulerError('invalid_when');
    }
    return parsed;
  } catch {
    throw new SchedulerError('invalid_when');
  }
}

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

export 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 removeScheduleJobSchedulers(
  queue: typeof scheduleQueue,
  scheduleId: string,
  jobId: string | null | undefined
): Promise<void> {
  for (const schedulerId of collectSchedulerIds(scheduleId, jobId)) {
    try {
      await queue.removeJobScheduler(schedulerId);
    } catch (error) {
      logger.warn({ schedulerId, error }, 'Failed to remove schedule job scheduler');
    }
  }

  // Legacy / hash-keyed schedulers: match by payload scheduleId.
  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 (error) {
        logger.warn({ schedulerId: id, error }, 'Failed to remove matched schedule job scheduler');
      }
    }
  } catch (error) {
    logger.warn({ scheduleId, error }, 'Failed to scan job schedulers for schedule');
  }
}

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

function buildEmbedJson(input: {
  embedTitle?: string | null;
  embedDescription?: string | null;
  embedColor?: number | null;
}): { title?: string; description?: string; color?: number } | null {
  const title = input.embedTitle?.trim();
  const description = input.embedDescription?.trim();
  const color = input.embedColor ?? undefined;

  if (!title && !description && color === undefined) {
    return null;
  }

  const embed: { title?: string; description?: string; color?: number } = {};
  if (title) {
    embed.title = title;
  }
  if (description) {
    embed.description = description;
  }
  if (color !== undefined) {
    embed.color = color;
  }
  return embed;
}

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

export function buildAnnouncementPayload(schedule: {
  content: string | null;
  embed: unknown;
  components?: unknown;
  rolePingId: string | null;
  id?: string;
}): MessageCreateOptions {
  const componentsData = parseMessageComponentsV2(schedule.components);
  if (componentsData && schedule.id) {
    const componentsPayload = buildComponentsV2Payload(componentsData, {
      source: 's',
      ref: schedule.id,
      renderText: expandBracketChannelMentions
    });
    if (componentsPayload) {
      if (schedule.rolePingId) {
        // Components V2 cannot mix classic content; role ping is skipped.
      }
      return componentsPayload;
    }
  }

  const embedData = parseStoredEmbed(schedule.embed);
  const embed = buildEmbedFromPayload(embedData, {
    renderText: expandBracketChannelMentions
  });

  const baseContent = expandBracketChannelMentions(schedule.content?.trim() ?? '');
  const roleMention = schedule.rolePingId ? `<@&${schedule.rolePingId}>` : '';
  const content = [roleMention, baseContent].filter(Boolean).join(' ').trim();

  const payload: MessageCreateOptions = {};
  if (content) {
    payload.content = content;
  }
  if (embed) {
    payload.embeds = [embed];
  }
  if (schedule.rolePingId) {
    payload.allowedMentions = { roles: [schedule.rolePingId] };
  }

  return payload;
}

async function assertSendableChannel(
  context: BotContext,
  channelId: string
): Promise<GuildTextBasedChannel> {
  const channel = await context.client.channels.fetch(channelId);
  if (!channel?.isTextBased() || channel.isDMBased()) {
    throw new SchedulerError('invalid_channel');
  }

  const me = channel.guild.members.me;
  if (
    !me?.permissionsIn(channel).has(PermissionFlagsBits.SendMessages) ||
    !me.permissionsIn(channel).has(PermissionFlagsBits.ViewChannel)
  ) {
    throw new SchedulerError('channel_unavailable');
  }

  return channel;
}

async function enqueueScheduleJob(
  schedule: ScheduledMessage,
  cron: string | null,
  runAt: Date | null,
  timeZone: string
): Promise<string> {
  const jobId = scheduleJobId(schedule.id);

  if (cron) {
    // BullMQ 5 job schedulers honor guild timezone for cron patterns.
    await scheduleQueue.upsertJobScheduler(
      jobId,
      { pattern: cron, tz: timeZone },
      {
        name: 'scheduleSend',
        data: { scheduleId: schedule.id },
        opts: {
          removeOnComplete: true,
          removeOnFail: 50
        }
      }
    );
    return jobId;
  }

  if (!runAt) {
    throw new SchedulerError('missing_schedule_time');
  }

  const job = await scheduleQueue.add(
    'scheduleSend',
    { scheduleId: schedule.id },
    {
      jobId,
      removeOnComplete: true,
      removeOnFail: 50,
      delay: Math.max(0, runAt.getTime() - Date.now())
    }
  );
  return String(job.id);
}

/**
 * Removes a one-shot delayed job and/or cron job scheduler. Locked (active)
 * jobs cannot be removed — do not call this from inside the scheduleSend
 * worker for the job that is currently running.
 */
export async function removeScheduleJob(
  jobId: string | null,
  options?: { scheduleId?: string; cron?: string | null }
): Promise<void> {
  if (options?.scheduleId) {
    await removeScheduleJobSchedulers(scheduleQueue, options.scheduleId, jobId);
  } else if (options?.cron && jobId) {
    // Best-effort for callers without scheduleId: extract scheduler from repeat instance id.
    for (const schedulerId of collectSchedulerIds('unknown', jobId)) {
      if (schedulerId === 'schedule-unknown') {
        continue;
      }
      try {
        await scheduleQueue.removeJobScheduler(schedulerId);
      } catch (error) {
        logger.warn({ schedulerId, error }, 'Failed to remove schedule job scheduler');
      }
    }
  }

  // One-shot delayed jobs only — never job.remove() on repeat:* scheduler children.
  const oneShotIds = new Set<string>();
  if (jobId && !jobId.startsWith('repeat:')) {
    oneShotIds.add(jobId);
  }
  if (options?.scheduleId) {
    oneShotIds.add(scheduleJobId(options.scheduleId));
  }

  for (const id of oneShotIds) {
    const job = await scheduleQueue.getJob(id);
    if (job?.id && String(job.id).startsWith('repeat:')) {
      continue;
    }
    await safeRemoveBullJob(job, { label: 'schedule', jobId: id });
  }
}

function validateAnnouncementContent(input: AnnouncementInput): void {
  const hasContent = input.content.trim().length > 0;
  const hasEmbed =
    Boolean(input.embedTitle?.trim()) ||
    Boolean(input.embedDescription?.trim()) ||
    input.embedColor !== null && input.embedColor !== undefined;

  if (!hasContent && !hasEmbed) {
    throw new SchedulerError('content_required');
  }
}

export async function createScheduledMessage(
  context: BotContext,
  input: ScheduleCreateInput
): Promise<ScheduledMessage> {
  await ensureGuild(context.prisma, input.guildId);
  validateAnnouncementContent(input);

  const whenInput = input.whenInput?.trim();
  const cron = input.cron?.trim();

  if (!whenInput && !cron) {
    throw new SchedulerError('missing_schedule_time');
  }

  let runAt: Date | null = null;
  let cronPattern: string | null = null;
  const timeZone = await getGuildTimezone(context.prisma, input.guildId);

  if (cron) {
    if (!isValidCron(cron)) {
      throw new SchedulerError('invalid_cron');
    }
    cronPattern = cron;
  } else if (whenInput) {
    runAt = parseScheduleWhen(whenInput, timeZone);
  }

  await assertSendableChannel(context, input.channelId);

  const embed = buildEmbedJson(input);

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

  const jobId = await enqueueScheduleJob(schedule, cronPattern, runAt, timeZone);
  return context.prisma.scheduledMessage.update({
    where: { id: schedule.id },
    data: { jobId }
  });
}

export async function listScheduledMessages(
  context: BotContext,
  guildId: string,
  locale: 'de' | 'en'
): Promise<string> {
  const schedules = await context.prisma.scheduledMessage.findMany({
    where: { guildId, enabled: true },
    orderBy: { createdAt: 'asc' },
    take: 20
  });

  if (schedules.length === 0) {
    return t(locale, 'scheduler.list.empty');
  }

  return schedules
    .map((schedule) => {
      const preview =
        schedule.content?.slice(0, 60) ??
        (schedule.components
          ? t(locale, 'scheduler.list.componentsPreview')
          : schedule.embed
            ? t(locale, 'scheduler.list.embedPreview')
            : '—');
      const timing = schedule.cron
        ? tf(locale, 'scheduler.list.cron', { cron: schedule.cron })
        : schedule.runAt
          ? `<t:${Math.floor(schedule.runAt.getTime() / 1000)}:R>`
          : '—';

      return tf(locale, 'scheduler.list.line', {
        id: schedule.id.slice(0, 8),
        channel: `<#${schedule.channelId}>`,
        timing,
        preview
      });
    })
    .join('\n');
}

export async function deleteScheduledMessage(
  context: BotContext,
  guildId: string,
  scheduleIdPrefix: string
): Promise<boolean> {
  const schedules = await context.prisma.scheduledMessage.findMany({
    where: { guildId, enabled: true }
  });
  const match = schedules.find((entry) => entry.id.startsWith(scheduleIdPrefix));
  if (!match) {
    return false;
  }

  await removeScheduleJob(match.jobId, { scheduleId: match.id, cron: match.cron });
  await context.prisma.scheduledMessage.delete({ where: { id: match.id } });
  return true;
}

export async function sendAnnouncement(
  context: BotContext,
  input: AnnouncementInput
): Promise<void> {
  validateAnnouncementContent(input);
  const channel = await assertSendableChannel(context, input.channelId);
  const payload = buildAnnouncementPayload({
    content: input.content.trim() || null,
    embed: buildEmbedJson(input),
    rolePingId: input.rolePingId ?? null
  });
  await (channel as TextChannel).send(payload);
}

export async function sendScheduledMessage(context: BotContext, scheduleId: string): Promise<void> {
  const schedule = await context.prisma.scheduledMessage.findUnique({ where: { id: scheduleId } });
  if (!schedule || !schedule.enabled) {
    return;
  }

  try {
    const channel = await assertSendableChannel(context, schedule.channelId);
    const payload = buildAnnouncementPayload(schedule);
    if (!payload.content && !payload.embeds?.length && !payload.components?.length) {
      throw new SchedulerError('content_required');
    }
    await (channel as TextChannel).send(payload);
  } catch (error) {
    logger.error({ scheduleId, error }, 'Failed to send scheduled message');
    if (error instanceof SchedulerError && error.code === 'channel_unavailable') {
      await context.prisma.scheduledMessage.update({
        where: { id: scheduleId },
        data: { enabled: false }
      });
    }
    throw error;
  }

  // One-shot: delete the DB row only. Do not remove the active BullMQ job
  // from inside its own worker (locked remove throws → retry → duplicate send).
  // removeOnComplete on enqueue cleans the job up after the processor returns.
  if (!schedule.cron) {
    await context.prisma.scheduledMessage.delete({ where: { id: scheduleId } });
  }
}
