import {
  ActionRowBuilder,
  AttachmentBuilder,
  ButtonBuilder,
  ButtonInteraction,
  ButtonStyle,
  ChannelType,
  ChatInputCommandInteraction,
  EmbedBuilder,
  Message,
  TextChannel,
  ThreadAutoArchiveDuration,
  ThreadChannel,
} from 'discord.js';
import fs from 'node:fs';
import path from 'node:path';
import sharp from 'sharp';
import { prisma } from '../database';
import { SupportedLocale, t } from '../i18n';
import { RefinementService } from './RefinementService';
import { logger } from '../utils/logger';

export type FishingDirection = 'left' | 'center' | 'right';
type FishingAction = FishingDirection | 'rest';
type FishingState = 'hooked' | 'resting' | 'tense' | 'missed' | 'success' | 'failed';

interface FishingRange {
  min: number;
  max: number;
}

interface FishingCatalogEntry {
  id: string;
  displayName: string;
  spawnRate: number;
  rewardGold: FishingRange;
  sizeCm: FishingRange;
  reactionWindowSec: number;
  distanceReductionByPosition: Record<FishingDirection, FishingRange>;
  artResourcePaths: string[];
}

interface FishingCatalogFile {
  fish: FishingCatalogEntry[];
}

interface FishingRarityEntry {
  id: string;
  displayName: string;
  displayNameKo?: string;
  rollRate: number;
  rewardMultiplier: number;
  reactionWindowMultiplier: number;
  tensionMultiplier: number;
  sizeMultiplier: {
    min: number;
    max: number;
  };
  backgroundColor: string;
}

interface FishingRarityFile {
  rarities: FishingRarityEntry[];
}

interface FishingSession {
  guildId: string;
  userId: string;
  userName: string;
  locale: SupportedLocale;
  threadId: string;
  thread: ThreadChannel;
  controlMessage: Message;
  currentFish: FishingCatalogEntry;
  currentRarity: FishingRarityEntry;
  fishPosition: FishingDirection;
  selectedAction: FishingAction | null;
  phaseStartedAt: number;
  distance: number;
  lineTension: number;
  status: FishingState;
  reward: number | null;
  catchSizeCm: number | null;
  tickInterval: NodeJS.Timeout | null;
  isRendering: boolean;
  needsRender: boolean;
}

const MAX_DISTANCE = 100;
const MAX_TENSION = 100;
const MATCH_TENSION_INCREASE = 12;
const MISS_TENSION_INCREASE = 6;
const REST_TENSION_RECOVERY = 20;
const REST_DISTANCE_INCREASE = 8;
const DISTANCE_BAR_WIDTH = 12;
const TENSION_BAR_WIDTH = 12;
const ROUND_DURATION_MS = 2500;
const REACTION_MIN_MS = 1000;
const REACTION_GRACE_MS = 300;
const SESSION_TICK_MS = 250;

export class FishingService {
  private static sessionsByUser = new Map<string, FishingSession>();
  private static sessionsByThread = new Map<string, FishingSession>();
  private static threadEnterPromises = new Map<string, Promise<{ thread: ThreadChannel; existed: boolean }>>();
  private static fishingCatalog = this.loadFishingCatalog();
  private static fishingRarities = this.loadFishingRarities();

  static async enterThread(interaction: ChatInputCommandInteraction) {
    if (!interaction.guildId || !interaction.channel || interaction.channel.type !== ChannelType.GuildText) {
      throw new Error('ENTER_TEXT_ONLY');
    }

    const userKey = this.getUserKey(interaction.guildId, interaction.user.id);
    const pending = this.threadEnterPromises.get(userKey);
    if (pending) {
      return pending;
    }

    const enterPromise = this.findOrCreateThread(
      interaction.channel as TextChannel,
      interaction.user.username,
      interaction.user.tag,
    );

    this.threadEnterPromises.set(userKey, enterPromise);

    try {
      return await enterPromise;
    } finally {
      this.threadEnterPromises.delete(userKey);
    }
  }

