import { z } from 'zod';
import {
  ActionRowBuilder,
  ButtonBuilder,
  ButtonStyle,
  ChannelType,
  EmbedBuilder,
  ModalBuilder,
  PermissionFlagsBits,
  StringSelectMenuBuilder,
  TextInputBuilder,
  TextInputStyle,
  UserSelectMenuBuilder,
  type APIMessageComponentEmoji,
  type Guild,
  type GuildBasedChannel,
  type GuildMember,
  type GuildTextBasedChannel,
  type Message,
  type Role,
  type SendableChannels,
  type TextChannel,
  type ThreadChannel
} from 'discord.js';
import {
  buildTicketTranscriptHtml,
  TicketModeSchema,
  TicketPrioritySchema,
  t,
  tf
} from '@nexumi/shared';
import type { Ticket, TicketCategory, TicketConfig } from '@prisma/client';
import { ensureGuild } from '../../guild.js';
import { logger } from '../../logger.js';
import type { BotContext } from '../../types.js';
import { getGuildLocale } from '../../i18n.js';

export const TICKET_OPEN_PREFIX = 'ticket:open:';
export const TICKET_SELECT_ID = 'ticket:select';
export const TICKET_MODAL_PREFIX = 'ticket:modal:';
export const TICKET_RATE_PREFIX = 'ticket:rate:';
export const TICKET_CTRL_CLAIM = 'ticket:ctrl:claim';
export const TICKET_CTRL_CLOSE = 'ticket:ctrl:close';
export const TICKET_CTRL_ADD = 'ticket:ctrl:add';
export const TICKET_CTRL_REMOVE = 'ticket:ctrl:remove';
export const TICKET_CTRL_PRIORITY = 'ticket:ctrl:priority';

/** Cap private-thread staff adds to stay within Discord rate limits. */
const MAX_THREAD_SUPPORT_MEMBERS = 50;

export const TicketFormFieldSchema = z.object({
  id: z.string(),
  label: z.string(),
  style: z.enum(['SHORT', 'PARAGRAPH']).default('SHORT'),
  required: z.boolean().default(true),
  placeholder: z.string().optional()
});

export type TicketFormField = z.infer<typeof TicketFormFieldSchema>;

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

export class TicketPanelError extends Error {
  constructor(public readonly code: 'not_configured' | 'missing_permission' | 'no_categories') {
    super(code);
    this.name = 'TicketPanelError';
  }
}

function isUnknownChannelError(error: unknown): boolean {
  return Boolean(
    error &&
      typeof error === 'object' &&
      'code' in error &&
      (error as { code: unknown }).code === 10003
  );
}

function botCanPostPanel(
  channel: GuildBasedChannel,
  meId: string
): channel is GuildTextBasedChannel {
  if (!channel.isTextBased() || channel.isDMBased()) {
    return false;
  }
  const permissions = channel.permissionsFor(meId);
  if (!permissions) {
    return false;
  }
  return (
    permissions.has(PermissionFlagsBits.ViewChannel) &&
    permissions.has(PermissionFlagsBits.SendMessages) &&
    permissions.has(PermissionFlagsBits.EmbedLinks)
  );
}

function parseButtonEmoji(emoji?: string | null): APIMessageComponentEmoji | undefined {
  if (!emoji) {
    return undefined;
  }

  const customMatch = emoji.match(/^<(a)?:(\w+):(\d+)>$/);
  if (customMatch) {
    return {
      id: customMatch[3]!,
      name: customMatch[2],
      animated: Boolean(customMatch[1])
    };
  }

  if (/^\d+$/.test(emoji)) {
    return { id: emoji };
  }

  return { name: emoji };
}

/**
 * `Role#members` only includes cached guild members. Fetch the member list when
 * the cache looks empty so support staff can be added to private threads.
 */
async function resolveSupportMembers(guild: Guild, roleIds: string[]): Promise<GuildMember[]> {
  if (roleIds.length === 0) {
    return [];
  }

  const roles: Role[] = [];
  for (const roleId of roleIds) {
    const role =
      guild.roles.cache.get(roleId) ?? (await guild.roles.fetch(roleId).catch(() => null));
    if (role) {
      roles.push(role);
    }
  }

  if (roles.length === 0) {
    return [];
  }

  const cachedCount = roles.reduce((sum, role) => sum + role.members.size, 0);
  if (cachedCount === 0) {
    await guild.members.fetch().catch((error) => {
      logger.warn({ error, guildId: guild.id }, 'Failed to fetch members for ticket support roles');
    });
  }

  const seen = new Set<string>();
  const members: GuildMember[] = [];
  for (const role of roles) {
    for (const member of role.members.values()) {
      if (member.user.bot || seen.has(member.id)) {
        continue;
      }
      seen.add(member.id);
      members.push(member);
      if (members.length >= MAX_THREAD_SUPPORT_MEMBERS) {
        return members;
      }
    }
  }
  return members;
}

