import { Injectable } from '@nestjs/common';
import { Prisma } from '@prisma/client';
import { PrismaService } from '../../shared/prisma/prisma.service';
import {
  DEFAULT_MARKET_TYPES,
  FOOTBALL_MARKET_CATALOG,
  buildMarketLineKey,
  buildMarketTemplate,
  defaultSelectionName,
  isSettlementSupportedMarketType,
  marketUsesLineValue,
  resolveMarketText,
  sanitizeLocalizedText,
  type LocalizedText,
} from '@thebet365/shared';
import { appBadRequest, appNotFound } from '../../shared/common/app-error';

type LocalizedInput = Record<string, string | undefined | null>;

type SelectionDraft = {
  id?: string;
  selectionCode: string;
  selectionName?: string;
  nameI18n?: LocalizedInput | null;
  odds: number;
  status?: string;
  sortOrder?: number;
};

type MarketDraft = {
  id?: string;
  marketType: string;
  marketKey?: string | null;
  lineKey?: string | null;
  period?: string;
  lineValue?: number | null;
  paramsJson?: Record<string, unknown> | null;
  status?: string;
  allowSingle?: boolean;
  allowParlay?: boolean;
  showOnPlayer?: boolean;
  sortOrder?: number;
  promoLabel?: string | null;
  promoLabelI18n?: LocalizedInput | null;
  nameI18n?: LocalizedInput | null;
  selections?: SelectionDraft[];
};

type TemplateDraft = {
  name?: string;
  nameI18n?: LocalizedInput | null;
  description?: string | null;
  isDefault?: boolean;
  status?: string;
  sortOrder?: number;
  items?: MarketDraft[];
};

@Injectable()
export class MarketsService {
  constructor(private prisma: PrismaService) {}

  listMarketDefinitions() {
    return Object.entries(FOOTBALL_MARKET_CATALOG).map(([marketType, entry]) => ({
      marketType,
      marketKey: entry.marketKey,
      period: entry.period,
      sortOrder: entry.sortOrder,
      defaultLineValue: entry.defaultLineValue,
      allowSingle: entry.allowSingle,
      allowParlay: entry.allowParlay,
      showOnPlayer: entry.showOnPlayer,
      settlementSupported: entry.settlementSupported,
      settlementKind: entry.settlementKind,
      renderType: entry.renderType,
      usesLineValue: marketUsesLineValue(marketType),
      nameI18n: entry.nameI18n,
      selectionTemplate: entry.selectionTemplate,
    }));
  }

  async listTemplates() {
    await this.ensureDefaultTemplate();
    const templates = await this.prisma.marketTemplate.findMany({
      where: { sportType: 'FOOTBALL' },
      include: { items: { include: { selections: true } } },
      orderBy: [{ isDefault: 'desc' }, { sortOrder: 'asc' }, { id: 'asc' }],
    });
    return templates.map((t) => this.mapTemplate(t));
  }

  async getTemplate(templateId: bigint) {
    await this.ensureDefaultTemplate();
    const template = await this.prisma.marketTemplate.findUnique({
      where: { id: templateId },
      include: {
        items: {
          include: { selections: { orderBy: { sortOrder: 'asc' } } },
          orderBy: { sortOrder: 'asc' },
        },
      },
    });
    if (!template) throw appNotFound('MARKET_TEMPLATE_NOT_FOUND');
    return this.mapTemplate(template);
  }

  async createTemplate(data: TemplateDraft) {
    const nameI18n = sanitizeLocalizedText(data.nameI18n);
    const name = data.name?.trim() || resolveMarketText(nameI18n, 'zh-CN', '足球盘口模板');
    const template = await this.prisma.marketTemplate.create({
      data: {
        sportType: 'FOOTBALL',
        name,
        nameI18n: this.jsonOrNull(nameI18n),
        description: data.description?.trim() || null,
        isDefault: data.isDefault ?? false,
        status: data.status ?? 'ACTIVE',
        sortOrder: data.sortOrder ?? 0,
      },
    });
    if (data.items?.length) {
      await this.replaceTemplateItems(template.id, data.items);
    }
    if (data.isDefault) await this.setDefaultTemplate(template.id);
    return this.getTemplate(template.id);
  }