  static isOwnedFishingThread(channel: unknown, userName: string): channel is ThreadChannel {
    return !!channel
      && typeof channel === 'object'
      && 'isThread' in channel
      && typeof (channel as { isThread: () => boolean }).isThread === 'function'
      && (channel as { isThread: () => boolean }).isThread()
      && 'name' in channel
      && (channel as { name: string }).name === this.buildThreadName(userName);
  }

  static async startSessionInThread(interaction: ChatInputCommandInteraction, locale: SupportedLocale) {
    if (!interaction.guildId || !this.isOwnedFishingThread(interaction.channel, interaction.user.username)) {
      throw new Error('CAST_THREAD_ONLY');
    }

    const userKey = this.getUserKey(interaction.guildId, interaction.user.id);
    const existing = await this.getActiveSession(userKey);
    if (existing) {
      return { thread: existing.thread, existed: true };
    }

    const thread = interaction.channel;
    await this.createSessionInThread({
      guildId: interaction.guildId,
      userId: interaction.user.id,
      userName: interaction.user.username,
      locale,
      thread,
    });

    return { thread, existed: false };
  }

  static async endThreadByUser(interaction: ChatInputCommandInteraction, locale: SupportedLocale) {
    if (!interaction.guildId) {
      return false;
    }

    const session = await this.getActiveSession(this.getUserKey(interaction.guildId, interaction.user.id));
    if (!session) {
      const thread = await this.findOwnedFishingThread(interaction);
      if (!thread) {
        return false;
      }

      await this.deleteThread(thread);
      return true;
    }

    session.locale = locale;
    await this.finishSession(session, 'failed', true);
    return true;
  }

  static async handleButton(interaction: ButtonInteraction, locale: SupportedLocale) {
    const [, action, ownerId] = interaction.customId.split('_');
    const session = interaction.guildId
      ? this.sessionsByUser.get(this.getUserKey(interaction.guildId, ownerId)) ?? this.sessionsByThread.get(interaction.channelId)
      : null;

    if (!interaction.guildId || !session) {
      await interaction.update({
        components: [this.buildControlRow(ownerId || 'stale', true)],
      });
      await interaction.followUp({
        content: t(locale, 'commands.fishing.noActiveSession'),
        ephemeral: true,
      });
      return;
    }

    if (session.status === 'success' || session.status === 'failed') {
      await interaction.update({
        components: [this.buildControlRow(session.userId, true)],
      });
      return;
    }

    if (interaction.user.id !== session.userId) {
      await interaction.reply({
        content: t(locale, 'commands.fishing.ownerOnly'),
        ephemeral: true,
      });
      return;
    }

    if (interaction.channelId !== session.threadId) {
      await interaction.reply({
        content: t(locale, 'commands.fishing.wrongThread'),
        ephemeral: true,
      });
      return;
    }

    await interaction.deferUpdate();
    session.locale = locale;
    await this.queueAction(session, action as FishingAction);
  }

  static async getProfile(userId: string, guildId: string) {
    return (prisma as any).fishingProfile.findUnique({
      where: {
        userId_guildId: { userId, guildId },
      },
    });
  }

  static async getCollection(userId: string, guildId: string) {
    return (prisma as any).fishingCollectionEntry.findMany({
      where: {
        userId,
        guildId,
      },
      orderBy: [
        { bestRarityRank: 'desc' },
        { bestSizeCm: 'desc' },
        { catchCount: 'desc' },
      ],
    });
  }

  static async getSizeRanking(guildId: string) {
    return (prisma as any).fishingCollectionEntry.findMany({
      where: {
        guildId,
      },
      orderBy: [
        { bestSizeCm: 'desc' },
        { bestRarityRank: 'desc' },
        { catchCount: 'desc' },
        { lastCaughtAt: 'asc' },
      ],
      take: 10,
    });
  }

  static getFishDisplayName(fishId: string) {
    return this.fishingCatalog.find((fish) => fish.id === fishId)?.displayName ?? fishId;
  }

  static getRarityDisplayNameById(rarityId: string, locale: SupportedLocale) {
    const rarity = this.fishingRarities.find((entry) => entry.id === rarityId);
    if (!rarity) {
      return rarityId;
    }
    return this.getRarityDisplayName(rarity, locale);
  }

