import 'zx/globals'

import { writeFileSync } from 'node:fs'
import { Command } from '@commander-js/extra-typings'
import Table from 'cli-table3'

import {
	buildCatalogLoad,
	CATALOG_INSERT_COLUMNS,
	CatalogKind,
	isSellableRarity,
	priceForRarity,
	subscriberPriceFor,
} from '../../../../apps/econ/src/catalog-load'
import { getRepoRoot } from '../path'
import { readCaptures } from './catalog.cmd'

/**
 * Generate `apps/econ/static/storefronts/sf3-2025.json` — the general store as the 2025 client
 * sees it: everything sf3 already sells, PLUS every sellable row of the item catalog.
 *
 *     runx storefront build
 *
 * One merged file rather than a second storefront id. The client asks for storefront 3 either
 * way; the econ worker picks WHICH file by the caller's build (`rn.ver`), so an older build
 * keeps exactly the sf3 it has always had and a newer one gets the same store with the
 * catalog's items added to it.
 *
 * A static catalog like every other `sf{N}.json`, because that is the only shape
 * `loadStorefront` reads and because browse and BUY have to agree: `findStoreItem` resolves a
 * purchase against the very same file, so an item that is not in it cannot be bought.
 *
 * The two id spaces do not collide, which is what makes the merge safe: every captured sf3 id
 * is 2764 or below (one outlier aside) and every catalog id starts at `CATALOG_ID_BASE`
 * (10000). Nothing is renumbered, and an id means the same item in both files.
 */

const OUT_DIR = 'apps/econ/static/storefronts'

/** The captured general store, whose items the generated one is built on top of. */
const BASE_STOREFRONT = `${OUT_DIR}/sf3.json`

/** The merged store the newer client is served, and the `StorefrontType` it reports. */
const OUT_FILE = `${OUT_DIR}/sf3-2025.json`
const STOREFRONT_TYPE = 3

/** RecCenterTokens — the currency the avatar storefronts sell in. */
const CURRENCY_TYPE_TOKENS = 2

/**
 * Far enough out that the client never refetches — the same sentinel sf3 carries. A real
 * storefront rotates; this one is regenerated by hand, so it must not expire on its own.
 */
const NEXT_UPDATE = '2226-06-14T00:12:20.1324853Z'

