import type { RowDataPacket } from 'mysql2'
import { db, DB } from './db'
import { executeSoapCommand } from './soap'
import { PD_PER_UNIT } from './dpoints'
import { VP_PRICE_FACTOR } from './pay-with-dpoints'
import { MAX_COPIES, PD_PER_EUR, PV_PER_EUR, storeEuroTotal as pureEuroTotal } from './store-pricing'

/**
 * Tienda de ítems (transfiguración/equipo), portada del sistema antiguo (BENNU).
 * Catálogo en `store_category` (árbol por `code` "1-1-1") + `store_item`
 * (precio en PD o PV, cantidad, icono). Se paga con el saldo PD/PV de la cuenta y
 * los ítems se envían por correo al personaje elegido (SOAP `.send items`).
 */

export type Currency = 'pd' | 'pv'
const SAFE_NAME = /^[A-Za-z]{1,12}$/

/** Nombre de personaje válido de WoW. Evita inyección en el comando SOAP. */
export function isValidCharName(name: string): boolean {
  return SAFE_NAME.test(name)
}
const MAIL_ITEM_LIMIT = 12 // máximo de ítems por correo en AzerothCore

export interface StoreItem {
  id: number // fila de store_item (clave del carrito)
  itemId: number
  name: string
  icon: string | null
  qty: number
  currency: Currency
  price: number
  preview: string | null
}

/**
 * Un trozo del nombre de una categoría: texto y, si lo lleva, la clase de color
 * con la que hay que pintarlo (`warrior`, `second-brown`…).
 *
 * El nombre viene de BD como HTML (así lo hace el original: colorea los nombres
 * de clase y pone las etiquetas en otro tono). Se trocea AQUÍ, en el servidor, y
 * al cliente le llega ya estructurado: así se pinta con React y no hace falta
 * meter HTML de la BD en el DOM.
 */
export interface NamePart {
  text: string
  cls?: string
}

export interface StoreCategory {
  code: string
  name: NamePart[]
  /** Clase de la cabecera; a veces sustituye a first-brown para colorearla entera. */
  css: string
  depth: number
  children: StoreCategory[]
  items: StoreItem[]
}

interface CatRow extends RowDataPacket {
  code: string
  parent_code: string | null
  name: string
  css: string
  depth: number
  sort: number
}
interface ItemRow extends RowDataPacket {
  id: number
  category_code: string
  item_id: number
  name: string
  icon: string | null
  quantity: number
  currency: Currency
  price: number
  preview: string | null
  sort: number
}

/** `<span class="no-toggle X">…</span>` — la única etiqueta que usa el original. */
const NAME_SPAN = /<span class="no-toggle ([a-z0-9- ]+)">([^<]*)<\/span>/g