  async updateTemplate(templateId: bigint, data: TemplateDraft) {
    const existing = await this.prisma.marketTemplate.findUnique({ where: { id: templateId } });
    if (!existing) throw appNotFound('MARKET_TEMPLATE_NOT_FOUND');
    const nameI18n =
      data.nameI18n !== undefined
        ? sanitizeLocalizedText(data.nameI18n)
        : this.asLocalizedText(existing.nameI18n);
    await this.prisma.marketTemplate.update({
      where: { id: templateId },
      data: {
        ...(data.name !== undefined
          ? { name: data.name.trim() || resolveMarketText(nameI18n, 'zh-CN', existing.name) }
          : {}),
        ...(data.nameI18n !== undefined ? { nameI18n: this.jsonOrNull(nameI18n) } : {}),
        ...(data.description !== undefined ? { description: data.description?.trim() || null } : {}),
        ...(data.status !== undefined ? { status: data.status } : {}),
        ...(data.sortOrder !== undefined ? { sortOrder: data.sortOrder } : {}),
        ...(data.isDefault !== undefined ? { isDefault: data.isDefault } : {}),
      },
    });
    if (data.items) await this.replaceTemplateItems(templateId, data.items);
    if (data.isDefault) await this.setDefaultTemplate(templateId);
    return this.getTemplate(templateId);
  }

  async duplicateTemplate(templateId: bigint) {
    const source = await this.prisma.marketTemplate.findUnique({
      where: { id: templateId },
      include: { items: { include: { selections: true }, orderBy: { sortOrder: 'asc' } } },
    });
    if (!source) throw appNotFound('MARKET_TEMPLATE_NOT_FOUND');
    const copy = await this.prisma.marketTemplate.create({
      data: {
        sportType: source.sportType,
        name: `${source.name} Copy`,
        nameI18n: source.nameI18n ?? undefined,
        description: source.description,
        isDefault: false,
        status: source.status,
        sortOrder: source.sortOrder + 1,
      },
    });
    await this.replaceTemplateItems(
      copy.id,
      source.items.map((item) => ({
        marketType: item.marketType,
        marketKey: item.marketKey,
        lineKey: item.lineKey,
        period: item.period,
        lineValue: item.lineValue == null ? null : Number(item.lineValue),
        paramsJson: (item.paramsJson as Record<string, unknown> | null) ?? null,
        status: item.status,
        allowSingle: item.allowSingle,
        allowParlay: item.allowParlay,
        showOnPlayer: item.showOnPlayer,
        sortOrder: item.sortOrder,
        promoLabel: item.promoLabel,
        promoLabelI18n: this.asLocalizedText(item.promoLabelI18n),
        nameI18n: this.asLocalizedText(item.nameI18n),
        selections: item.selections.map((s) => ({
          selectionCode: s.selectionCode,
          selectionName: s.selectionName,
          nameI18n: this.asLocalizedText(s.nameI18n),
          odds: Number(s.odds),
          status: s.status,
          sortOrder: s.sortOrder,
        })),
      })),
    );
    return this.getTemplate(copy.id);
  }

  async setDefaultTemplate(templateId: bigint) {
    const existing = await this.prisma.marketTemplate.findUnique({ where: { id: templateId } });
    if (!existing) throw appNotFound('MARKET_TEMPLATE_NOT_FOUND');
    await this.prisma.$transaction([
      this.prisma.marketTemplate.updateMany({
        where: { sportType: 'FOOTBALL', id: { not: templateId } },
        data: { isDefault: false },
      }),
      this.prisma.marketTemplate.update({
        where: { id: templateId },
        data: { isDefault: true, status: 'ACTIVE' },
      }),
    ]);
    return this.getTemplate(templateId);
  }