export function isTicketControlInteraction(customId: string): boolean {
  return (
    customId === TICKET_CTRL_CLAIM ||
    customId === TICKET_CTRL_CLOSE ||
    customId === TICKET_CTRL_ADD ||
    customId === TICKET_CTRL_REMOVE ||
    customId === TICKET_CTRL_PRIORITY
  );
}

export function buildTicketControlPanel(locale: 'de' | 'en'): {
  embeds: EmbedBuilder[];
  components: ActionRowBuilder<
    ButtonBuilder | UserSelectMenuBuilder | StringSelectMenuBuilder
  >[];
} {
  const embed = new EmbedBuilder()
    .setTitle(t(locale, 'ticket.control.title'))
    .setDescription(t(locale, 'ticket.control.description'))
    .setColor(0x6366f1);

  const buttons = new ActionRowBuilder<ButtonBuilder>().addComponents(
    new ButtonBuilder()
      .setCustomId(TICKET_CTRL_CLAIM)
      .setLabel(t(locale, 'ticket.control.claim'))
      .setStyle(ButtonStyle.Success),
    new ButtonBuilder()
      .setCustomId(TICKET_CTRL_CLOSE)
      .setLabel(t(locale, 'ticket.control.close'))
      .setStyle(ButtonStyle.Danger)
  );

  const priorityRow = new ActionRowBuilder<StringSelectMenuBuilder>().addComponents(
    new StringSelectMenuBuilder()
      .setCustomId(TICKET_CTRL_PRIORITY)
      .setPlaceholder(t(locale, 'ticket.control.priorityPlaceholder'))
      .addOptions(
        {
          label: t(locale, 'ticket.priority.LOW'),
          value: 'LOW',
          description: t(locale, 'ticket.control.priorityLowHint')
        },
        {
          label: t(locale, 'ticket.priority.NORMAL'),
          value: 'NORMAL',
          description: t(locale, 'ticket.control.priorityNormalHint')
        },
        {
          label: t(locale, 'ticket.priority.HIGH'),
          value: 'HIGH',
          description: t(locale, 'ticket.control.priorityHighHint')
        },
        {
          label: t(locale, 'ticket.priority.URGENT'),
          value: 'URGENT',
          description: t(locale, 'ticket.control.priorityUrgentHint')
        }
      )
  );

  const addRow = new ActionRowBuilder<UserSelectMenuBuilder>().addComponents(
    new UserSelectMenuBuilder()
      .setCustomId(TICKET_CTRL_ADD)
      .setPlaceholder(t(locale, 'ticket.control.addPlaceholder'))
      .setMinValues(1)
      .setMaxValues(1)
  );

  const removeRow = new ActionRowBuilder<UserSelectMenuBuilder>().addComponents(
    new UserSelectMenuBuilder()
      .setCustomId(TICKET_CTRL_REMOVE)
      .setPlaceholder(t(locale, 'ticket.control.removePlaceholder'))
      .setMinValues(1)
      .setMaxValues(1)
  );

  return {
    embeds: [embed],
    components: [buttons, priorityRow, addRow, removeRow]
  };
}

async function fetchSendableChannel(
  context: BotContext,
  channelId: string
): Promise<SendableChannels | null> {
  const channel = await context.client.channels.fetch(channelId).catch(() => null);
  if (!channel?.isTextBased() || channel.isDMBased()) {
    return null;
  }
  return channel as SendableChannels;
}

export async function getTicketConfig(
  prisma: BotContext['prisma'],
  guildId: string
): Promise<TicketConfig> {
  await ensureGuild(prisma, guildId);
  return prisma.ticketConfig.upsert({
    where: { guildId },
    update: {},
    create: { guildId }
  });
}

export function parseFormFields(raw: unknown): TicketFormField[] {
  if (!Array.isArray(raw)) {
    return [];
  }
  return raw
    .map((item) => TicketFormFieldSchema.safeParse(item))
    .filter((result) => result.success)
    .map((result) => result.data!)
    .slice(0, 5);
}

export function openButtonId(categoryId: string): string {
  return `${TICKET_OPEN_PREFIX}${categoryId}`;
}

export function modalId(categoryId: string): string {
  return `${TICKET_MODAL_PREFIX}${categoryId}`;
}

export function rateButtonId(ticketId: string, rating: number): string {
  return `${TICKET_RATE_PREFIX}${ticketId}:${rating}`;
}

export function parseRateButton(customId: string): { ticketId: string; rating: number } | null {
  if (!customId.startsWith(TICKET_RATE_PREFIX)) {
    return null;
  }
  const rest = customId.slice(TICKET_RATE_PREFIX.length);
  const colon = rest.lastIndexOf(':');
  if (colon === -1) {
    return null;
  }
  const ticketId = rest.slice(0, colon);
  const rating = Number.parseInt(rest.slice(colon + 1), 10);
  if (!ticketId || Number.isNaN(rating) || rating < 1 || rating > 5) {
    return null;
  }
  return { ticketId, rating };
}