  private static async tickSession(session: FishingSession) {
    if (!this.sessionsByUser.has(this.getUserKey(session.guildId, session.userId))) {
      this.clearTick(session);
      return;
    }

    const elapsed = Date.now() - session.phaseStartedAt;
    const reactionWindowMs = this.getReactionWindowMs(session.currentFish, session.currentRarity);

    if (
      session.selectedAction
      && session.selectedAction !== 'rest'
      && session.selectedAction === session.fishPosition
      && elapsed >= reactionWindowMs
    ) {
      await this.resolveSuccessfulPull(session);
      return;
    }

    if (elapsed >= ROUND_DURATION_MS + REACTION_GRACE_MS) {
      await this.resolveMiss(session);
    }
  }

  private static async queueAction(session: FishingSession, action: FishingAction) {
    session.selectedAction = action;

    if (action === 'rest') {
      session.lineTension = Math.max(0, session.lineTension - REST_TENSION_RECOVERY);
      session.distance = Math.min(MAX_DISTANCE, session.distance + REST_DISTANCE_INCREASE);
      session.status = 'resting';
      this.startNextPhase(session);
      await this.renderSession(session, false);
      return;
    }

    session.status = 'hooked';
    await this.renderSession(session, false);
  }

  private static async resolveSuccessfulPull(session: FishingSession) {
    const distanceReduction = this.rollRange(session.currentFish.distanceReductionByPosition[session.fishPosition]);
    session.distance = Math.max(0, session.distance - distanceReduction);
    session.lineTension = Math.min(
      MAX_TENSION,
      session.lineTension + Math.max(1, Math.round(MATCH_TENSION_INCREASE * session.currentRarity.tensionMultiplier)),
    );
    session.status = session.lineTension >= MAX_TENSION ? 'failed' : 'tense';

    if (session.distance <= 0) {
      const reward = Math.max(
        1,
        Math.round(this.rollRange(session.currentFish.rewardGold) * session.currentRarity.rewardMultiplier),
      );
      session.reward = reward;
      session.catchSizeCm = this.rollCatchSizeCm(session.currentFish, session.currentRarity);
      await RefinementService.addGold(session.userId, session.guildId, reward);
      await this.finishSession(session, 'success', false);
      return;
    }

    if (session.lineTension >= MAX_TENSION) {
      await this.finishSession(session, 'failed', false);
      return;
    }

    this.startNextPhase(session);
    await this.renderSession(session, false);
  }

  private static async resolveMiss(session: FishingSession) {
    session.lineTension = Math.min(
      MAX_TENSION,
      session.lineTension + Math.max(1, Math.round(MISS_TENSION_INCREASE * session.currentRarity.tensionMultiplier)),
    );
    session.status = session.selectedAction && session.selectedAction !== 'rest' ? 'missed' : 'hooked';

    if (session.lineTension >= MAX_TENSION) {
      await this.finishSession(session, 'failed', false);
      return;
    }

    this.startNextPhase(session);
    await this.renderSession(session, false);
  }

  private static startNextPhase(session: FishingSession) {
    session.selectedAction = null;
    session.phaseStartedAt = Date.now();
    session.fishPosition = this.randomDirection();
  }

  private static async finishSession(session: FishingSession, finalState: 'success' | 'failed', deleteThread: boolean) {
    session.status = finalState;
    this.clearTick(session);

    logger.info(`[Fishing] Finished session for ${session.userId} with state ${finalState}.`);

    await this.recordProfileResult(session, finalState);

    await this.renderSession(session, true);

    this.sessionsByUser.delete(this.getUserKey(session.guildId, session.userId));
    this.sessionsByThread.delete(session.threadId);

    if (finalState === 'success') {
      await this.sendCatchResult(session);
    }

    if (deleteThread) {
      await this.deleteThread(session.thread);
    }
  }

  private static async getActiveSession(userKey: string) {
    const session = this.sessionsByUser.get(userKey);
    if (!session) {
      return null;
    }

    if (await this.isSessionStale(session)) {
      this.clearStaleSession(session);
      return null;
    }

    return session;
  }