  async applyTemplateToMatch(matchId: bigint, templateId?: bigint | null) {
    const match = await this.prisma.match.findUnique({ where: { id: matchId } });
    if (!match) throw appNotFound('MATCH_NOT_FOUND');
    const template = templateId
      ? await this.prisma.marketTemplate.findUnique({
          where: { id: templateId },
          include: { items: { include: { selections: true }, orderBy: { sortOrder: 'asc' } } },
        })
      : await this.getDefaultTemplateRecord();
    if (!template) throw appNotFound('MARKET_TEMPLATE_NOT_FOUND');

    const results = [];
    for (const item of template.items) {
      if (item.status === 'DELETED') continue;
      const draft: MarketDraft = {
        marketType: item.marketType,
        marketKey: item.marketKey,
        lineKey: item.lineKey,
        period: item.period,
        lineValue: item.lineValue == null ? null : Number(item.lineValue),
        paramsJson: (item.paramsJson as Record<string, unknown> | null) ?? null,
        status: item.status,
        allowSingle: item.allowSingle,
        allowParlay: item.allowParlay,
        showOnPlayer: item.showOnPlayer,
        sortOrder: item.sortOrder,
        promoLabel: item.promoLabel,
        promoLabelI18n: this.asLocalizedText(item.promoLabelI18n),
        nameI18n: this.asLocalizedText(item.nameI18n),
        selections: item.selections.map((s) => ({
          selectionCode: s.selectionCode,
          selectionName: s.selectionName,
          nameI18n: this.asLocalizedText(s.nameI18n),
          odds: Number(s.odds),
          status: s.status,
          sortOrder: s.sortOrder,
        })),
      };
      results.push(await this.upsertMatchMarket(matchId, draft, undefined, item.id));
    }
    return { templateId: template.id.toString(), applied: results.length };
  }

  async saveMatchMarkets(matchId: bigint, items: MarketDraft[], operatorId?: bigint) {
    const match = await this.prisma.match.findUnique({ where: { id: matchId } });
    if (!match) throw appNotFound('MATCH_NOT_FOUND');
    const saved = [];
    const activeIds = new Set<string>();
    for (const item of items) {
      const market = await this.upsertMatchMarket(matchId, item, operatorId);
      if (!market) throw appNotFound('MARKET_NOT_FOUND');
      activeIds.add(market.id.toString());
      saved.push(market);
    }

    const existing = await this.prisma.market.findMany({ where: { matchId }, select: { id: true } });
    const missing = existing.filter((m) => !activeIds.has(m.id.toString())).map((m) => m.id);
    if (missing.length) {
      await this.prisma.market.updateMany({
        where: { id: { in: missing } },
        data: { status: 'CLOSED', showOnPlayer: false },
      });
    }
    return { updated: saved.length, closed: missing.length };
  }

  async generateTemplates(matchId: bigint, marketTypes: string[]) {
    const items = marketTypes.map((marketType, i) => {
      const config = this.getMarketConfig(marketType);
      return this.defaultDraftFromConfig(marketType, config.defaultLineValue, i);
    });
    const result = await this.saveMatchMarkets(matchId, items);
    return { created: result.updated };
  }

  async updateOdds(selectionId: bigint, newOdds: number, operatorId: bigint) {
    const selection = await this.prisma.marketSelection.findUnique({
      where: { id: selectionId },
    });
    if (!selection) throw appNotFound('SELECTION_NOT_FOUND');
    if (newOdds <= 1) throw appBadRequest('ODDS_MIN');

    const newVersion = selection.oddsVersion + BigInt(1);

    return this.prisma.$transaction(async (tx) => {
      await tx.oddsChangeLog.create({
        data: {
          selectionId,
          oldOdds: selection.odds,
          newOdds,
          oddsVersion: newVersion,
          changedBy: operatorId,
        },
      });

      return tx.marketSelection.update({
        where: { id: selectionId },
        data: { odds: newOdds, oddsVersion: newVersion },
      });
    });
  }