export async function findTicketByChannel(
  prisma: BotContext['prisma'],
  channelId: string
): Promise<(Ticket & { category: TicketCategory | null }) | null> {
  return prisma.ticket.findFirst({
    where: {
      OR: [{ channelId }, { threadId: channelId }],
      status: { not: 'CLOSED' }
    },
    include: { category: true }
  });
}

export async function findOpenTicketForUser(
  prisma: BotContext['prisma'],
  guildId: string,
  userId: string
): Promise<Ticket | null> {
  return prisma.ticket.findFirst({
    where: {
      guildId,
      openerId: userId,
      status: { in: ['OPEN', 'CLAIMED'] }
    }
  });
}

function isTicketStaff(member: GuildMember, category: TicketCategory | null): boolean {
  if (member.permissions.has(PermissionFlagsBits.ManageChannels)) {
    return true;
  }
  if (!category) {
    return false;
  }
  return category.supportRoleIds.some((roleId) => member.roles.cache.has(roleId));
}

export async function assertTicketStaff(
  member: GuildMember,
  ticket: Ticket & { category: TicketCategory | null }
): Promise<void> {
  if (!isTicketStaff(member, ticket.category)) {
    throw new TicketError('not_staff');
  }
}

export function buildPanelEmbed(
  locale: 'de' | 'en',
  title: string,
  description?: string | null
): EmbedBuilder {
  return new EmbedBuilder()
    .setTitle(title)
    .setDescription(description ?? t(locale, 'ticket.panel.defaultDescription'))
    .setColor(0x6366f1);
}

export function buildPanelComponents(categories: TicketCategory[]): ActionRowBuilder<
  ButtonBuilder | StringSelectMenuBuilder
>[] {
  if (categories.length === 0) {
    return [];
  }

  if (categories.length <= 5) {
    const row = new ActionRowBuilder<ButtonBuilder>();
    for (const category of categories) {
      const button = new ButtonBuilder()
        .setCustomId(openButtonId(category.id))
        .setLabel(category.name.slice(0, 80))
        .setStyle(ButtonStyle.Primary);
      const emoji = parseButtonEmoji(category.emoji);
      if (emoji) {
        button.setEmoji(emoji);
      }
      row.addComponents(button);
    }
    return [row];
  }

  const select = new StringSelectMenuBuilder()
    .setCustomId(TICKET_SELECT_ID)
    .setPlaceholder('Select a category')
    .addOptions(
      categories.slice(0, 25).map((category) => {
        const emoji = parseButtonEmoji(category.emoji);
        return {
          label: category.name.slice(0, 100),
          value: category.id,
          description: category.description?.slice(0, 100) ?? undefined,
          emoji
        };
      })
    );

  return [new ActionRowBuilder<StringSelectMenuBuilder>().addComponents(select)];
}

/**
 * Posts or updates the ticket panel. Used by `/ticket panel_create` and by the
 * dashboard via BullMQ (`ticketPanelSync`).
 */
export async function syncTicketPanel(
  context: BotContext,
  guildId: string,
  options: {
    channelId?: string | null;
    title?: string;
    description?: string | null;
  } = {}
): Promise<{ panelChannelId: string; panelMessageId: string }> {
  const locale = await getGuildLocale(context.prisma, guildId);
  const config = await getTicketConfig(context.prisma, guildId);
  const categories = await getGuildCategories(context, guildId);

  if (categories.length === 0) {
    throw new TicketPanelError('no_categories');
  }

  const targetChannelId = options.channelId || config.panelChannelId;
  if (!targetChannelId) {
    throw new TicketPanelError('not_configured');
  }

  const guild = await context.client.guilds.fetch(guildId);
  const me = guild.members.me;
  if (!me) {
    throw new TicketPanelError('missing_permission');
  }

  const channel = await guild.channels.fetch(targetChannelId).catch(() => null);
  if (!channel || !botCanPostPanel(channel, me.id)) {
    throw new TicketPanelError('missing_permission');
  }

  const title = options.title ?? t(locale, 'ticket.panel.defaultTitle');
  const description =
    options.description !== undefined
      ? options.description
      : t(locale, 'ticket.panel.defaultDescription');
  const payload = {
    embeds: [buildPanelEmbed(locale, title, description)],
    components: buildPanelComponents(categories)
  };

  const sameChannel =
    Boolean(config.panelMessageId) && config.panelChannelId === channel.id;

  if (sameChannel && config.panelMessageId) {
    try {
      const existing = await channel.messages.fetch(config.panelMessageId);
      await existing.edit(payload);
      if (!config.panelChannelId) {
        await context.prisma.ticketConfig.update({
          where: { guildId },
          data: { panelChannelId: channel.id }
        });
      }
      return { panelChannelId: channel.id, panelMessageId: existing.id };
    } catch (error) {
      logger.warn(
        { error, guildId, messageId: config.panelMessageId },
        'Failed to edit ticket panel; recreating'
      );
    }
  }

  if (config.panelMessageId && config.panelChannelId && config.panelChannelId !== channel.id) {
    try {
      const oldChannel = await guild.channels.fetch(config.panelChannelId);
      if (oldChannel?.isTextBased() && !oldChannel.isDMBased()) {
        const oldMessage = await oldChannel.messages.fetch(config.panelMessageId);
        await oldMessage.delete();
      }
    } catch {
      // Previous panel may already be gone
    }
  }

  const message = await channel.send(payload);
  await context.prisma.ticketConfig.update({
    where: { guildId },
    data: {
      panelChannelId: channel.id,
      panelMessageId: message.id
    }
  });

  return { panelChannelId: channel.id, panelMessageId: message.id };
}