  private static async isSessionStale(session: FishingSession) {
    try {
      await session.thread.fetch();
      await session.controlMessage.fetch();
      return false;
    } catch (error) {
      logger.info(
        `[Fishing] Clearing stale session for ${session.userId} in thread ${session.threadId}: ${error instanceof Error ? error.message : String(error)}`,
      );
      return true;
    }
  }

  private static clearStaleSession(session: FishingSession) {
    this.clearTick(session);
    this.sessionsByUser.delete(this.getUserKey(session.guildId, session.userId));
    this.sessionsByThread.delete(session.threadId);
  }

  private static async deleteThread(thread: ThreadChannel) {
    try {
      await thread.delete();
    } catch (error) {
      logger.warn('Failed to delete fishing thread, archiving instead:', error);
      try {
        await thread.setArchived(true);
        await thread.setLocked(true);
      } catch (archiveError) {
        logger.warn('Failed to archive fishing thread after delete failure:', archiveError);
      }
    }
  }

  private static async renderSession(session: FishingSession, disabled: boolean) {
    if (session.isRendering) {
      session.needsRender = true;
      return;
    }

    session.isRendering = true;

    try {
      await session.controlMessage.edit({
        embeds: [this.buildEmbed(session)],
        components: [this.buildControlRow(session.userId, disabled)],
      });
    } finally {
      session.isRendering = false;
    }

    if (session.needsRender) {
      session.needsRender = false;
      await this.renderSession(session, disabled);
    }
  }

  private static buildEmbed(
    session: Pick<FishingSession, 'locale' | 'currentFish' | 'currentRarity' | 'fishPosition' | 'selectedAction' | 'phaseStartedAt' | 'distance' | 'lineTension' | 'status' | 'reward'>,
  ) {
    const distanceProgress = MAX_DISTANCE - session.distance;
    const distanceBar = this.buildGauge(distanceProgress, MAX_DISTANCE, DISTANCE_BAR_WIDTH);
    const tensionBar = this.buildGauge(session.lineTension, MAX_TENSION, TENSION_BAR_WIDTH);
    const stateKey = `commands.fishing.states.${session.status}`;
    const selectedAction = this.formatSelectedAction(session.selectedAction);

    const embed = new EmbedBuilder()
      .setColor(
        session.status === 'success'
          ? this.hexToColorInt(session.currentRarity.backgroundColor)
          : session.status === 'failed'
            ? 0xed4245
            : 0x5865f2,
      )
      .setTitle(
        session.status === 'success' || session.status === 'failed'
          ? t(session.locale, 'commands.fishing.titleEnded')
          : t(session.locale, 'commands.fishing.titleActive'),
      )
      .setDescription([
        '```',
        '⬅️   ⏺️   ➡️',
        this.buildFishLane(session.fishPosition),
        '```',
      ].join('\n'))
      .addFields(
        {
          name: t(session.locale, 'commands.fishing.status'),
          value: `${t(session.locale, stateKey)}\n🎯 ${selectedAction}`,
          inline: true,
        },
        {
          name: t(session.locale, 'commands.fishing.distance'),
          value: `${distanceBar} ${session.distance} / ${MAX_DISTANCE}`,
          inline: false,
        },
        {
          name: t(session.locale, 'commands.fishing.tension'),
          value: `${tensionBar} ${session.lineTension} / ${MAX_TENSION}`,
          inline: false,
        },
      )
      .setFooter({
        text: t(session.locale, 'commands.fishing.threadHint'),
      });

    if (session.reward !== null) {
      embed.addFields({
        name: t(session.locale, 'commands.fishing.reward'),
        value: `${session.reward} G`,
        inline: true,
      });
    }

    return embed;
  }