const build = new Command('build')
	.description('Generate the merged 2025 general store (sf3 + the item catalog)')
	.action(async () => {
		const { avatarItems, skins } = await readCaptures()

		// sf3's own items, carried through UNCHANGED. They keep their ids, their prices and their
		// gift-drops: the merge adds to the store the older client knows, it does not restate it.
		const basePath = path.join(getRepoRoot(), BASE_STOREFRONT)
		const base = JSON.parse(await fs.readFile(basePath, 'utf8')) as {
			StoreItems: Array<{ PurchasableItemId: number }>
		}

		// The catalog ids come from the same loader the DB load uses, so the number in this file is
		// the number in the table. An avatar item's `item_key` IS its `AvatarItemDesc`, which is
		// what lets the two be matched up without a second numbering scheme to keep in step.
		const { rows } = buildCatalogLoad(avatarItems, skins)
		const kindAt = CATALOG_INSERT_COLUMNS.indexOf('kind')
		const catalogIdByDesc = new Map(
			rows.filter((r) => r.values[kindAt] === CatalogKind.AvatarItem).map((r) => [r.key, r.id])
		)

		const byRarity = new Map<number, number>()
		const excluded = new Map<number, number>()
		const forSale = avatarItems.filter((item) => {
			if (isSellableRarity(item.Rarity)) return true
			excluded.set(item.Rarity, (excluded.get(item.Rarity) ?? 0) + 1)
			return false
		})
		const storeItems = forSale.map((item) => {
			const catalogId = catalogIdByDesc.get(item.AvatarItemDesc)
			if (catalogId === undefined) {
				throw new Error(`no catalog id for avatar item ${item.AvatarItemDesc}`)
			}
			const price = priceForRarity(item.Rarity)
			byRarity.set(item.Rarity, (byRarity.get(item.Rarity) ?? 0) + 1)
			const priceEntry = (p: number) => [
				{ CurrencyType: CURRENCY_TYPE_TOKENS, Price: p, StorefrontSaleData: null, Type: 0 },
			]
			return {
				// Key order and every constant field mirror sf3, because the client's parser reads
				// this shape and an sf that differs from the one known to work is a needless variable.
				GiftDrop: {
					AvatarItemDesc: item.AvatarItemDesc,
					AvatarItemType: item.AvatarItemType,
					ConsumableItemDesc: '',
					Context: 0,
					Currency: 0,
					CurrencyType: 0,
					EquipmentModificationGuid: '',
					EquipmentPrefabName: '',
					FriendlyName: item.FriendlyName,
					// sf3 has GiftDropId === PurchasableItemId on all 1161 of its items; keep that.
					GiftDropId: catalogId,
					IsQuery: false,
					ItemSetFriendlyName: '',
					ItemSetId: 0,
					Rarity: item.Rarity,
					SubscribersOnly: false,
					// The catalog's tooltip is genuinely null on some rows; the client's field is a
					// string, so this is the one place the distinction is flattened.
					Tooltip: item.Tooltip ?? '',
					Unique: true,
				},
				IsFeatured: false,
				Prices: priceEntry(price),
				// The catalog id, directly — one number, no second numbering to keep in step. It is
				// already clear of every captured storefront's ids (see `CATALOG_ID_BASE`), which is
				// what lets it be used here as-is.
				PurchasableItemId: catalogId,
				SubscriberPrices: priceEntry(subscriberPriceFor(price)),
				Type: 0,
			}
		})

		// An id colliding across the two would make one number mean two different items depending
		// on which half answered first — refused rather than resolved by ordering, since the whole
		// point of `CATALOG_ID_BASE` is that this cannot happen.
		const baseIds = new Set(base.StoreItems.map((i) => i.PurchasableItemId))
		const collisions = storeItems.filter((i) => baseIds.has(i.PurchasableItemId))
		if (collisions.length > 0) {
			throw new Error(
				`${collisions.length} catalog id(s) collide with sf3's own, starting at ` +
					`${collisions[0]?.PurchasableItemId}. The catalog must be renumbered above them.`
			)
		}

		const storefront = {
			NextUpdate: NEXT_UPDATE,
			StoreItems: [...base.StoreItems, ...storeItems],
			StorefrontType: STOREFRONT_TYPE,
			// Deliberately 0 — the discount is expressed ONLY in `SubscriberPrices`. Announcing it
			// again here risks a client taking the 10% off an already-discounted price and posting
			// through the server's own subscriber floor, refused as "Price has changed".
			SubscriberDiscountPercent: 0,
		}

		writeFileSync(OUT_FILE, `${JSON.stringify(storefront, null, '\t')}\n`)

		const table = new Table({ head: ['rarity', 'items', 'price', 'subscriber'] })
		for (const rarity of [...byRarity.keys()].sort((a, b) => a - b)) {
			const price = priceForRarity(rarity)
			table.push([
				String(rarity),
				String(byRarity.get(rarity)),
				String(price),
				String(subscriberPriceFor(price)),
			])
		}
		console.log(table.toString())
		for (const [rarity, n] of [...excluded].sort((a, b) => a[0] - b[0])) {
			console.log(chalk.yellow(`excluded ${n} item(s) of rarity ${rarity} — not for sale`))
		}
		console.log(
			chalk.green(
				`✓ wrote ${OUT_FILE}: ${storefront.StoreItems.length} items ` +
					`(${base.StoreItems.length} from sf3 + ${storeItems.length} from the catalog, ` +
					`${((await fs.stat(OUT_FILE)).size / 1024 / 1024).toFixed(1)} MB)`
			)
		)
	})

export const storefrontCmd = new Command('storefront')
	.description('Generate the storefront catalogs the econ worker serves')
	// Bare `storefront` (no subcommand) prints help and exits cleanly, rather than commander's
	// default "missing command" error (exit 1).
	.action((_opts, command: Command) => command.outputHelp())
	.addCommand(build)
	.addHelpText(
		'after',
		`
sf3-2025 is the general store as the 2025 client sees it: everything sf3 already sells plus
every sellable row of the item catalog, priced by rarity. The client asks for storefront 3
either way — the econ worker picks which file by the caller's build, so an older client keeps
the sf3 it has always had.

Regenerate whenever apps/econ/static/db/*.json changes, and after every \`runx catalog load\`:
a load renumbers catalog_id, and a stale file would list the wrong items.

Examples:
  $ runx storefront build             # write apps/econ/static/storefronts/sf3-2025.json`
	)
