import { createHash } from "node:crypto";
import { Hono } from "hono";
import { bodyLimit } from "hono/body-limit";
import { eq, lt } from "drizzle-orm";
import { z } from "zod";
import type { AppDatabase, AuthEnv, createAuth } from "../auth";
import { discordDeliveries, sharingLimits } from "../db/schema";
import { HabitService, ApiError } from "../habits/service";
import { shade } from "../habits/calendar";
import { shareInput, type ShareData } from "./contracts";
import type { DiscordSharingConfig } from "./config";

export type DiscordFetch = (url: string, init?: RequestInit) => Promise<Response>;
const MAX_IMAGE_BYTES = 4 * 1024 * 1024;
const snowflake = /^\d{17,20}$/;

export function createSharingRoutes(db: AppDatabase, auth: ReturnType<typeof createAuth>, config: DiscordSharingConfig, now: () => number, request: DiscordFetch = fetch) {
  const app = new Hono<AuthEnv>();
  const configured = Boolean(config.token && !/\s/.test(config.token) && snowflake.test(config.channelId));
  const channelEndpoint = `https://discord.com/api/v10/channels/${config.channelId}`;
  const headers = { Authorization: `Bot ${config.token}` };
  let cachedBotAvatar: { url: string | null; expiresAt: number } | null = null;
  let loadingBotAvatar: Promise<string | null> | null = null;
  async function botAvatarUrl(): Promise<string | null> {
    if (!configured) return null;
    if (cachedBotAvatar && cachedBotAvatar.expiresAt > now()) return cachedBotAvatar.url;
    if (loadingBotAvatar) return loadingBotAvatar;
    loadingBotAvatar = (async () => {
      let url: string | null = null;
      try {
        const response = await request("https://discord.com/api/v10/users/@me", { headers, redirect: "error", signal: AbortSignal.timeout(5000) });
        const bot = response.ok ? await response.json() as { id?: string; bot?: boolean; avatar?: string | null; discriminator?: string } : null;
        if (bot?.bot && snowflake.test(bot.id ?? "")) {
          if (bot.avatar && /^(?:a_)?[a-fA-F0-9]{32}$/.test(bot.avatar)) {
            url = `https://cdn.discordapp.com/avatars/${bot.id}/${bot.avatar}.png?size=128`;
          } else if (!bot.avatar) {
            const index = bot.discriminator && /^\d{4}$/.test(bot.discriminator) && bot.discriminator !== "0000"
              ? Number(bot.discriminator) % 5 : Number((BigInt(bot.id!) >> 22n) % 6n);
            url = `https://cdn.discordapp.com/embed/avatars/${index}.png`;
          }
        }
      } catch { /* Keep card exports available when Discord cannot supply the avatar. */ }
      cachedBotAvatar = { url, expiresAt: now() + (url ? 300000 : 30000) };
      return url;
    })();
    try { return await loadingBotAvatar; } finally { loadingBotAvatar = null; }
  }
  let cachedChannel: { name: string; guildId: string; channelUrl: string; expiresAt: number } | null = null;
  async function channel() {
    if (!configured) throw new ApiError(422, "Discord sharing has not been configured on this server.");
    if (cachedChannel && cachedChannel.expiresAt > now()) return cachedChannel;
    let response: Response;
    try { response = await request(channelEndpoint, { headers, redirect: "error", signal: AbortSignal.timeout(10000) }); }
    catch { throw new ApiError(502, "Could not reach Discord. Please try again."); }
    if (!response.ok) {
      const message = response.status === 401 ? "The Discord bot token is invalid. Update the server configuration."
        : response.status === 403 || response.status === 404 ? "The bot cannot access the sharing channel. Check its channel permissions."
        : response.status === 429 ? "Discord is busy. Wait a moment before trying again." : "Could not load the Discord sharing channel.";
      throw new ApiError(response.status === 429 ? 429 : 502, message);
    }
    const data = await response.json().catch(() => null) as { id?: string; type?: number; name?: string; guild_id?: string } | null;
    if (data?.id !== config.channelId || !snowflake.test(data.guild_id ?? "") || ![0, 5, 10, 11, 12].includes(data.type ?? -1))
      throw new ApiError(422, "Set the sharing channel to a Discord server text channel or thread.");
    cachedChannel = { name: data.name ? `#${data.name}` : "Discord sharing channel", guildId: data.guild_id!, channelUrl: `https://discord.com/channels/${data.guild_id}/${config.channelId}`, expiresAt: now() + 60000 };
    return cachedChannel;
  }
  app.use("*", auth.requireAuth);
  app.use("*", async (c, next) => c.req.method === "GET" ? next() : auth.requireSameOrigin(c, next));
  app.use("*", bodyLimit({ maxSize: MAX_IMAGE_BYTES + 65536, onError: c => c.json({ error: "Choose a progress image smaller than 4 MB." }, 413) }));
  async function json<T extends z.ZodType>(req: Request, schema: T): Promise<z.output<T>> {
    if (req.headers.get("Content-Type")?.split(";")[0] !== "application/json") throw new ApiError(400, "Expected application/json");
    const value = await req.json().catch(() => { throw new ApiError(400, "Malformed JSON"); });
    const parsed = schema.safeParse(value);
    if (!parsed.success) throw new ApiError(422, "Check your selection and try again.");
    return parsed.data;
  }

  app.post("/preview", async c => {
    const input = await json(c.req.raw, shareInput);
    const service = new HabitService(db, c.get("user"), now()); service.sync();
    if (input.to > service.today) throw new ApiError(422, "The end date cannot be after today.");
    const result: ShareData = { from: input.from, to: input.to, timezone: service.user.timezone, habits: input.habitIds.map(id => {
      const habit = service.current(id);
      const chart = service.calendar([id], input.from, input.to, service.settings(id), false);
      return { name: habit.name, color: chart.settings.mainColor,
        legend: { empty: chart.settings.emptyColor, complete: chart.settings.mainColor,
          partial: chart.days.some(d => d.shadeCount > 1) ? [0.25, 0.5, 0.75].map(ratio => shade(ratio, 4, chart.settings, false).color) : [] },
        completed: chart.days.filter(d => d.completed > 0).length, scheduled: chart.days.filter(d => d.due > 0).length,
        days: chart.days.map(d => ({ date: d.date, color: d.color, due: d.due > 0, complete: d.completed > 0, ratio: d.ratio })) };
    }) };
    result.botAvatarUrl = await botAvatarUrl();
    return c.json(result);
  });
  app.get("/discord", async c => {
    if (!configured) return c.json({ connected: false, message: "Discord sharing has not been configured on this server." });
    if (config.allowedUserIds?.length && !config.allowedUserIds.includes(c.get("user").discordId))
      return c.json({ connected: false, message: "Discord sharing is limited to approved community members. PNG download remains available." });
    const destination = await channel();
    return c.json({ connected: true, name: destination.name, channelUrl: destination.channelUrl });
  });
  app.post("/discord/send", async c => {
    const userId = c.get("user").id;
    if (config.allowedUserIds?.length && !config.allowedUserIds.includes(c.get("user").discordId)) return c.json({ error: "Discord sharing is limited to approved community members." }, 403);
    if (!configured) throw new ApiError(422, "Discord sharing has not been configured on this server.");
    const form = await c.req.formData().catch(() => { throw new ApiError(400, "Expected a progress image."); });
    const id = z.string().uuid().safeParse(form.get("deliveryId"));
    const image = form.get("image");
    if (!id.success || !(image instanceof File) || image.type !== "image/png" || image.size > MAX_IMAGE_BYTES || image.size < 33) throw new ApiError(422, "Choose a PNG progress image smaller than 4 MB.");
    const bytes = Buffer.from(await image.arrayBuffer());
    if (bytes.subarray(0, 8).toString("hex") !== "89504e470d0a1a0a" || bytes.toString("ascii", 12, 16) !== "IHDR" || bytes.readUInt32BE(16) !== 1200 || bytes.readUInt32BE(20) > 4000) throw new ApiError(422, "Regenerate the progress card before sending.");
    const destination = await channel();
    const imageHash = createHash("sha256").update(bytes).update(`bot:${config.channelId}`).digest("hex");
    const previous = db.select().from(discordDeliveries).where(eq(discordDeliveries.id, id.data)).get();
    if (previous) {
      if (previous.userId !== userId || previous.imageHash !== imageHash) throw new ApiError(409, "Create a new preview before sending again.");
      return c.json({ status: previous.status === "sent" ? "sent" : "uncertain", messageUrl: previous.messageUrl ?? undefined });
    }
    const timestamp = now();
    // Reserve both quotas and the delivery before any asynchronous outbound work.
    // SQLite makes limits survive restarts and coordinate concurrent workers.
    const retryAfter = db.transaction(tx => {
      const raced = tx.select().from(discordDeliveries).where(eq(discordDeliveries.id, id.data)).get();
      if (raced) {
        if (raced.userId !== userId || raced.imageHash !== imageHash) throw new ApiError(409, "Create a new preview before sending again.");
        return -1;
      }
      tx.delete(sharingLimits).where(lt(sharingLimits.startedAt, timestamp - 86400000)).run();
      const quotas = [
        { key: `channel:${config.channelId}`, maximum: 10 },
        { key: `channel:${config.channelId}:user:${c.get("user").discordId}`, maximum: 1 },
      ].map(quota => ({ ...quota, row: tx.select().from(sharingLimits).where(eq(sharingLimits.key, quota.key)).get() }));
      const blocked = quotas.filter(({ row, maximum }) => row && timestamp < row.startedAt + 60000 && row.count >= maximum);
      if (blocked.length) return Math.max(...blocked.map(({ row }) => Math.ceil((row!.startedAt + 60000 - timestamp) / 1000)));
      for (const { key, row } of quotas) {
        const current = row && timestamp < row.startedAt + 60000;
        tx.insert(sharingLimits).values({ key, startedAt: current ? row.startedAt : timestamp, count: current ? row.count + 1 : 1 })
          .onConflictDoUpdate({ target: sharingLimits.key, set: { startedAt: current ? row.startedAt : timestamp, count: current ? row.count + 1 : 1 } }).run();
      }
      tx.insert(discordDeliveries).values({ id: id.data, userId, imageHash, status: "pending", createdAt: timestamp }).run();
      return 0;
    }, { behavior: "immediate" });
    if (retryAfter === -1) {
      const delivery = db.select().from(discordDeliveries).where(eq(discordDeliveries.id, id.data)).get()!;
      return c.json({ status: delivery.status === "sent" ? "sent" : "uncertain", messageUrl: delivery.messageUrl ?? undefined });
    }
    if (retryAfter > 0) {
      c.header("Retry-After", String(retryAfter));
      return c.json({ error: `Sharing limit reached. Try again in ${retryAfter} seconds.` }, 429);
    }
    const attachment = new FormData();
    attachment.set("payload_json", JSON.stringify({ allowed_mentions: { parse: [] }, nonce: createHash("sha256").update(`${userId}:${id.data}`).digest("hex").slice(0, 24), enforce_nonce: true, attachments: [{ id: 0, filename: "minabot-progress.png", description: "Progress card shared from minabot" }] }));
    attachment.set("files[0]", image, "minabot-progress.png");
    let response: Response;
    try { response = await request(`${channelEndpoint}/messages`, { method: "POST", headers, body: attachment, redirect: "error", signal: AbortSignal.timeout(15000) }); }
    catch { return c.json({ status: "uncertain" }); }
    if (!response.ok) {
      if (response.status >= 500) return c.json({ status: "uncertain" });
      db.delete(discordDeliveries).where(eq(discordDeliveries.id, id.data)).run();
      cachedChannel = null;
      throw new ApiError(response.status === 429 ? 429 : 422, response.status === 429 ? "Discord is busy. Wait a moment before trying again." : "Discord rejected the card. Check the bot token and its View Channel, Send Messages, and Attach Files permissions (Send Messages in Threads for threads).");
    }
    const message = await response.json().catch(() => null) as { id?: string } | null;
    const messageUrl = snowflake.test(message?.id ?? "") ? `${destination.channelUrl}/${message!.id}` : null;
    db.update(discordDeliveries).set({ status: "sent", messageUrl }).where(eq(discordDeliveries.id, id.data)).run();
    return c.json({ status: "sent", messageUrl: messageUrl ?? undefined });
  });
  return app;
}