export async function runTicketPanelSyncJob(
  context: BotContext,
  data: { guildId: string }
): Promise<{ panelChannelId: string; panelMessageId: string }> {
  return syncTicketPanel(context, data.guildId);
}

/**
 * Dashboard ticket actions (close / claim / priority). Staff checks are done by
 * the WebUI (`requireGuildAccess`); the bot applies Discord side effects.
 */
export async function runTicketDashboardActionJob(
  context: BotContext,
  data: {
    ticketId: string;
    guildId: string;
    actorUserId: string;
    action: 'close' | 'claim' | 'priority';
    reason?: string;
    priority?: string;
  }
): Promise<{ id: string; status: string; priority: string; claimedById: string | null }> {
  const ticket = await context.prisma.ticket.findFirst({
    where: { id: data.ticketId, guildId: data.guildId },
    include: { category: true }
  });
  if (!ticket) {
    throw new TicketError('not_found');
  }
  if (ticket.status === 'CLOSED') {
    throw new TicketError('closed');
  }

  const locale = await getGuildLocale(context.prisma, data.guildId);

  if (data.action === 'close') {
    await closeTicket(context, ticket, data.actorUserId, locale, data.reason);
    const closed = await context.prisma.ticket.findUniqueOrThrow({ where: { id: ticket.id } });
    return {
      id: closed.id,
      status: closed.status,
      priority: closed.priority,
      claimedById: closed.claimedById
    };
  }

  if (data.action === 'claim') {
    if (ticket.claimedById === data.actorUserId) {
      throw new TicketError('already_claimed');
    }
    const updated = await context.prisma.ticket.update({
      where: { id: ticket.id },
      data: {
        claimedById: data.actorUserId,
        status: 'CLAIMED',
        lastActivityAt: new Date()
      }
    });
    const channelId = ticket.channelId ?? ticket.threadId;
    if (channelId) {
      const channel = await fetchSendableChannel(context, channelId);
      if (channel) {
        await channel.send(tf(locale, 'ticket.claimed', { user: `<@${data.actorUserId}>` }));
      }
    }
    return {
      id: updated.id,
      status: updated.status,
      priority: updated.priority,
      claimedById: updated.claimedById
    };
  }

  const parsed = TicketPrioritySchema.parse(data.priority);
  const updated = await context.prisma.ticket.update({
    where: { id: ticket.id },
    data: { priority: parsed, lastActivityAt: new Date() }
  });
  const channelId = ticket.channelId ?? ticket.threadId;
  if (channelId) {
    const channel = await fetchSendableChannel(context, channelId);
    if (channel) {
      await channel.send(
        tf(locale, 'ticket.prioritySet', { priority: t(locale, `ticket.priority.${parsed}`) })
      );
    }
  }
  return {
    id: updated.id,
    status: updated.status,
    priority: updated.priority,
    claimedById: updated.claimedById
  };
}

export function buildOpenModal(category: TicketCategory, locale: 'de' | 'en'): ModalBuilder {
  const fields = parseFormFields(category.formFields);
  const modal = new ModalBuilder()
    .setCustomId(modalId(category.id))
    .setTitle(category.name.slice(0, 45));

  for (const field of fields) {
    const input = new TextInputBuilder()
      .setCustomId(field.id)
      .setLabel(field.label.slice(0, 45))
      .setStyle(field.style === 'PARAGRAPH' ? TextInputStyle.Paragraph : TextInputStyle.Short)
      .setRequired(field.required);
    if (field.placeholder) {
      input.setPlaceholder(field.placeholder.slice(0, 100));
    }
    modal.addComponents(new ActionRowBuilder<TextInputBuilder>().addComponents(input));
  }

  if (fields.length === 0) {
    modal.addComponents(
      new ActionRowBuilder<TextInputBuilder>().addComponents(
        new TextInputBuilder()
          .setCustomId('subject')
          .setLabel(t(locale, 'ticket.modal.subject'))
          .setStyle(TextInputStyle.Short)
          .setRequired(true)
          .setMaxLength(100)
      )
    );
  }

  return modal;
}