  async batchUpdateOdds(
    updates: Array<{ selectionId: bigint; odds: number }>,
    operatorId: bigint,
  ) {
    const results = [];
    for (const u of updates) {
      results.push(await this.updateOdds(u.selectionId, u.odds, operatorId));
    }
    return results;
  }

  async updateMarket(
    marketId: bigint,
    data: {
      promoLabel?: string | null;
      promoLabelI18n?: LocalizedInput | null;
      nameI18n?: LocalizedInput | null;
      status?: string;
      lineValue?: number | null;
      showOnPlayer?: boolean;
    },
  ) {
    const market = await this.prisma.market.findUnique({ where: { id: marketId } });
    if (!market) throw appNotFound('MARKET_NOT_FOUND');
    const lineValue = data.lineValue !== undefined ? data.lineValue : market.lineValue == null ? null : Number(market.lineValue);
    if (data.lineValue !== undefined) {
      this.assertLineValueAllowed(market.marketType, lineValue);
    }
    const lineKey =
      data.lineValue !== undefined
        ? buildMarketLineKey(market.marketType, lineValue, (market.paramsJson as Record<string, unknown> | null) ?? null)
        : market.lineKey;

    return this.prisma.market.update({
      where: { id: marketId },
      data: {
        ...(data.promoLabel !== undefined ? { promoLabel: data.promoLabel?.trim() || null } : {}),
        ...(data.promoLabelI18n !== undefined ? { promoLabelI18n: this.jsonOrNull(sanitizeLocalizedText(data.promoLabelI18n)) } : {}),
        ...(data.nameI18n !== undefined ? { nameI18n: this.jsonOrNull(sanitizeLocalizedText(data.nameI18n)) } : {}),
        ...(data.status !== undefined ? { status: data.status } : {}),
        ...(data.lineValue !== undefined ? { lineValue, lineKey } : {}),
        ...(data.showOnPlayer !== undefined ? { showOnPlayer: data.showOnPlayer } : {}),
      },
    });
  }

  async updateSelection(
    selectionId: bigint,
    data: { selectionName?: string; nameI18n?: LocalizedInput | null; odds?: number; status?: string },
    operatorId?: bigint,
  ) {
    const selection = await this.prisma.marketSelection.findUnique({
      where: { id: selectionId },
    });
    if (!selection) throw appNotFound('SELECTION_NOT_FOUND');

    if (data.odds != null) {
      if (!operatorId) throw appBadRequest('OPERATOR_REQUIRED');
      await this.updateOdds(selectionId, data.odds, operatorId);
    }

    return this.prisma.marketSelection.update({
      where: { id: selectionId },
      data: {
        ...(data.selectionName !== undefined ? { selectionName: data.selectionName.trim() } : {}),
        ...(data.nameI18n !== undefined ? { nameI18n: this.jsonOrNull(sanitizeLocalizedText(data.nameI18n)) } : {}),
        ...(data.status !== undefined ? { status: data.status } : {}),
      },
    });
  }

  private async ensureDefaultTemplate() {
    const existing = await this.prisma.marketTemplate.findFirst({
      where: { sportType: 'FOOTBALL', isDefault: true, status: 'ACTIVE' },
    });
    if (existing) return existing;
    const template = await this.prisma.marketTemplate.create({
      data: {
        sportType: 'FOOTBALL',
        name: '默认足球盘口模板',
        nameI18n: this.jsonOrNull({
          'zh-CN': '默认足球盘口模板',
          'en-US': 'Default Football Market Template',
          'ms-MY': 'Templat Pasaran Bola Sepak Lalai',
        }),
        isDefault: true,
        status: 'ACTIVE',
        sortOrder: 0,
      },
    });
    await this.replaceTemplateItems(
      template.id,
      DEFAULT_MARKET_TYPES.map((marketType, i) => {
        const config = this.getMarketConfig(marketType);
        return this.defaultDraftFromConfig(marketType, config.defaultLineValue, i);
      }),
    );
    return template;
  }