  private static buildControlRow(userId: string, disabled: boolean) {
    return new ActionRowBuilder<ButtonBuilder>().addComponents(
      new ButtonBuilder()
        .setCustomId(`fishing_left_${userId}`)
        .setEmoji('⬅️')
        .setStyle(ButtonStyle.Secondary)
        .setDisabled(disabled),
      new ButtonBuilder()
        .setCustomId(`fishing_center_${userId}`)
        .setEmoji('⏺️')
        .setStyle(ButtonStyle.Secondary)
        .setDisabled(disabled),
      new ButtonBuilder()
        .setCustomId(`fishing_right_${userId}`)
        .setEmoji('➡️')
        .setStyle(ButtonStyle.Secondary)
        .setDisabled(disabled),
      new ButtonBuilder()
        .setCustomId(`fishing_rest_${userId}`)
        .setEmoji('🛌')
        .setStyle(ButtonStyle.Primary)
        .setDisabled(disabled),
    );
  }

  private static buildGauge(current: number, max: number, width: number) {
    const ratio = max > 0 ? Math.max(0, Math.min(1, current / max)) : 0;
    const filled = Math.round(ratio * width);
    return `${'█'.repeat(filled)}${'░'.repeat(width - filled)}`;
  }

  private static async createSessionInThread(params: {
    guildId: string;
    userId: string;
    userName: string;
    locale: SupportedLocale;
    thread: ThreadChannel;
  }) {
    const currentFish = this.pickFishByRate();
    const currentRarity = this.pickRarityByRate();
    const sessionBase = {
      guildId: params.guildId,
      userId: params.userId,
      userName: params.userName,
      locale: params.locale,
      threadId: params.thread.id,
      thread: params.thread,
      currentFish,
      currentRarity,
      fishPosition: this.randomDirection(),
      selectedAction: null,
      phaseStartedAt: Date.now(),
      distance: MAX_DISTANCE,
      lineTension: 0,
      status: 'hooked' as FishingState,
      reward: null,
      catchSizeCm: null,
      tickInterval: null,
      isRendering: false,
      needsRender: false,
    };

    const controlMessage = await params.thread.send({
      embeds: [this.buildEmbed(sessionBase)],
      components: [this.buildControlRow(params.userId, false)],
    });

    const session: FishingSession = {
      ...sessionBase,
      controlMessage,
    };

    this.sessionsByUser.set(this.getUserKey(session.guildId, session.userId), session);
    this.sessionsByThread.set(session.threadId, session);

    logger.info(
      `[Fishing] Started session for ${session.userId} in thread ${session.threadId} (${session.currentRarity.id} ${session.currentFish.id}).`,
    );

    session.tickInterval = setInterval(() => {
      void this.tickSession(session);
    }, SESSION_TICK_MS);

    return session;
  }

  private static async findOrCreateThread(parentChannel: TextChannel, userName: string, userTag: string) {
    const threadName = this.buildThreadName(userName);
    const activeThreads = await parentChannel.threads.fetchActive();
    const existingThread = activeThreads.threads.find((thread) => thread.name === threadName);

    if (existingThread) {
      logger.info(`[Fishing] Reusing existing thread ${existingThread.id} for ${userName}.`);
      return { thread: existingThread as ThreadChannel, existed: true };
    }

    const thread = await parentChannel.threads.create({
      name: threadName,
      autoArchiveDuration: ThreadAutoArchiveDuration.OneHour,
      reason: `Fishing thread for ${userTag}`,
    });

    return { thread, existed: false };
  }

  private static async findOwnedFishingThread(interaction: ChatInputCommandInteraction) {
    if (this.isOwnedFishingThread(interaction.channel, interaction.user.username)) {
      return interaction.channel;
    }

    if (!interaction.channel || interaction.channel.type !== ChannelType.GuildText) {
      return null;
    }

    const activeThreads = await interaction.channel.threads.fetchActive();
    const thread = activeThreads.threads.find((candidate) => candidate.name === this.buildThreadName(interaction.user.username));
    return (thread as ThreadChannel | undefined) ?? null;
  }