function unescapeHtml(s: string): string {
  return s
    .replace(/&lt;/g, '<')
    .replace(/&gt;/g, '>')
    .replace(/&quot;/g, '"')
    .replace(/&#39;/g, "'")
    .replace(/&amp;/g, '&')
}

/**
 * Trocea el nombre HTML de una categoría en partes con su color.
 *
 * Solo entiende `<span class="no-toggle X">`, que es lo único que usa el
 * catálogo. Cualquier otra etiqueta se ignora y queda su texto: si alguien mete
 * HTML raro en la BD, aquí NO puede acabar inyectado (al cliente solo le llegan
 * texto y un nombre de clase).
 */
export function parseCategoryName(html: string): NamePart[] {
  const parts: NamePart[] = []
  let last = 0
  for (const m of html.matchAll(NAME_SPAN)) {
    const plain = html.slice(last, m.index).replace(/<[^>]*>/g, '')
    if (plain) parts.push({ text: unescapeHtml(plain) })
    if (m[2]) parts.push({ text: unescapeHtml(m[2]), cls: m[1] })
    last = m.index + m[0].length
  }
  const tail = html.slice(last).replace(/<[^>]*>/g, '')
  if (tail) parts.push({ text: unescapeHtml(tail) })
  return parts.length ? parts : [{ text: unescapeHtml(html.replace(/<[^>]*>/g, '')) }]
}

/**
 * Árbol completo de la tienda (categorías anidadas con sus ítems en las hojas).
 *
 * El nombre del ítem sale de `item_data` (extraído de los DB2 del cliente
 * 3.4.3, ver sql/db2/), que es el ÚNICO sitio con el nombre en inglés: el
 * `store_item.name` del catálogo original es solo español. El de la
 * categoría sale de `store_category.name_en` (ver sql/store_category_en.sql).
 * En ambos casos, si falta el nombre en ese idioma se cae al del catálogo, que
 * siempre está.
 */
export async function getStoreCatalog(locale: string): Promise<StoreCategory[]> {
  // Solo se interpola el nombre de la COLUMNA, y sale de una lista cerrada:
  // nunca del locale crudo (que viene de la URL).
  const en = locale === 'en'
  const nameCol = en ? 'name_en' : 'name_es'
  const catNameCol = en ? 'name_en' : 'name'
  let cats: CatRow[] = []
  let items: ItemRow[] = []
  try {
    ;[cats] = await db(DB.default).query<CatRow[]>(
      `SELECT code, parent_code, COALESCE(NULLIF(${catNameCol}, ''), name) AS name, css, depth, sort
         FROM store_category ORDER BY sort`,
    )
    ;[items] = await db(DB.default).query<ItemRow[]>(
      `SELECT i.id, i.category_code, i.item_id, COALESCE(NULLIF(d.${nameCol}, ''), i.name) AS name,
              i.icon, i.quantity, i.currency, i.price, i.preview, i.sort
         FROM store_item i
         LEFT JOIN item_data d ON d.entry = i.item_id
        ORDER BY i.sort`,
    )
  } catch {
    return []
  }

  const nodes = new Map<string, StoreCategory>()
  for (const c of cats) {
    nodes.set(c.code, {
      code: c.code,
      name: parseCategoryName(c.name),
      css: c.css || 'first-brown',
      depth: c.depth,
      children: [],
      items: [],
    })
  }
  const roots: StoreCategory[] = []
  for (const c of cats) {
    const node = nodes.get(c.code)!
    if (c.parent_code && nodes.has(c.parent_code)) nodes.get(c.parent_code)!.children.push(node)
    else roots.push(node)
  }
  for (const it of items) {
    const leaf = nodes.get(it.category_code)
    if (!leaf) continue
    leaf.items.push({
      id: it.id,
      itemId: it.item_id,
      name: it.name,
      icon: it.icon,
      qty: it.quantity,
      currency: it.currency,
      price: it.price,
      preview: it.preview,
    })
  }
  return roots
}

/** Saldos PD y PV de la cuenta (para mostrar y validar). */
export async function getStoreBalances(accountId: number): Promise<{ dp: number; vp: number }> {
  if (!accountId) return { dp: 0, vp: 0 }
  try {
    const [rows] = await db(DB.default).query<RowDataPacket[]>(
      'SELECT dp, vp FROM api_points WHERE accountID = ?',
      [accountId],
    )
    return rows[0] ? { dp: Number(rows[0].dp), vp: Number(rows[0].vp) } : { dp: 0, vp: 0 }
  } catch {
    return { dp: 0, vp: 0 }
  }
}

/** Línea que manda el cliente: fila del catálogo + cuántas copias quiere. */
export interface StoreCartLine {
  id: number
  copies: number
}

export interface PricedCart {
  // `qty` = lote que entrega CADA copia (store_item.quantity);
  // `copies` = cuántas veces se compra la línea. Total de unidades = qty*copies.
  // `name` = nombre del catálogo; lo usa el concepto del cobro con tarjeta.
  lines: { id: number; itemId: number; name: string; qty: number; currency: Currency; price: number; copies: number }[]
  pdTotal: number
  vpTotal: number
}

/**
 * Concepto del cobro con tarjeta: quién compra y qué. Sale en la pasarela y en
 * su panel, así que es lo único que tendrán Stripe/SumUp para identificar un
 * pago si hay que reclamar o devolver algo a mano.
 *
 * Formato: `Tienda · BNet <email> · Cuenta <cuenta> · <personaje> · IP <ip> · Nombre (id) x2, …`
 *
 * OJO: la IP va aquí a propósito (identificar al pagador si hay fraude o una
 * devolución a mano), pero esto se la manda a la pasarela; hasta ahora solo se
 * guardaba en `stripelog.acore_ip`. Se omite si no se conoce, y también el
 * 0.0.0.0 de relleno, que no dice nada.
 *
 * Se recorta a `MAX_CONCEPT`: Stripe y SumUp limitan el campo (~250-255), y un
 * carrito de 100 líneas se pasaría de largo. Se recortan solo los ÍTEMS, nunca
 * la cabecera: quién ha pagado importa más que el listado completo, que además
 * queda entero en `store_order`.
 */
const MAX_CONCEPT = 200

export function storeConcept(
  cart: PricedCart,
  opts: { bnetEmail?: string; account?: string; character: string; ip?: string },
): string {
  const ip = opts.ip && opts.ip !== '0.0.0.0' ? opts.ip : ''
  const head = [
    'Tienda',
    opts.bnetEmail ? `BNet ${opts.bnetEmail}` : null,
    opts.account ? `Cuenta ${opts.account}` : null,
    opts.character,
    ip ? `IP ${ip}` : null,
  ]
    .filter(Boolean)
    .join(' · ')
  const parts = cart.lines.map((l) => `${l.name} (${l.itemId})${l.copies > 1 ? ` x${l.copies}` : ''}`)

  // Se van quitando ítems por el final hasta que quepa TODO, sufijo incluido: si
  // se comprueba antes de añadir el "+N más" el resultado se pasa igualmente.
  const fits = (s: string) => `${head} · ${s}`.length <= MAX_CONCEPT
  let list = parts.join(', ')
  for (let n = parts.length - 1; n >= 0 && !fits(list); n--) {
    list = n > 0 ? `${parts.slice(0, n).join(', ')}, +${parts.length - n} más` : `${parts.length} ítems`
  }
  return fits(list) ? `${head} · ${list}` : head
}

/**
 * Valida el carrito contra la BD (precios/cantidades/moneda REALES, nunca del
 * cliente) a partir de las filas `store_item.id` y las copias pedidas.
 * Devuelve null si está vacío, si alguna copia no es válida o si algún id no
 * existe en el catálogo (mismo criterio que `priceCart` de send-gift).
 */
export async function priceStoreCart(cart: StoreCartLine[]): Promise<PricedCart | null> {
  const copiesById = new Map<number, number>()
  for (const l of Array.isArray(cart) ? cart : []) {
    const id = Math.floor(Number(l?.id))
    const copies = Math.floor(Number(l?.copies))
    if (!Number.isInteger(id) || id <= 0) return null
    if (!Number.isInteger(copies) || copies < 1 || copies > MAX_COPIES) return null
    copiesById.set(id, Math.min(MAX_COPIES, (copiesById.get(id) ?? 0) + copies))
  }
  if (copiesById.size === 0) return null

  const ids = [...copiesById.keys()]
  let rows: ItemRow[] = []
  try {
    ;[rows] = await db(DB.default).query<ItemRow[]>(
      `SELECT id, item_id, name, quantity, currency, price FROM store_item WHERE id IN (${ids.map(() => '?').join(',')})`,
      ids,
    )
  } catch {
    return null
  }
  if (rows.length !== ids.length) return null // algún id no existe en el catálogo

  const lines = rows.map((r) => ({
    id: Number(r.id),
    itemId: Number(r.item_id),
    name: String(r.name),
    qty: Number(r.quantity),
    currency: r.currency,
    price: Number(r.price),
    copies: copiesById.get(Number(r.id))!,
  }))
  const sum = (c: Currency) =>
    lines.filter((l) => l.currency === c).reduce((s, l) => s + l.price * l.copies, 0)
  return { lines, pdTotal: sum('pd'), vpTotal: sum('pv') }
}

/**
 * Texto del correo. El regalo lo cambia para decir quién lo envía; sin esto el
 * destinatario recibiría un correo de "Tienda" sin saber de quién viene.
 */
export interface Mail {
  subject: string
  body: string
}

/** Correo de un regalo: dice quién lo manda (como el original). */
export function giftMail(sender: string): Mail {
  return {
    subject: 'Regalo',
    body: `¡Has recibido un regalo de ${sender}! ¡Que lo disfrutes!`,
  }
}

/** Entrada de correo: un lote de un ítem, con lo que costó (para reembolsar). */
interface CartEntry {
  i: number
  q: number
  currency: Currency
  price: number
}

/**
 * Entradas para el SOAP/el pedido: cada copia va como una entrada propia
 * (`2589:20 2589:20` = 2 lotes de 20), que es justo lo que ya sabe trocear
 * `sendStoreItems` (12 por correo) y parsear `fulfillStoreOrder`. Cada entrada
 * lleva su precio para poder reembolsar SOLO lo que no llegue a enviarse.
 */
function cartEntries(cart: PricedCart): CartEntry[] {
  return cart.lines.flatMap((l) =>
    Array.from({ length: l.copies }, () => ({ i: l.itemId, q: l.qty, currency: l.currency, price: l.price })),
  )
}

/**
 * Cobra el carrito (descuenta PD y PV de forma atómica, ambos a la vez) y envía
 * los ítems por correo al personaje. Reembolsa si el envío por SOAP falla.
 * Devuelve un código de error si algo no cuadra.
 */
export async function purchaseStoreCart(
  accountId: number,
  character: string,
  cart: PricedCart,
  mail?: Mail,
): Promise<{ success: boolean; error?: string }> {
  if (!SAFE_NAME.test(character)) return { success: false, error: 'invalidCharacter' }
  if (cart.pdTotal <= 0 && cart.vpTotal <= 0) return { success: false, error: 'emptyCart' }

  // Cobro atómico: bloquea la fila y exige saldo suficiente en AMBAS monedas.
  const conn = await db(DB.default).getConnection()
  let charged = false
  try {
    await conn.beginTransaction()
    const [pts] = await conn.query<RowDataPacket[]>(
      'SELECT dp, vp FROM api_points WHERE accountID = ? FOR UPDATE',
      [accountId],
    )
    const dp = pts[0] ? Number(pts[0].dp) : 0
    const vp = pts[0] ? Number(pts[0].vp) : 0
    if (dp < cart.pdTotal) { await conn.rollback(); return { success: false, error: 'insufficientPd' } }
    if (vp < cart.vpTotal) { await conn.rollback(); return { success: false, error: 'insufficientVp' } }
    await conn.query('UPDATE api_points SET dp = dp - ?, vp = vp - ? WHERE accountID = ?', [cart.pdTotal, cart.vpTotal, accountId])
    await conn.commit()
    charged = true
  } catch {
    try { await conn.rollback() } catch { /* ignore */ }
    return { success: false, error: 'genericError' }
  } finally {
    conn.release()
  }

  // Envío por correo (troceado a 12 ítems). Si falla a medias, se reembolsa SOLO
  // lo que no se envió: los correos que ya salieron no se pueden recuperar, y
  // devolver el carrito entero regalaría lo ya entregado.
  const entries = cartEntries(cart)
  const sent = await sendStoreItems(character, entries, mail)
  if (sent < entries.length) {
    const left = entries.slice(sent)
    const back = (c: Currency) => left.filter((e) => e.currency === c).reduce((s, e) => s + e.price, 0)
    const pdBack = back('pd')
    const vpBack = back('pv')
    if (charged && (pdBack > 0 || vpBack > 0)) {
      await db(DB.default)
        .query('UPDATE api_points SET dp = dp + ?, vp = vp + ? WHERE accountID = ?', [pdBack, vpBack, accountId])
        .catch(() => {})
    }
    // Si no salió ni un correo es el fallo de siempre (SOAP caído). Si salió
    // alguno, el jugador tiene parte de la compra y solo se le devuelve el resto.
    return { success: false, error: sent === 0 ? 'deliveryFailed' : 'partialDelivery' }
  }
  return { success: true }
}

/**
 * Coste del carrito en euros para pagar con tarjeta. Inverso exacto de cómo se
 * derivan los precios PD/PV de un precio en euros en el resto de servicios:
 * 100 PD = 1 € y los PV cuestan VP_PRICE_FACTOR× (200 PV = 1 €).
 *
 * La implementación vive en `store-pricing` (módulo puro) para que el carrito
 * del cliente enseñe EXACTAMENTE el importe que aquí se valida y se cobra. Este
 * `if` es la red de seguridad de esa duplicación: si alguien cambia PD_PER_UNIT
 * o VP_PRICE_FACTOR y no toca store-pricing, salta al primer uso en vez de
 * cobrar mal en silencio.
 */
export function storeEuroTotal(pdTotal: number, vpTotal: number): number {
  if (PD_PER_UNIT !== PD_PER_EUR || PD_PER_UNIT * VP_PRICE_FACTOR !== PV_PER_EUR) {
    throw new Error(
      `store-pricing desincronizado: PD_PER_UNIT=${PD_PER_UNIT}, VP_PRICE_FACTOR=${VP_PRICE_FACTOR}` +
        ` pero store-pricing tiene PD_PER_EUR=${PD_PER_EUR}, PV_PER_EUR=${PV_PER_EUR}`,
    )
  }
  return pureEuroTotal(pdTotal, vpTotal)
}

/** Guarda el carrito como pedido pendiente (para entregarlo tras el pago con tarjeta). */
export async function createStoreOrder(
  ref: string,
  accountId: number,
  character: string,
  cart: PricedCart,
): Promise<boolean> {
  // Formato `itemId:qty:precio:moneda` por entrada. El precio va guardado (y no
  // se relee del catálogo al entregar) por dos razones: 114 item_id son
  // ambiguos —el mismo objeto se vende a 50 PV y a 100 PD—, y aunque no lo
  // fueran, hay que reembolsar lo que se COBRÓ, no lo que valga el ítem el día
  // de la entrega. Se guarda en PD/PV, no en céntimos, para que el reembolso
  // redondee igual que el cobro (storeEuroTotal, una sola vez sobre el total).
  const items = cartEntries(cart).map((e) => `${e.i}:${e.q}:${e.price}:${e.currency}`).join(',')
  const amount = storeEuroTotal(cart.pdTotal, cart.vpTotal)
  try {
    await db(DB.default).query(
      'INSERT INTO store_order (ref, account_id, character_name, items, amount) VALUES (?, ?, ?, ?, ?)',
      [ref, accountId, character, items, amount],
    )
    return true
  } catch {
    return false
  }
}

/**
 * Entrega un pedido pagado con tarjeta: busca el carrito por `ref` y envía los
 * ítems por correo. Lo llama el fulfillment del servicio `store` (webhook/return).
 *
 * Aquí no se puede devolver saldo como en el pago con PD/PV: el dinero lo tiene
 * la pasarela. Por eso devuelve cuánto hay que reembolsarle al cliente
 * (`refundEur`), y quien llama —que es quien sabe si fue Stripe o SumUp— pide el
 * reembolso por su API. No puede duplicar entregas porque `claimPaidCheckout` /
 * `fulfillSumUpCheckout` reclaman el pago una sola vez.
 */
export async function fulfillStoreOrder(
  character: string,
  ref: string,
  mail?: Mail,
): Promise<{ ok: boolean; refundEur?: number }> {
  if (!ref) return { ok: false }
  let rows: RowDataPacket[] = []
  try {
    ;[rows] = await db(DB.default).query<RowDataPacket[]>(
      'SELECT character_name, items, amount FROM store_order WHERE ref = ?',
      [ref],
    )
  } catch {
    return { ok: false }
  }
  const order = rows[0]
  if (!order) return { ok: false }
  const target = character || String(order.character_name)

  // `itemId:qty:precio:moneda`. Los pedidos anteriores a este formato solo traen
  // `itemId:qty`: se entregan igual, pero de esos no se puede calcular cuánto
  // reembolsar (no llevan precio), así que se avisa y se deja a mano.
  const entries = String(order.items)
    .split(',')
    .map((p) => {
      const [i, q, price, cur] = p.split(':')
      return { i: Number(i), q: Number(q), price: Number(price), currency: cur as Currency }
    })
    .filter((e) => e.i > 0 && e.q >= 1)
  if (entries.length === 0) return { ok: false }
  const priced = entries.every((e) => e.price > 0 && (e.currency === 'pd' || e.currency === 'pv'))

  const sent = await sendStoreItems(target, entries, mail)
  if (sent === entries.length) return { ok: true }

  if (!priced) {
    console.error(
      `[store] pedido ${ref}: entregadas ${sent}/${entries.length} entradas, pero es de formato antiguo` +
        ` (sin precios) y no se puede calcular el reembolso. Revisar a mano.`,
    )
    return { ok: false }
  }
  const left = entries.slice(sent)
  const back = (c: Currency) => left.filter((e) => e.currency === c).reduce((s, e) => s + e.price, 0)
  // Nunca más de lo cobrado: los dos importes se redondean por separado y podrían
  // descuadrar un céntimo si se entregó justo la mitad de un ítem de PV impar.
  const refundEur = Math.min(storeEuroTotal(back('pd'), back('pv')), Number(order.amount))
  console.error(`[store] pedido ${ref}: entregadas ${sent}/${entries.length}; se reembolsan ${refundEur} €`)
  return { ok: false, refundEur }
}

/**
 * Envía los ítems de la tienda por correo (SOAP `.send items`), troceado a 12
 * por correo.
 *
 * Devuelve CUÁNTAS entradas se entregaron, no un booleano: un correo enviado ya
 * no se puede deshacer, así que si el 3.º de 9 falla, los 2 primeros están en el
 * buzón del jugador y quien llame tiene que saberlo para reembolsar solo el
 * resto. Cada correo es un `.send items`, así que el troceo es atómico por
 * correo: un chunk sale entero o no sale.
 */
async function sendStoreItems(
  character: string,
  items: { i: number; q: number }[],
  mail?: Mail,
): Promise<number> {
  if (!SAFE_NAME.test(character)) return 0
  // Los datos salen de la BD: si algo no cuadra es un bug, y se prefiere no
  // enviar nada a enviar de menos y descuadrar el reembolso por índice.
  if (items.length === 0 || items.some((it) => !(Number(it.i) > 0) || !(Number(it.q) >= 1))) return 0
  const subject = mail?.subject ?? 'Tienda'
  const body = mail?.body ?? 'Has recibido los objetos de la tienda. ¡Que los disfrutes!'
  let sent = 0
  for (let n = 0; n < items.length; n += MAIL_ITEM_LIMIT) {
    const chunk = items.slice(n, n + MAIL_ITEM_LIMIT)
    const itemsStr = chunk.map((it) => `${Math.floor(it.i)}:${Math.floor(it.q)}`).join(' ')
    const res = await executeSoapCommand(`.send items "${character}" "${subject}" "${body}" ${itemsStr}`)
    if (res === null) return sent
    sent += chunk.length
  }
  return sent
}