  private async getDefaultTemplateRecord() {
    await this.ensureDefaultTemplate();
    return this.prisma.marketTemplate.findFirst({
      where: { sportType: 'FOOTBALL', isDefault: true, status: 'ACTIVE' },
      include: { items: { include: { selections: true }, orderBy: { sortOrder: 'asc' } } },
    });
  }

  private async replaceTemplateItems(templateId: bigint, items: MarketDraft[]) {
    await this.prisma.marketTemplateItem.deleteMany({ where: { templateId } });
    for (const [index, item] of items.entries()) {
      const normalized = this.normalizeDraft(item, index);
      await this.prisma.marketTemplateItem.create({
        data: {
          templateId,
          marketType: normalized.marketType,
          marketKey: normalized.marketKey,
          lineKey: normalized.lineKey,
          period: normalized.period,
          lineValue: normalized.lineValue,
          paramsJson: this.jsonValueOrNull(normalized.paramsJson),
          status: normalized.status,
          allowSingle: normalized.allowSingle,
          allowParlay: normalized.allowParlay,
          showOnPlayer: normalized.showOnPlayer,
          sortOrder: normalized.sortOrder,
          promoLabel: normalized.promoLabel,
          promoLabelI18n: this.jsonOrNull(normalized.promoLabelI18n),
          nameI18n: this.jsonOrNull(normalized.nameI18n),
          selections: {
            create: normalized.selections.map((s) => ({
              selectionCode: s.selectionCode,
              selectionName: s.selectionName,
              nameI18n: this.jsonOrNull(s.nameI18n),
              odds: s.odds,
              status: s.status,
              sortOrder: s.sortOrder,
            })),
          },
        },
      });
    }
  }

  private async upsertMatchMarket(
    matchId: bigint,
    draft: MarketDraft,
    operatorId?: bigint,
    templateItemId?: bigint,
  ) {
    const normalized = this.normalizeDraft(draft, draft.sortOrder ?? 0);
    const existing = draft.id
      ? await this.prisma.market.findFirst({ where: { id: BigInt(draft.id), matchId }, include: { selections: true } })
      : await this.prisma.market.findFirst({ where: { matchId, lineKey: normalized.lineKey }, include: { selections: true } });

    const showOnPlayer = normalized.showOnPlayer && isSettlementSupportedMarketType(normalized.marketType);
    const data = {
      marketType: normalized.marketType,
      marketKey: normalized.marketKey,
      lineKey: normalized.lineKey,
      period: normalized.period,
      lineValue: normalized.lineValue,
      paramsJson: this.jsonValueOrNull(normalized.paramsJson),
      status: normalized.status,
      allowSingle: normalized.allowSingle,
      allowParlay: normalized.allowParlay,
      showOnPlayer,
      sortOrder: normalized.sortOrder,
      promoLabel: normalized.promoLabel,
      promoLabelI18n: this.jsonOrNull(normalized.promoLabelI18n),
      nameI18n: this.jsonOrNull(normalized.nameI18n),
      templateItemId: templateItemId ?? undefined,
    };

    const market = existing
      ? await this.prisma.market.update({ where: { id: existing.id }, data })
      : await this.prisma.market.create({ data: { matchId, ...data } });

    await this.syncMarketSelections(market.id, normalized.selections, operatorId);
    return this.prisma.market.findUnique({
      where: { id: market.id },
      include: { selections: { orderBy: { sortOrder: 'asc' } } },
    });
  }