  private static async sendCatchResult(session: FishingSession) {
    const artPath = this.pickRandomArtPath(session.currentFish);
    const rarityName = this.getRarityDisplayName(session.currentRarity, session.locale);
    const rarityBadge = this.getRarityBadge(session.currentRarity.id);
    const embed = new EmbedBuilder()
      .setColor(this.hexToColorInt(session.currentRarity.backgroundColor))
      .setTitle(`${rarityBadge} ${t(session.locale, 'commands.fishing.catchResultTitle')} · ${rarityName}`)
      .setDescription(
        t(session.locale, 'commands.fishing.catchResultBody', {
          rarity: rarityName,
          fish: session.currentFish.displayName,
          sizeCm: (session.catchSizeCm ?? 0).toFixed(1),
          reward: String(session.reward ?? 0),
        }),
      )
      .addFields({
        name: t(session.locale, 'commands.fishing.rarity'),
        value: rarityName,
        inline: true,
      })
      .addFields({
        name: t(session.locale, 'commands.fishing.size'),
        value: `${(session.catchSizeCm ?? 0).toFixed(1)} cm`,
        inline: true,
      });

    if (artPath && fs.existsSync(artPath)) {
      const fileName = `${session.currentFish.id}_${session.currentRarity.id}.png`;
      try {
        const composite = await this.composeRarityArt(artPath, session.currentRarity.backgroundColor);
        embed.setImage(`attachment://${fileName}`);
        await session.thread.send({
          embeds: [embed],
          files: [
            new AttachmentBuilder(composite ?? artPath, { name: fileName }),
          ],
        });
        logger.info(
          `[Fishing] Sent catch result with art for ${session.userId} (${session.currentRarity.id} ${session.currentFish.id}).`,
        );
        return;
      } catch (error) {
        logger.warn('Failed to send fishing catch result with art, retrying without attachment:', error);
      }
    }

    try {
      await session.thread.send({ embeds: [embed] });
      logger.info(
        `[Fishing] Sent catch result without art for ${session.userId} (${session.currentRarity.id} ${session.currentFish.id}).`,
      );
    } catch (error) {
      logger.error('Failed to send fishing catch result message:', error);
    }
  }

  static previewFishLane(position: FishingDirection) {
    return this.buildFishLane(position);
  }

  static previewGauge(current: number, max: number, width: number) {
    return this.buildGauge(current, max, width);
  }

  private static buildFishLane(position: FishingDirection) {
    if (position === 'left') return '🐟   ·   ·';
    if (position === 'right') return '·   ·   🐟';
    return '·   🐟   ·';
  }

  private static randomDirection(): FishingDirection {
    const directions: FishingDirection[] = ['left', 'center', 'right'];
    return directions[Math.floor(Math.random() * directions.length)];
  }

  private static buildThreadName(userName: string) {
    const sanitized = userName.replace(/[^\w\s-]/g, '').trim() || 'User';
    return `${sanitized}'s Fishing Spot`.slice(0, 100);
  }

  private static loadFishingCatalog(): FishingCatalogEntry[] {
    const catalogPath = this.resolveResourcePath('resource/data/fishing/fish_catalog.json');
    const raw = fs.readFileSync(catalogPath, 'utf-8');
    const parsed = JSON.parse(raw) as FishingCatalogFile;

    if (!parsed.fish?.length) {
      throw new Error('Fishing catalog is empty.');
    }

    return parsed.fish;
  }

  private static pickFishByRate() {
    const totalRate = this.fishingCatalog.reduce((sum, fish) => sum + fish.spawnRate, 0);
    let roll = Math.random() * totalRate;

    for (const fish of this.fishingCatalog) {
      roll -= fish.spawnRate;
      if (roll <= 0) {
        return fish;
      }
    }

    return this.fishingCatalog[this.fishingCatalog.length - 1];
  }

  private static pickRarityByRate() {
    const totalRate = this.fishingRarities.reduce((sum, rarity) => sum + rarity.rollRate, 0);
    let roll = Math.random() * totalRate;

    for (const rarity of this.fishingRarities) {
      roll -= rarity.rollRate;
      if (roll <= 0) {
        return rarity;
      }
    }

    return this.fishingRarities[this.fishingRarities.length - 1];
  }

  private static getReactionWindowMs(fish: FishingCatalogEntry, rarity: FishingRarityEntry) {
    const configured = Math.round(fish.reactionWindowSec * 1000);
    const adjusted = Math.round(configured / Math.max(1, rarity.reactionWindowMultiplier));
    return Math.max(REACTION_MIN_MS, Math.min(adjusted, ROUND_DURATION_MS));
  }

