Skip to content
import { initDb, query, transaction } from "./db.ts";
import {
  type HeroProgression,
  initialProgression,
  parseProgression,
  serializeProgression,
} from "./progression.ts";

export type ClassId = "maiden" | "witch" | "assassin";

export interface Hero {
  userId: number;
  name: string;
  class: ClassId;
  level: number;
  anima: number;
  progression: HeroProgression;
  deck: string[];
  collection: Record<string, number>;
}

interface HeroRow {
  user_id: number | string;
  name: string;
  class: string;
  level: number | string;
  anima: number | string;
  progression: string;
  deck: string;
}

function parseDeck(value: string): string[] {
  let deck: string[] = [];
  try {
    const parsed = JSON.parse(value);
    if (Array.isArray(parsed)) {
      deck = parsed.filter((c): c is string => typeof c === "string");
    }
  } catch {
    deck = [];
  }
  return deck;
}

function rowToHero(row: HeroRow): Omit<Hero, "collection"> {
  return {
    userId: Number(row.user_id),
    name: String(row.name),
    class: String(row.class) as ClassId,
    level: Number(row.level),
    anima: Number(row.anima),
    progression: parseProgression(row.progression, Number(row.level)),
    deck: parseDeck(row.deck),
  };
}

function cardCounts(cards: string[]): Record<string, number> {
  const counts: Record<string, number> = {};
  for (const id of cards) counts[id] = (counts[id] ?? 0) + 1;
  return counts;
}

async function getCollection(
  userId: number,
  deck: string[],
): Promise<Record<string, number>> {
  let result = await query(
    "SELECT card_id, quantity FROM card_collection WHERE user_id = ?",
    [userId],
  );
  if (result.rows.length === 0 && deck.length > 0) {
    const tx = await transaction();
    try {
      for (const [cardId, quantity] of Object.entries(cardCounts(deck))) {
        await tx.execute({
          sql:
            `INSERT OR IGNORE INTO card_collection (user_id, card_id, quantity)
                VALUES (?, ?, ?)`,
          args: [userId, cardId, quantity],
        });
      }
      await tx.commit();
    } catch (error) {
      await tx.rollback();
      throw error;
    } finally {
      tx.close();
    }
    result = await query(
      "SELECT card_id, quantity FROM card_collection WHERE user_id = ?",
      [userId],
    );
  }
  return Object.fromEntries(
    result.rows.map((row) => [String(row.card_id), Number(row.quantity)]),
  );
}

export async function getHero(userId: number): Promise<Hero | null> {
  await initDb();
  const result = await query("SELECT * FROM heroes WHERE user_id = ?", [
    userId,
  ]);
  const row = result.rows[0];
  if (!row) return null;
  const hero = rowToHero(row as unknown as HeroRow);
  const serializedProgression = serializeProgression(hero.progression);
  if (String(row.progression) !== serializedProgression) {
    await query(
      "UPDATE heroes SET progression = ? WHERE user_id = ? AND progression = ?",
      [serializedProgression, userId, String(row.progression)],
    );
  }
  return { ...hero, collection: await getCollection(userId, hero.deck) };
}

const CLASS_IDS: ClassId[] = ["maiden", "witch", "assassin"];

export async function createHero(
  userId: number,
  name: string,
  klass: ClassId,
  startingDeck: string[],
): Promise<Hero> {
  await initDb();
  await query(
    `INSERT INTO heroes (user_id, name, class, level, anima, progression, deck)
     VALUES (?, ?, ?, 1, 40, ?, ?)
     ON CONFLICT(user_id) DO NOTHING`,
    [
      userId,
      name,
      klass,
      serializeProgression(initialProgression()),
      JSON.stringify(startingDeck),
    ],
  );
  const hero = await getHero(userId);
  if (!hero) throw new Error("Failed to create hero");
  return hero;
}

export async function saveHeroProgress(
  userId: number,
  progress: {
    anima: number;
    level: number;
    progression: HeroProgression;
    deck: string[];
    expectedAnima: number;
    expectedLevel: number;
    expectedProgression: HeroProgression;
    expectedDeck: string[];
  },
): Promise<boolean> {
  await initDb();
  const anima = Math.max(0, Math.floor(progress.anima));
  const level = Math.max(1, Math.min(999, Math.floor(progress.level)));
  const deck = progress.deck
    .filter((c) => typeof c === "string" && c.length < 64)
    .slice(0, 80);
  await getCollection(userId, progress.expectedDeck);

  const previousCounts = cardCounts(progress.expectedDeck);
  const nextCounts = cardCounts(deck);
  const cardIds = new Set([
    ...Object.keys(previousCounts),
    ...Object.keys(nextCounts),
  ]);
  const tx = await transaction();
  try {
    const update = await tx.execute({
      sql: `UPDATE heroes SET anima = ?, level = ?, progression = ?, deck = ?
            WHERE user_id = ? AND anima = ? AND level = ? AND progression = ? AND deck = ?`,
      args: [
        anima,
        level,
        serializeProgression(progress.progression),
        JSON.stringify(deck),
        userId,
        progress.expectedAnima,
        progress.expectedLevel,
        serializeProgression(progress.expectedProgression),
        JSON.stringify(progress.expectedDeck),
      ],
    });
    if (update.rowsAffected !== 1) {
      await tx.rollback();
      return false;
    }
    for (const cardId of cardIds) {
      const delta = (nextCounts[cardId] ?? 0) - (previousCounts[cardId] ?? 0);
      if (delta > 0) {
        await tx.execute({
          sql: `INSERT INTO card_collection (user_id, card_id, quantity)
                VALUES (?, ?, ?)
                ON CONFLICT(user_id, card_id)
                DO UPDATE SET quantity = quantity + excluded.quantity`,
          args: [userId, cardId, delta],
        });
      } else if (delta < 0) {
        await tx.execute({
          sql: `UPDATE card_collection
                SET quantity = MAX(0, quantity + ?)
                WHERE user_id = ? AND card_id = ?`,
          args: [delta, userId, cardId],
        });
      }
    }
    await tx.commit();
    return true;
  } catch (error) {
    await tx.rollback();
    throw error;
  } finally {
    tx.close();
  }
}

export async function saveHeroDeck(
  userId: number,
  deck: string[],
): Promise<boolean> {
  await initDb();
  const requested = cardCounts(deck);
  const tx = await transaction();
  try {
    const result = await tx.execute({
      sql: "SELECT card_id, quantity FROM card_collection WHERE user_id = ?",
      args: [userId],
    });
    const owned = Object.fromEntries(
      result.rows.map((row) => [String(row.card_id), Number(row.quantity)]),
    );
    if (
      Object.entries(requested).some(([id, count]) => count > (owned[id] ?? 0))
    ) {
      await tx.rollback();
      return false;
    }
    await tx.execute({
      sql: "UPDATE heroes SET deck = ? WHERE user_id = ?",
      args: [JSON.stringify(deck), userId],
    });
    await tx.commit();
    return true;
  } catch (error) {
    await tx.rollback();
    throw error;
  } finally {
    tx.close();
  }
}

export function isClassId(value: unknown): value is ClassId {
  return typeof value === "string" && CLASS_IDS.includes(value as ClassId);
}