  private async syncMarketSelections(
    marketId: bigint,
    selections: Array<SelectionDraft & { selectionName: string; nameI18n: LocalizedText; status: string; sortOrder: number }>,
    operatorId?: bigint,
  ) {
    const existing = await this.prisma.marketSelection.findMany({ where: { marketId } });
    const seen = new Set<string>();
    for (const sel of selections) {
      if (!sel.odds || sel.odds <= 1) throw appBadRequest('ODDS_MIN');
      const selectionId = sel.id;
      const current = selectionId
        ? existing.find((s) => s.id === BigInt(selectionId))
        : existing.find((s) => s.selectionCode === sel.selectionCode);
      if (current) {
        seen.add(current.id.toString());
        const oddsChanged = Number(current.odds) !== Number(sel.odds);
        const newVersion = oddsChanged ? current.oddsVersion + BigInt(1) : current.oddsVersion;
        if (oddsChanged) {
          await this.prisma.oddsChangeLog.create({
            data: {
              selectionId: current.id,
              oldOdds: current.odds,
              newOdds: sel.odds,
              oddsVersion: newVersion,
              changedBy: operatorId,
            },
          });
        }
        await this.prisma.marketSelection.update({
          where: { id: current.id },
          data: {
            selectionCode: sel.selectionCode,
            selectionName: sel.selectionName,
            nameI18n: this.jsonOrNull(sel.nameI18n),
            odds: sel.odds,
            oddsVersion: newVersion,
            status: sel.status,
            sortOrder: sel.sortOrder,
          },
        });
      } else {
        const created = await this.prisma.marketSelection.create({
          data: {
            marketId,
            selectionCode: sel.selectionCode,
            selectionName: sel.selectionName,
            nameI18n: this.jsonOrNull(sel.nameI18n),
            odds: sel.odds,
            status: sel.status,
            sortOrder: sel.sortOrder,
          },
        });
        seen.add(created.id.toString());
      }
    }
    const toClose = existing.filter((s) => !seen.has(s.id.toString())).map((s) => s.id);
    if (toClose.length) {
      await this.prisma.marketSelection.updateMany({
        where: { id: { in: toClose } },
        data: { status: 'CLOSED' },
      });
    }
  }

  private normalizeDraft(item: MarketDraft, fallbackOrder: number) {
    const config = this.getMarketConfig(item.marketType);
    const lineValue = item.lineValue !== undefined ? item.lineValue : config.defaultLineValue;
    this.assertLineValueAllowed(item.marketType, lineValue);
    const paramsJson = item.paramsJson ?? config.defaultParams ?? null;
    const lineKey = item.lineKey?.trim() || buildMarketLineKey(item.marketType, lineValue, paramsJson);
    const nameI18n = {
      ...config.nameI18n,
      ...sanitizeLocalizedText(item.nameI18n),
    };
    const promoLabelI18n = sanitizeLocalizedText(item.promoLabelI18n);
    const sourceSelections = item.selections?.length
      ? item.selections
      : config.selectionTemplate.map((s, index) => ({
          selectionCode: s.code,
          selectionName: s.name,
          nameI18n: s.nameI18n,
          odds: s.odds,
          status: 'OPEN',
          sortOrder: index,
        }));
    return {
      marketType: item.marketType,
      marketKey: item.marketKey?.trim() || config.marketKey,
      lineKey,
      period: item.period || config.period,
      lineValue,
      paramsJson,
      status: item.status || 'OPEN',
      allowSingle: item.allowSingle ?? config.allowSingle,
      allowParlay: item.allowParlay ?? config.allowParlay,
      showOnPlayer: item.showOnPlayer ?? config.showOnPlayer,
      sortOrder: item.sortOrder ?? config.sortOrder ?? fallbackOrder,
      promoLabel: item.promoLabel?.trim() || null,
      promoLabelI18n,
      nameI18n,
      selections: sourceSelections.map((s, index) => {
        const defaultName = defaultSelectionName(item.marketType, s.selectionCode, 'zh-CN');
        const rawNameI18n = sanitizeLocalizedText(s.nameI18n);
        const nameI18n = Object.keys(rawNameI18n).length
          ? rawNameI18n
          : { 'zh-CN': s.selectionName?.trim() || defaultName };
        return {
          id: 'id' in s ? s.id : undefined,
          selectionCode: s.selectionCode,
          selectionName: s.selectionName?.trim() || resolveMarketText(nameI18n, 'zh-CN', defaultName),
          nameI18n,
          odds: Number(s.odds),
          status: s.status || 'OPEN',
          sortOrder: s.sortOrder ?? index,
        };
      }),
    };
  }