  private static rollRange(range: FishingRange) {
    if (range.min === range.max) {
      return range.min;
    }

    return range.min + Math.floor(Math.random() * (range.max - range.min + 1));
  }

  private static pickRandomArtPath(fish: FishingCatalogEntry) {
    if (!fish.artResourcePaths.length) {
      return null;
    }

    const relativePath = fish.artResourcePaths[Math.floor(Math.random() * fish.artResourcePaths.length)];
    return this.resolveResourcePath(relativePath);
  }

  private static resolveResourcePath(relativePath: string) {
    // Current file: apps/bot/src/services/FishingService.ts
    // 1:src/services, 2:src, 3:bot, 4:apps, 5:root
    // After re-evaluating: 
    // apps/bot/src/services -> .. -> src -> .. -> bot -> .. -> apps -> .. -> root (Total 4 levels)
    const rootPath = path.resolve(__dirname, '..', '..', '..', '..');
    const candidatePath = path.resolve(rootPath, relativePath);
    
    if (fs.existsSync(candidatePath)) {
      return candidatePath;
    }
    
    // Fallback to local if root doesn't have it (unlikely in this monorepo)
    return path.resolve(__dirname, '..', '..', relativePath);
  }

  private static loadFishingRarities(): FishingRarityEntry[] {
    const rarityPath = this.resolveResourcePath('resource/data/fishing/fish_rarities.json');
    const raw = fs.readFileSync(rarityPath, 'utf-8');
    const parsed = JSON.parse(raw) as FishingRarityFile;

    if (!parsed.rarities?.length) {
      throw new Error('Fishing rarity catalog is empty.');
    }

    return parsed.rarities;
  }

  private static getRarityDisplayName(rarity: FishingRarityEntry, locale: SupportedLocale) {
    return locale === 'ko' && rarity.displayNameKo ? rarity.displayNameKo : rarity.displayName;
  }

  private static async recordProfileResult(session: FishingSession, finalState: 'success' | 'failed') {
    const reward = session.reward ?? 0;
    const rarityField = this.getRarityCountField(session.currentRarity.id);
    const existingProfile = await this.getProfile(session.userId, session.guildId);

    await (prisma as any).fishingProfile.upsert({
      where: {
        userId_guildId: {
          userId: session.userId,
          guildId: session.guildId,
        },
      },
      create: {
        userId: session.userId,
        guildId: session.guildId,
        totalCastCount: 1,
        successCount: finalState === 'success' ? 1 : 0,
        failCount: finalState === 'failed' ? 1 : 0,
        totalGoldEarned: reward,
        bestCatchReward: reward,
        commonCatchCount: rarityField === 'commonCatchCount' && finalState === 'success' ? 1 : 0,
        uncommonCatchCount: rarityField === 'uncommonCatchCount' && finalState === 'success' ? 1 : 0,
        rareCatchCount: rarityField === 'rareCatchCount' && finalState === 'success' ? 1 : 0,
        epicCatchCount: rarityField === 'epicCatchCount' && finalState === 'success' ? 1 : 0,
        legendaryCatchCount: rarityField === 'legendaryCatchCount' && finalState === 'success' ? 1 : 0,
        lastCastAt: new Date(),
      },
      update: {
        totalCastCount: { increment: 1 },
        successCount: finalState === 'success' ? { increment: 1 } : undefined,
        failCount: finalState === 'failed' ? { increment: 1 } : undefined,
        totalGoldEarned: reward > 0 ? { increment: reward } : undefined,
        bestCatchReward: reward > 0 ? Math.max(reward, existingProfile?.bestCatchReward ?? 0) : undefined,
        [rarityField]: finalState === 'success' ? { increment: 1 } : undefined,
        lastCastAt: new Date(),
      },
    });

    if (finalState === 'success') {
      await this.recordCollectionCatch(session);
    }
  }