async function permissionOverwrites(
  guild: Guild,
  openerId: string,
  supportRoleIds: string[]
) {
  const me = await guild.members.fetchMe();
  const overwrites = [
    {
      id: guild.roles.everyone.id,
      deny: [PermissionFlagsBits.ViewChannel]
    },
    {
      id: openerId,
      allow: [
        PermissionFlagsBits.ViewChannel,
        PermissionFlagsBits.SendMessages,
        PermissionFlagsBits.ReadMessageHistory,
        PermissionFlagsBits.AttachFiles,
        PermissionFlagsBits.EmbedLinks
      ]
    },
    {
      id: me.id,
      allow: [
        PermissionFlagsBits.ViewChannel,
        PermissionFlagsBits.SendMessages,
        PermissionFlagsBits.ReadMessageHistory,
        PermissionFlagsBits.ManageChannels,
        PermissionFlagsBits.ManageThreads,
        PermissionFlagsBits.AttachFiles,
        PermissionFlagsBits.EmbedLinks
      ]
    }
  ];

  for (const roleId of supportRoleIds) {
    overwrites.push({
      id: roleId,
      allow: [
        PermissionFlagsBits.ViewChannel,
        PermissionFlagsBits.SendMessages,
        PermissionFlagsBits.ReadMessageHistory,
        PermissionFlagsBits.AttachFiles,
        PermissionFlagsBits.EmbedLinks,
        PermissionFlagsBits.ManageChannels
      ]
    });
  }

  return overwrites;
}

async function fetchTicketMessages(
  context: BotContext,
  ticket: Ticket
): Promise<Array<{ author: string; content: string; timestamp: string }>> {
  const channelId = ticket.channelId ?? ticket.threadId;
  if (!channelId) {
    return [];
  }

  try {
    const channel = await context.client.channels.fetch(channelId);
    if (!channel?.isTextBased()) {
      return [];
    }

    const collected: Message[] = [];
    let lastId: string | undefined;
    while (collected.length < 500) {
      const batch = await channel.messages.fetch({ limit: 100, before: lastId });
      if (batch.size === 0) {
        break;
      }
      collected.push(...batch.values());
      lastId = batch.last()?.id;
    }

    return collected
      .reverse()
      .map((message) => ({
        author: message.author.tag,
        content: message.content || `[${message.attachments.size} attachment(s)]`,
        timestamp: message.createdAt.toISOString()
      }));
  } catch (error) {
    if (isUnknownChannelError(error)) {
      logger.debug({ ticketId: ticket.id }, 'Ticket channel already gone while fetching messages');
    } else {
      logger.warn({ error, ticketId: ticket.id }, 'Failed to fetch ticket messages');
    }
    return [];
  }
}

function buildRatingComponents(ticketId: string): ActionRowBuilder<ButtonBuilder> {
  const row = new ActionRowBuilder<ButtonBuilder>();
  for (let rating = 1; rating <= 5; rating += 1) {
    row.addComponents(
      new ButtonBuilder()
        .setCustomId(rateButtonId(ticketId, rating))
        .setLabel(String(rating))
        .setStyle(ButtonStyle.Secondary)
    );
  }
  return row;
}