  private defaultDraftFromConfig(marketType: string, lineValue: number | null, index: number): MarketDraft {
    const config = this.getMarketConfig(marketType);
    this.assertLineValueAllowed(marketType, lineValue);
    return {
      marketType,
      marketKey: config.marketKey,
      lineKey: buildMarketLineKey(marketType, lineValue, null),
      period: config.period,
      lineValue,
      status: 'OPEN',
      allowSingle: config.allowSingle,
      allowParlay: config.allowParlay,
      showOnPlayer: config.showOnPlayer,
      sortOrder: config.sortOrder ?? index,
      nameI18n: config.nameI18n,
      selections: config.selectionTemplate.map((s, i) => ({
        selectionCode: s.code,
        selectionName: s.name,
        nameI18n: s.nameI18n,
        odds: s.odds,
        status: 'OPEN',
        sortOrder: i,
      })),
    };
  }

  private mapTemplate(template: Prisma.MarketTemplateGetPayload<{ include: { items: { include: { selections: true } } } }>) {
    return {
      id: template.id.toString(),
      sportType: template.sportType,
      name: template.name,
      nameI18n: this.asLocalizedText(template.nameI18n),
      description: template.description,
      isDefault: template.isDefault,
      status: template.status,
      sortOrder: template.sortOrder,
      items: template.items
        .slice()
        .sort((a, b) => a.sortOrder - b.sortOrder)
        .map((item) => ({
          id: item.id.toString(),
          marketType: item.marketType,
          marketKey: item.marketKey,
          lineKey: item.lineKey,
          period: item.period,
          lineValue: item.lineValue == null ? null : Number(item.lineValue),
          paramsJson: item.paramsJson,
          status: item.status,
          allowSingle: item.allowSingle,
          allowParlay: item.allowParlay,
          showOnPlayer: item.showOnPlayer,
          sortOrder: item.sortOrder,
          promoLabel: item.promoLabel ?? '',
          promoLabelI18n: this.asLocalizedText(item.promoLabelI18n),
          nameI18n: this.asLocalizedText(item.nameI18n),
          selections: item.selections
            .slice()
            .sort((a, b) => a.sortOrder - b.sortOrder)
            .map((s) => ({
              id: s.id.toString(),
              selectionCode: s.selectionCode,
              selectionName: s.selectionName,
              nameI18n: this.asLocalizedText(s.nameI18n),
              odds: Number(s.odds),
              status: s.status,
              sortOrder: s.sortOrder,
            })),
        })),
    };
  }

  private getMarketConfig(marketType: string) {
    try {
      return buildMarketTemplate(marketType);
    } catch {
      throw appBadRequest('UNKNOWN_MARKET_TYPE', { marketType });
    }
  }

  private assertLineValueAllowed(marketType: string, lineValue: number | null | undefined) {
    if (marketUsesLineValue(marketType)) {
      if (typeof lineValue !== 'number' || !Number.isFinite(lineValue)) {
        throw appBadRequest('MARKET_LINE_REQUIRED');
      }
      return;
    }

    if (lineValue != null) {
      throw appBadRequest('MARKET_LINE_NOT_ALLOWED');
    }
  }

  private jsonOrNull(value: LocalizedText | Record<string, unknown> | null | undefined) {
    if (!value || !Object.keys(value).length) return Prisma.JsonNull;
    return value as Prisma.InputJsonValue;
  }

  private jsonValueOrNull(value: Record<string, unknown> | null | undefined) {
    if (value == null) return Prisma.JsonNull;
    return value as Prisma.InputJsonValue;
  }

  private asLocalizedText(value: unknown): LocalizedText {
    return sanitizeLocalizedText(value);
  }
}