  private static async recordCollectionCatch(session: FishingSession) {
    const rarityRank = this.getRarityRank(session.currentRarity.id);
    const existingEntry = await (prisma as any).fishingCollectionEntry.findUnique({
      where: {
        userId_guildId_fishId: {
          userId: session.userId,
          guildId: session.guildId,
          fishId: session.currentFish.id,
        },
      },
    });

    const bestRarityRank = Math.max(existingEntry?.bestRarityRank ?? 0, rarityRank);
    const bestRarityId = bestRarityRank === rarityRank
      ? session.currentRarity.id
      : existingEntry?.bestRarityId ?? session.currentRarity.id;
    const bestSizeCm = Math.max(existingEntry?.bestSizeCm ?? 0, session.catchSizeCm ?? 0);

    await (prisma as any).fishingCollectionEntry.upsert({
      where: {
        userId_guildId_fishId: {
          userId: session.userId,
          guildId: session.guildId,
          fishId: session.currentFish.id,
        },
      },
      create: {
        userId: session.userId,
        guildId: session.guildId,
        fishId: session.currentFish.id,
        catchCount: 1,
        bestRarityId: session.currentRarity.id,
        bestRarityRank: rarityRank,
        bestSizeCm: session.catchSizeCm ?? 0,
        lastCaughtAt: new Date(),
      },
      update: {
        catchCount: { increment: 1 },
        bestRarityId,
        bestRarityRank,
        bestSizeCm,
        lastCaughtAt: new Date(),
      },
    });
  }

  private static getRarityCountField(rarityId: string) {
    if (rarityId === 'legendary') return 'legendaryCatchCount';
    if (rarityId === 'epic') return 'epicCatchCount';
    if (rarityId === 'rare') return 'rareCatchCount';
    if (rarityId === 'uncommon') return 'uncommonCatchCount';
    return 'commonCatchCount';
  }

  private static getRarityRank(rarityId: string) {
    if (rarityId === 'legendary') return 5;
    if (rarityId === 'epic') return 4;
    if (rarityId === 'rare') return 3;
    if (rarityId === 'uncommon') return 2;
    return 1;
  }

  private static getRarityBadge(rarityId: string) {
    if (rarityId === 'legendary') return '🟠';
    if (rarityId === 'epic') return '🟣';
    if (rarityId === 'rare') return '🔵';
    if (rarityId === 'uncommon') return '🟢';
    return '⚪';
  }

  private static async composeRarityArt(artPath: string, backgroundColor: string) {
    try {
      const sourceBuffer = fs.readFileSync(artPath);
      const metadata = await sharp(sourceBuffer).metadata();
      const width = metadata.width ?? 512;
      const height = metadata.height ?? 512;

      return await sharp({
        create: {
          width,
          height,
          channels: 4,
          background: backgroundColor,
        },
      })
        .composite([{ input: sourceBuffer }])
        .png()
        .toBuffer();
    } catch (error) {
      logger.warn('Failed to compose rarity fishing art, falling back to original asset:', error);
      return null;
    }
  }

  private static hexToColorInt(value: string) {
    return Number.parseInt(value.replace('#', ''), 16);
  }

  private static rollCatchSizeCm(fish: FishingCatalogEntry, rarity: FishingRarityEntry) {
    const base = this.rollDecimalRange(fish.sizeCm);
    const multiplier = this.rollDecimalRange(rarity.sizeMultiplier);
    return Math.round(base * multiplier * 10) / 10;
  }

  private static formatSelectedAction(action: FishingAction | null) {
    if (action === 'left') return '⬅️';
    if (action === 'center') return '⏺️';
    if (action === 'right') return '➡️';
    if (action === 'rest') return '🛌';
    return '—';
  }

  private static clearTick(session: FishingSession) {
    if (session.tickInterval) {
      clearInterval(session.tickInterval);
      session.tickInterval = null;
    }
  }

  private static getUserKey(guildId: string, userId: string) {
    return `${guildId}:${userId}`;
  }

  private static rollDecimalRange(range: { min: number; max: number }) {
    if (range.min === range.max) {
      return range.min;
    }

    return range.min + Math.random() * (range.max - range.min);
  }
}

export function buildFishingGauge(current: number, max: number, width: number) {
  return FishingService.previewGauge(current, max, width);
}

export function buildFishingLane(position: FishingDirection) {
  return FishingService.previewFishLane(position);
}