export async function createTicketChannel(
  context: BotContext,
  params: {
    guild: Guild;
    opener: GuildMember;
    category: TicketCategory;
    config: TicketConfig;
    parentChannelId?: string;
    subject?: string;
    formAnswers?: Record<string, string>;
  },
  locale: 'de' | 'en'
): Promise<Ticket> {
  const existing = await findOpenTicketForUser(
    context.prisma,
    params.guild.id,
    params.opener.id
  );
  if (existing) {
    throw new TicketError('already_open');
  }

  const mode = TicketModeSchema.parse(params.config.mode);
  const supportRoleIds = params.category.supportRoleIds;
  const channelName = `ticket-${params.opener.user.username}`
    .toLowerCase()
    .replace(/[^a-z0-9-]/g, '-')
    .slice(0, 90);

  let channelId: string | null = null;
  let threadId: string | null = null;

  if (mode === 'THREAD') {
    const parentId = params.parentChannelId ?? params.config.categoryId;
    if (!parentId) {
      throw new TicketError('no_parent_channel');
    }
    const parent = await params.guild.channels.fetch(parentId);
    if (!parent?.isTextBased() || parent.isDMBased()) {
      throw new TicketError('invalid_parent_channel');
    }

    const thread = await (parent as TextChannel).threads.create({
      name: channelName,
      type: ChannelType.PrivateThread,
      invitable: false,
      reason: `Ticket opened by ${params.opener.user.tag}`
    });
    await thread.members.add(params.opener.id);

    const supportMembers = await resolveSupportMembers(params.guild, supportRoleIds);
    for (const member of supportMembers) {
      if (member.id === params.opener.id) {
        continue;
      }
      await thread.members.add(member.id).catch((error) => {
        logger.warn(
          { error, threadId: thread.id, userId: member.id },
          'Failed to add support member to ticket thread'
        );
      });
    }
    if (supportRoleIds.length > 0 && supportMembers.length === 0) {
      logger.warn(
        { guildId: params.guild.id, supportRoleIds },
        'No cached members found for ticket support roles – staff may need Server Members intent coverage'
      );
    }
    threadId = thread.id;
  } else {
    const overwrites = await permissionOverwrites(
      params.guild,
      params.opener.id,
      supportRoleIds
    );
    const channel = await params.guild.channels.create({
      name: channelName,
      type: ChannelType.GuildText,
      parent: params.config.categoryId ?? undefined,
      permissionOverwrites: overwrites,
      reason: `Ticket opened by ${params.opener.user.tag}`
    });
    channelId = channel.id;
  }

  const ticket = await context.prisma.ticket.create({
    data: {
      guildId: params.guild.id,
      categoryId: params.category.id,
      openerId: params.opener.id,
      channelId,
      threadId,
      subject: params.subject ?? params.category.name,
      formAnswers: params.formAnswers ?? undefined,
      status: 'OPEN',
      lastActivityAt: new Date()
    }
  });

  const targetId = channelId ?? threadId!;
  const target = (await context.client.channels.fetch(targetId)) as TextChannel | ThreadChannel;

  const roleMentions = supportRoleIds.map((roleId) => `<@&${roleId}>`).join(' ');
  const introLines = [
    roleMentions || null,
    tf(locale, 'ticket.opened', { user: `<@${params.opener.id}>` }),
    params.subject ? tf(locale, 'ticket.subjectLine', { subject: params.subject }) : null
  ].filter(Boolean) as string[];

  if (params.formAnswers && Object.keys(params.formAnswers).length > 0) {
    const answerLines = Object.entries(params.formAnswers).map(
      ([key, value]) => `**${key}:** ${value}`
    );
    introLines.push(answerLines.join('\n'));
  }

  await target.send({
    content: introLines.join('\n'),
    allowedMentions: {
      roles: supportRoleIds,
      users: [params.opener.id]
    }
  });

  const control = buildTicketControlPanel(locale);
  await target.send(control);

  return ticket;
}

export async function claimTicket(
  context: BotContext,
  ticket: Ticket & { category: TicketCategory | null },
  member: GuildMember,
  locale: 'de' | 'en'
): Promise<Ticket> {
  await assertTicketStaff(member, ticket);
  if (ticket.status === 'CLOSED') {
    throw new TicketError('closed');
  }
  if (ticket.claimedById === member.id) {
    throw new TicketError('already_claimed');
  }

  const updated = await context.prisma.ticket.update({
    where: { id: ticket.id },
    data: {
      claimedById: member.id,
      status: 'CLAIMED',
      lastActivityAt: new Date()
    }
  });

  const channelId = ticket.channelId ?? ticket.threadId;
  if (channelId) {
    const channel = await fetchSendableChannel(context, channelId);
    if (channel) {
      await channel.send(tf(locale, 'ticket.claimed', { user: `<@${member.id}>` }));
    }
  }

  return updated;
}

export async function addUserToTicket(
  context: BotContext,
  ticket: Ticket & { category: TicketCategory | null },
  member: GuildMember,
  target: GuildMember,
  locale: 'de' | 'en'
): Promise<void> {
  await assertTicketStaff(member, ticket);
  const channelId = ticket.channelId ?? ticket.threadId;
  if (!channelId) {
    throw new TicketError('no_channel');
  }

  const channel = await context.client.channels.fetch(channelId);
  if (channel?.isThread()) {
    await channel.members.add(target.id);
  } else if (channel?.type === ChannelType.GuildText) {
    await channel.permissionOverwrites.edit(target.id, {
      ViewChannel: true,
      SendMessages: true,
      ReadMessageHistory: true,
      AttachFiles: true
    });
  }

  await context.prisma.ticket.update({
    where: { id: ticket.id },
    data: { lastActivityAt: new Date() }
  });

  if (channel?.isTextBased()) {
    const sendable = channel as SendableChannels;
    await sendable.send(tf(locale, 'ticket.userAdded', { user: `<@${target.id}>` }));
  }
}

export async function removeUserFromTicket(
  context: BotContext,
  ticket: Ticket & { category: TicketCategory | null },
  member: GuildMember,
  target: GuildMember,
  locale: 'de' | 'en'
): Promise<void> {
  await assertTicketStaff(member, ticket);
  if (target.id === ticket.openerId) {
    throw new TicketError('cannot_remove_opener');
  }

  const channelId = ticket.channelId ?? ticket.threadId;
  if (!channelId) {
    throw new TicketError('no_channel');
  }

  const channel = await context.client.channels.fetch(channelId);
  if (channel?.isThread()) {
    await channel.members.remove(target.id);
  } else if (channel?.type === ChannelType.GuildText) {
    await channel.permissionOverwrites.delete(target.id);
  }

  if (channel?.isTextBased()) {
    await (channel as SendableChannels).send(
      tf(locale, 'ticket.userRemoved', { user: `<@${target.id}>` })
    );
  }
}

