๐๏ธ
1import type { Card } from "@/lib/scryfall-types";
2import { getPrimaryFace } from "./card-faces";
3import type { DeckCard, GroupBy, SortBy } from "./deck-types";
4
5/**
6 * Card lookup function type (returns Card data for a given Scryfall ID)
7 */
8export type CardLookup = (card: DeckCard) => Card | undefined;
9
10/**
11 * Extract the primary type from a card's type line
12 * Example: "Legendary Creature โ Human Wizard" โ "Creature"
13 */
14export function extractPrimaryType(typeLine: string | undefined): string {
15 if (!typeLine) return "Other";
16
17 // Split on "โ" or "-" to remove subtypes
18 const mainPart = typeLine.split(/โ|-/)[0].trim();
19
20 // Common type order: "Legendary Enchantment Creature"
21 // We want the rightmost non-supertype word
22 const types = [
23 "Creature",
24 "Instant",
25 "Sorcery",
26 "Enchantment",
27 "Artifact",
28 "Planeswalker",
29 "Land",
30 "Battle",
31 "Kindred",
32 "Tribal",
33 ];
34
35 for (const type of types) {
36 if (mainPart.includes(type)) {
37 return type;
38 }
39 }
40
41 return "Other";
42}
43
44/**
45 * Extract subtypes from a card's type line
46 * Example: "Legendary Creature โ Human Wizard" โ ["Human", "Wizard"]
47 */
48export function extractSubtypes(typeLine: string | undefined): string[] {
49 if (!typeLine) return [];
50
51 // Split on "โ" or "-" to get subtypes
52 const parts = typeLine.split(/โ|-/);
53 if (parts.length < 2) return [];
54
55 const subtypesPart = parts[1].trim();
56 return subtypesPart.split(/\s+/).filter((s) => s.length > 0);
57}
58
59// Color combination names (guild/shard/wedge names)
60// Keys are in WUBRG order (how we sort them internally)
61// But we display the canonical MTG order in the UI
62const COLOR_NAMES_BY_SORTED: Record<
63 string,
64 { name: string; canonical: string }
65> = {
66 // Mono
67 W: { name: "White", canonical: "W" },
68 U: { name: "Blue", canonical: "U" },
69 B: { name: "Black", canonical: "B" },
70 R: { name: "Red", canonical: "R" },
71 G: { name: "Green", canonical: "G" },
72 // Guilds (2-color) - already in WUBRG order
73 WU: { name: "Azorius", canonical: "WU" },
74 WB: { name: "Orzhov", canonical: "WB" },
75 WR: { name: "Boros", canonical: "WR" },
76 WG: { name: "Selesnya", canonical: "WG" },
77 UB: { name: "Dimir", canonical: "UB" },
78 UR: { name: "Izzet", canonical: "UR" },
79 UG: { name: "Simic", canonical: "UG" },
80 BR: { name: "Rakdos", canonical: "BR" },
81 BG: { name: "Golgari", canonical: "BG" },
82 RG: { name: "Gruul", canonical: "RG" },
83 // Shards (3-color, color + 2 allies)
84 WUG: { name: "Bant", canonical: "GWU" }, // Sorted WUG, shown as GWU
85 WUB: { name: "Esper", canonical: "WUB" },
86 UBR: { name: "Grixis", canonical: "UBR" },
87 BRG: { name: "Jund", canonical: "BRG" },
88 WRG: { name: "Naya", canonical: "RGW" }, // Sorted WRG, shown as RGW
89 // Wedges (3-color, color + 2 enemies)
90 WBG: { name: "Abzan", canonical: "WBG" },
91 WUR: { name: "Jeskai", canonical: "URW" }, // Sorted WUR, shown as URW
92 UBG: { name: "Sultai", canonical: "BGU" }, // Sorted UBG, shown as BGU
93 WBR: { name: "Mardu", canonical: "RWB" }, // Sorted WBR, shown as RWB
94 URG: { name: "Temur", canonical: "GUR" }, // Sorted URG, shown as GUR
95 // 4-color
96 WUBR: { name: "Non-Green", canonical: "WUBR" },
97 WUBG: { name: "Non-Red", canonical: "WUBG" },
98 WURG: { name: "Non-Black", canonical: "WURG" },
99 WBRG: { name: "Non-Blue", canonical: "WBRG" },
100 UBRG: { name: "Non-White", canonical: "UBRG" },
101 // 5-color
102 WUBRG: { name: "Five-Color", canonical: "WUBRG" },
103};
104
105/**
106 * Get a label for a color identity
107 * Example: ["W", "U"] โ "Azorius (WU)"
108 * Example: ["U"] โ "Blue"
109 * Example: [] โ "Colorless"
110 */
111export function getColorIdentityLabel(
112 colorIdentity: string[] | undefined,
113): string {
114 if (!colorIdentity || colorIdentity.length === 0) return "Colorless";
115
116 // Sort in WUBRG order for lookup
117 const order = ["W", "U", "B", "R", "G"];
118 const sorted = [...colorIdentity].sort(
119 (a, b) => order.indexOf(a) - order.indexOf(b),
120 );
121
122 const sortedKey = sorted.join("");
123 const colorInfo = COLOR_NAMES_BY_SORTED[sortedKey];
124
125 // For mono-color, just return the name
126 if (sorted.length === 1) return colorInfo?.name ?? sortedKey;
127
128 // For multi-color, return "Name (Canonical)"
129 return colorInfo ? `${colorInfo.name} (${colorInfo.canonical})` : sortedKey;
130}
131
132/**
133 * Get mana value bucket for grouping
134 * Example: 0 โ "0", 0.5 โ "1", 3 โ "3", 8 โ "8"
135 */
136export function getManaValueBucket(cmc: number | undefined): string {
137 if (cmc === undefined || cmc === 0) return "0";
138 return Math.ceil(cmc).toString();
139}
140
141/**
142 * Sort cards by the specified method
143 */
144export function sortCards(
145 cards: DeckCard[],
146 cardLookup: CardLookup,
147 sortBy: SortBy,
148): DeckCard[] {
149 const sorted = [...cards];
150
151 switch (sortBy) {
152 case "name": {
153 sorted.sort((a, b) => {
154 const cardA = cardLookup(a);
155 const cardB = cardLookup(b);
156 const nameA = cardA?.name ?? "";
157 const nameB = cardB?.name ?? "";
158 return nameA.localeCompare(nameB);
159 });
160 break;
161 }
162
163 case "manaValue": {
164 sorted.sort((a, b) => {
165 const cardA = cardLookup(a);
166 const cardB = cardLookup(b);
167 const cmcA = cardA?.cmc ?? 0;
168 const cmcB = cardB?.cmc ?? 0;
169 if (cmcA !== cmcB) return cmcA - cmcB;
170 // Tiebreak by name
171 return (cardA?.name ?? "").localeCompare(cardB?.name ?? "");
172 });
173 break;
174 }
175
176 case "rarity": {
177 const rarityOrder: Record<string, number> = {
178 common: 0,
179 uncommon: 1,
180 rare: 2,
181 mythic: 3,
182 special: 4,
183 bonus: 5,
184 };
185
186 sorted.sort((a, b) => {
187 const cardA = cardLookup(a);
188 const cardB = cardLookup(b);
189 const rarityA = rarityOrder[cardA?.rarity ?? ""] ?? 999;
190 const rarityB = rarityOrder[cardB?.rarity ?? ""] ?? 999;
191 if (rarityA !== rarityB) return rarityA - rarityB;
192 // Tiebreak by name
193 return (cardA?.name ?? "").localeCompare(cardB?.name ?? "");
194 });
195 break;
196 }
197 }
198
199 return sorted;
200}
201
202/**
203 * Group cards by the specified method
204 * Returns a Map of group name โ cards in that group
205 * also includes a bool to indicate if the group is based on a user tag
206 *
207 * Note: Cards with multiple tags will appear in multiple groups
208 */
209export function groupCards(
210 cards: DeckCard[],
211 cardLookup: CardLookup,
212 groupBy: GroupBy,
213): Map<
214 string,
215 {
216 cards: DeckCard[];
217 forTag: boolean;
218 }
219> {
220 const groups = new Map<
221 string,
222 {
223 cards: DeckCard[];
224 forTag: boolean;
225 }
226 >();
227
228 switch (groupBy) {
229 case "none": {
230 groups.set("all", { cards, forTag: false });
231 break;
232 }
233
234 case "type": {
235 for (const card of cards) {
236 const cardData = cardLookup(card);
237 const face = cardData ? getPrimaryFace(cardData) : undefined;
238 const type = extractPrimaryType(face?.type_line);
239 const group = groups.get(type) ?? { cards: [], forTag: false };
240 group.cards.push(card);
241 groups.set(type, group);
242 }
243 break;
244 }
245
246 case "typeAndTags": {
247 for (const card of cards) {
248 if (!card.tags || card.tags.length === 0) {
249 // No tags โ group by type
250 const cardData = cardLookup(card);
251 const face = cardData ? getPrimaryFace(cardData) : undefined;
252 const type = extractPrimaryType(face?.type_line);
253 const group = groups.get(type) ?? { cards: [], forTag: false };
254 group.cards.push(card);
255 groups.set(type, group);
256 } else {
257 // Has tags โ add to each unique tag group (dedupe to handle malformed data)
258 for (const tag of new Set(card.tags)) {
259 const group = groups.get(tag) ?? { cards: [], forTag: true };
260 group.forTag = true;
261 group.cards.push(card);
262 groups.set(tag, group);
263 }
264 }
265 }
266 break;
267 }
268
269 case "subtype": {
270 for (const card of cards) {
271 const cardData = cardLookup(card);
272 const face = cardData ? getPrimaryFace(cardData) : undefined;
273 const subtypes = extractSubtypes(face?.type_line);
274
275 if (subtypes.length === 0) {
276 const group = groups.get("(No Subtype)") ?? {
277 cards: [],
278 forTag: false,
279 };
280 group.cards.push(card);
281 groups.set("(No Subtype)", group);
282 } else {
283 // Add card to each subtype group it belongs to
284 for (const subtype of subtypes) {
285 const group = groups.get(subtype) ?? { cards: [], forTag: false };
286 group.cards.push(card);
287 groups.set(subtype, group);
288 }
289 }
290 }
291 break;
292 }
293
294 case "manaValue": {
295 for (const card of cards) {
296 const cardData = cardLookup(card);
297 const bucket = getManaValueBucket(cardData?.cmc);
298 const group = groups.get(bucket) ?? { cards: [], forTag: false };
299 group.cards.push(card);
300 groups.set(bucket, group);
301 }
302 break;
303 }
304
305 case "colorIdentity": {
306 for (const card of cards) {
307 const cardData = cardLookup(card);
308 const label = getColorIdentityLabel(cardData?.color_identity);
309 const group = groups.get(label) ?? { cards: [], forTag: false };
310 group.cards.push(card);
311 groups.set(label, group);
312 }
313 break;
314 }
315 }
316
317 return groups;
318}
319
320/**
321 * Sort group names for consistent display order
322 */
323export function sortGroupNames(
324 groups: Map<string, { cards: DeckCard[]; forTag: boolean }>,
325 groupBy: GroupBy,
326): string[] {
327 const groupNames = Array.from(groups.keys());
328
329 switch (groupBy) {
330 case "manaValue": {
331 // Sort numerically: 0, 1, 2, ..., 7+
332 return groupNames.sort((a, b) => {
333 if (a === "7+") return 1;
334 if (b === "7+") return -1;
335 return Number.parseInt(a, 10) - Number.parseInt(b, 10);
336 });
337 }
338
339 case "colorIdentity": {
340 // Sort by WUBRG order, then by length (mono โ multi)
341 const order = ["W", "U", "B", "R", "G"];
342 return groupNames.sort((a, b) => {
343 if (a === "Colorless") return -1;
344 if (b === "Colorless") return 1;
345
346 // Compare by length first (mono < dual < tri, etc)
347 if (a.length !== b.length) return a.length - b.length;
348
349 // Same length, compare by first color
350 const firstA = order.indexOf(a[0]);
351 const firstB = order.indexOf(b[0]);
352 return firstA - firstB;
353 });
354 }
355
356 case "typeAndTags": {
357 // Sort tags before types, then alphabetically within each category
358 return groupNames.sort((a, b) => {
359 const aIsSpecial = a.startsWith("(");
360 const bIsSpecial = b.startsWith("(");
361 const aIsTag = groups.get(a)?.forTag ?? false;
362 const bIsTag = groups.get(b)?.forTag ?? false;
363
364 // Put special groups at the end
365 if (aIsSpecial && !bIsSpecial) return 1;
366 if (!aIsSpecial && bIsSpecial) return -1;
367
368 // Tags before types
369 if (aIsTag && !bIsTag) return -1;
370 if (!aIsTag && bIsTag) return 1;
371
372 // Both tags, both types, or both special: sort alphabetically
373 return a.localeCompare(b);
374 });
375 }
376
377 default:
378 // Alphabetical for type, subtype, etc
379 return groupNames.sort((a, b) => {
380 const aIsSpecial = a.startsWith("(");
381 const bIsSpecial = b.startsWith("(");
382
383 // Put special groups at the end
384 if (aIsSpecial && !bIsSpecial) return 1;
385 if (!aIsSpecial && bIsSpecial) return -1;
386
387 // Both special or both normal: sort alphabetically
388 return a.localeCompare(b);
389 });
390 }
391}