export async function renameTicket(
  context: BotContext,
  ticket: Ticket & { category: TicketCategory | null },
  member: GuildMember,
  name: string,
  locale: 'de' | 'en'
): Promise<void> {
  await assertTicketStaff(member, ticket);
  const sanitized = name
    .toLowerCase()
    .replace(/[^a-z0-9-]/g, '-')
    .slice(0, 90);
  if (!sanitized) {
    throw new TicketError('invalid_name');
  }

  const channelId = ticket.channelId ?? ticket.threadId;
  if (!channelId) {
    throw new TicketError('no_channel');
  }

  const channel = await context.client.channels.fetch(channelId);
  if (channel && 'setName' in channel) {
    await channel.setName(sanitized);
  }

  await context.prisma.ticket.update({
    where: { id: ticket.id },
    data: { subject: name, lastActivityAt: new Date() }
  });

  if (channel?.isTextBased()) {
    await (channel as SendableChannels).send(tf(locale, 'ticket.renamed', { name }));
  }
}

export async function setTicketPriority(
  context: BotContext,
  ticket: Ticket & { category: TicketCategory | null },
  member: GuildMember,
  priority: string,
  locale: 'de' | 'en'
): Promise<Ticket> {
  await assertTicketStaff(member, ticket);
  const parsed = TicketPrioritySchema.parse(priority);

  const updated = await context.prisma.ticket.update({
    where: { id: ticket.id },
    data: { priority: parsed, lastActivityAt: new Date() }
  });

  const channelId = ticket.channelId ?? ticket.threadId;
  if (channelId) {
    const channel = await fetchSendableChannel(context, channelId);
    if (channel) {
      await channel.send(
        tf(locale, 'ticket.prioritySet', { priority: t(locale, `ticket.priority.${parsed}`) })
      );
    }
  }

  return updated;
}

export async function closeTicket(
  context: BotContext,
  ticket: Ticket & { category: TicketCategory | null },
  closedById: string,
  locale: 'de' | 'en',
  reason?: string
): Promise<void> {
  if (ticket.status === 'CLOSED') {
    throw new TicketError('closed');
  }

  const config = await getTicketConfig(context.prisma, ticket.guildId);
  const guild = await context.client.guilds.fetch(ticket.guildId);
  const opener = await context.client.users.fetch(ticket.openerId).catch(() => null);
  const messages = await fetchTicketMessages(context, ticket);
  const transcriptHtml = buildTicketTranscriptHtml({
    guildName: guild.name,
    ticketId: ticket.id,
    openerTag: opener?.tag ?? ticket.openerId,
    messages
  });

  await context.prisma.ticket.update({
    where: { id: ticket.id },
    data: {
      status: 'CLOSED',
      closedAt: new Date(),
      transcriptHtml,
      lastActivityAt: new Date()
    }
  });

  const channelId = ticket.channelId ?? ticket.threadId;
  if (channelId) {
    const channel = await fetchSendableChannel(context, channelId);
    if (channel) {
      const closeMessage = reason
        ? tf(locale, 'ticket.closedWithReason', {
            user: `<@${closedById}>`,
            reason
          })
        : tf(locale, 'ticket.closed', { user: `<@${closedById}>` });
      await channel.send(closeMessage);
    }
  }

  if (config.logChannelId) {
    try {
      const logChannel = (await context.client.channels.fetch(
        config.logChannelId
      )) as TextChannel;
      await logChannel.send({
        embeds: [
          new EmbedBuilder()
            .setTitle(t(locale, 'ticket.log.closed'))
            .setColor(0x6366f1)
            .addFields(
              { name: 'ID', value: ticket.id, inline: true },
              { name: t(locale, 'ticket.log.opener'), value: `<@${ticket.openerId}>`, inline: true },
              {
                name: t(locale, 'ticket.log.closedBy'),
                value: `<@${closedById}>`,
                inline: true
              }
            )
        ]
      });
    } catch (error) {
      logger.warn({ error, ticketId: ticket.id }, 'Failed to post ticket close log');
    }
  }

  if (config.transcriptDm && opener) {
    try {
      await opener.send({
        content: t(locale, 'ticket.transcriptDm'),
        files: [{ attachment: Buffer.from(transcriptHtml, 'utf8'), name: `ticket-${ticket.id}.html` }]
      });
    } catch (error) {
      logger.warn({ error, ticketId: ticket.id }, 'Failed to DM ticket transcript');
    }
  }

  if (config.ratingEnabled && opener) {
    try {
      await opener.send({
        content: t(locale, 'ticket.ratePrompt'),
        components: [buildRatingComponents(ticket.id)]
      });
    } catch {
      // User may have DMs disabled
    }
  }

  if (channelId) {
    try {
      const channel = await context.client.channels.fetch(channelId);
      if (channel?.isThread()) {
        await channel.setArchived(true);
      } else if (channel && 'delete' in channel) {
        await channel.delete(`Ticket ${ticket.id} closed`);
      }
    } catch (error) {
      if (isUnknownChannelError(error)) {
        logger.debug({ ticketId: ticket.id }, 'Ticket channel already gone while deleting');
      } else {
        logger.warn({ error, ticketId: ticket.id }, 'Failed to delete ticket channel');
      }
    }
  }
}

export async function rateTicket(
  context: BotContext,
  ticketId: string,
  userId: string,
  rating: number
): Promise<void> {
  const ticket = await context.prisma.ticket.findUnique({ where: { id: ticketId } });
  if (!ticket || ticket.openerId !== userId) {
    throw new TicketError('not_found');
  }
  if (ticket.rating !== null) {
    throw new TicketError('already_rated');
  }

  await context.prisma.ticket.update({
    where: { id: ticketId },
    data: { rating }
  });
}

export async function touchTicketActivity(
  context: BotContext,
  channelId: string
): Promise<void> {
  const ticket = await context.prisma.ticket.findFirst({
    where: {
      OR: [{ channelId }, { threadId: channelId }],
      status: { in: ['OPEN', 'CLAIMED'] }
    }
  });
  if (!ticket) {
    return;
  }
  await context.prisma.ticket.update({
    where: { id: ticket.id },
    data: { lastActivityAt: new Date() }
  });
}

export async function closeInactiveTickets(context: BotContext): Promise<number> {
  const configs = await context.prisma.ticketConfig.findMany({
    where: { enabled: true }
  });

  let closed = 0;
  for (const config of configs) {
    const cutoff = new Date(Date.now() - config.inactivityHours * 3_600_000);
    const stale = await context.prisma.ticket.findMany({
      where: {
        guildId: config.guildId,
        status: { in: ['OPEN', 'CLAIMED'] },
        lastActivityAt: { lt: cutoff }
      },
      include: { category: true }
    });

    const locale = await getGuildLocale(context.prisma, config.guildId);
    for (const ticket of stale) {
      try {
        await closeTicket(context, ticket, context.client.user!.id, locale, t(locale, 'ticket.autoCloseReason'));
        closed += 1;
      } catch (error) {
        logger.warn({ error, ticketId: ticket.id }, 'Auto-close ticket failed');
      }
    }
  }

  return closed;
}

export const checkInactiveTickets = closeInactiveTickets;

export async function upsertCategoryByName(
  context: BotContext,
  guildId: string,
  name: string,
  description?: string | null
): Promise<TicketCategory> {
  await ensureGuild(context.prisma, guildId);
  return context.prisma.ticketCategory.upsert({
    where: { guildId_name: { guildId, name } },
    update: { description: description ?? undefined },
    create: { guildId, name, description: description ?? null }
  });
}

export async function createCategoryWithRole(
  context: BotContext,
  guildId: string,
  name: string,
  supportRoleId: string
): Promise<TicketCategory> {
  await ensureGuild(context.prisma, guildId);
  const existing = await context.prisma.ticketCategory.findUnique({
    where: { guildId_name: { guildId, name } }
  });
  if (existing) {
    const roleIds = existing.supportRoleIds.includes(supportRoleId)
      ? existing.supportRoleIds
      : [...existing.supportRoleIds, supportRoleId];
    return context.prisma.ticketCategory.update({
      where: { id: existing.id },
      data: { supportRoleIds: roleIds }
    });
  }
  return context.prisma.ticketCategory.create({
    data: {
      guildId,
      name,
      supportRoleIds: [supportRoleId]
    }
  });
}

export async function getGuildCategories(
  context: BotContext,
  guildId: string
): Promise<TicketCategory[]> {
  return context.prisma.ticketCategory.findMany({
    where: { guildId },
    orderBy: { name: 'asc' }
  });
}

export async function openTicketForCategory(
  context: BotContext,
  guild: Guild,
  member: GuildMember,
  categoryId: string,
  parentChannelId?: string,
  formAnswers?: Record<string, string>,
  subject?: string
): Promise<Ticket> {
  const locale = await getGuildLocale(context.prisma, guild.id);
  const config = await getTicketConfig(context.prisma, guild.id);
  if (!config.enabled) {
    throw new TicketError('disabled');
  }

  const category = await context.prisma.ticketCategory.findUnique({ where: { id: categoryId } });
  if (!category || category.guildId !== guild.id) {
    throw new TicketError('category_not_found');
  }

  return createTicketChannel(
    context,
    {
      guild,
      opener: member,
      category,
      config,
      parentChannelId,
      subject,
      formAnswers
    },
    locale
  );
}
