import type { Knex } from 'knex';
import { z } from 'zod';
import { db } from '../db/client.js';
import { sendNotifications } from './notification-service.js';
import { getSharedResourceTokenDetail } from './shared-resource-token-service.js';

const TICKET_BAY_ORIGIN = 'https://www.ticketbay.co.kr';
const SPORTS_SEARCH_KEY = '5';
const MAX_CATEGORY_COUNT = 16;
const MAX_RESULT_COUNT = 12;
const TICKET_BAY_FETCH_TIMEOUT_MS = 12_000;
const TICKET_BAY_PRODUCT_PAGE_SIZE = 100;
const BASEBALL_TICKET_BAY_BATCH_ADVISORY_LOCK_KEY = 741_205_261;

const teamKeywordMap: Record<string, string> = {
  LG: 'LG',
  두산: '두산',
  SSG: 'SSG',
  키움: '키움',
  KT: 'KT',
  KIA: 'KIA',
  NC: 'NC',
  롯데: '롯데',
  삼성: '삼성',
  한화: '한화',
  전체: '야구',
};

const titleSearchKeywordEntries = [
  '잠실',
  '고척',
  '광주',
  '대구',
  '대전',
  '사직',
  '수원',
  '창원',
  '인천',
  '문학',
  'LG',
  '두산',
  'SSG',
  '키움',
  'KT',
  'KIA',
  'NC',
  '롯데',
  '삼성',
  '한화',
] as const;

const ticketAlertSearchSchema = z.object({
  title: z.string().trim().min(1).max(200),
  eventDate: z.string().trim().regex(/^\d{4}-\d{2}-\d{2}$/),
  team: z.string().trim().min(1).max(50),
  zone: z.string().trim().min(1).max(100),
  aisleSide: z.string().trim().min(1).max(100),
  seatDirections: z.array(z.string().trim().min(1).max(50)).max(10).default([]),
  maxPrice: z.number().finite().positive().nullable(),
  seatCount: z.number().int().positive().max(10),
});

type TicketAlertSearchInput = z.infer<typeof ticketAlertSearchSchema>;

type AlertMatchFailureReason =
  | 'status'
  | 'eventDate'
  | 'team'
  | 'maxPrice'
  | 'seatCount'
  | 'zone'
  | 'aisleSide'
  | 'seatDirections';

type AlertMatchEvaluation =
  | { matched: true }
  | {
      matched: false;
      failedReason: AlertMatchFailureReason;
    };

type SearchCategoryItem = {
  depth1Id?: number;
  depth2Id?: number;
  depth3Id?: number;
  depth1Name?: string;
  depth2Name?: string;
  depth3Name?: string;
  startDate?: string | null;
  endDate?: string | null;
  performCity?: string | null;
  performLocation?: string | null;
};

type TicketBayProductListItem = {
  id: number;
  category_id: number;
  display_number: string;
  name: string;
  start_perform_date: string | null;
  end_perform_date: string | null;
  depth1_name?: string | null;
  depth2_name?: string | null;
  depth3_name?: string | null;
  info_type?: string | null;
  seat_number_type?: string | null;
  seat_number?: string | null;
  area?: string | null;
  floor?: string | null;
  grade?: string | null;
  addinfo?: string | null;
  sale_quantity?: number | null;
  is_together?: string | null;
  price?: number | null;
  total_price?: number | null;
  product_status?: string | null;
  is_use_safe?: string | null;
  is_use_pin?: string | null;
  is_use_delivery?: string | null;
  is_use_field?: string | null;
  is_use_etc?: string | null;
  description?: string | null;
  created_at?: string | null;
  transaction_type?: string | null;
};

type TicketBayProductDetailInfo = TicketBayProductListItem & {
  product_remark?: Array<{ id?: number; content?: string | null }>;
  seat_remark?: Array<{ id?: number; content?: string | null }>;
  photo_res?: Array<{ photo_url?: string | null; origin_name?: string | null }>;
  transaction_type?: string | null;
};

type TicketBayCategoryInfo = {
  seat_image?: string | null;
};

const CATEGORY_VARIANT_SUFFIX_PATTERN = /\s*\((?:주중|금토일\/공휴일)\)\s*$/u;

function normalizeText(value: unknown) {
  return String(value ?? '').trim();
}

function normalizeDateTime(value: unknown) {
  if (value instanceof Date) {
    return value.toISOString();
  }

  const normalized = normalizeText(value);

  if (!normalized) {
    return '';
  }

  const parsed = Date.parse(normalized);
  return Number.isNaN(parsed) ? normalized : new Date(parsed).toISOString();
}

function normalizeTimestampForDb(value: unknown) {
  if (value instanceof Date) {
    return value.toISOString();
  }

  const normalized = normalizeText(value);

  if (!normalized) {
    return null;
  }

  const candidates = [
    normalized,
    normalized.replace(/\s*\([^)]*\)\s*$/u, ''),
    normalized.replace(/\s*\([^)]*\)\s*$/u, '').replace(/\s+GMT([+-]\d{4})$/iu, ' $1'),
  ];

  for (const candidate of candidates) {
    const parsed = Date.parse(candidate);

    if (!Number.isNaN(parsed)) {
      return new Date(parsed).toISOString();
    }

    const offsetMatch = candidate.match(
      /^(\d{4}-\d{2}-\d{2})[ T](\d{2}:\d{2}:\d{2}(?:\.\d{1,3})?)\s*([+-]\d{2})(\d{2})$/u,
    );

    if (offsetMatch) {
      const [, datePart, timePart, hourOffset, minuteOffset] = offsetMatch;
      const reparsed = Date.parse(`${datePart}T${timePart}${hourOffset}:${minuteOffset}`);

      if (!Number.isNaN(reparsed)) {
        return new Date(reparsed).toISOString();
      }
    }
  }

  return null;
}

function normalizeDateOnly(value: unknown) {
  const normalized = normalizeText(value);

  if (!normalized) {
    return '';
  }

  return normalized.slice(0, 10);
}

function buildCategorySearchKeywords(input: TicketAlertSearchInput) {
  const keywords: string[] = [];
  const primaryKeyword = teamKeywordMap[input.team] ?? normalizeText(input.team);

  if (primaryKeyword) {
    keywords.push(primaryKeyword);
  }

  const title = normalizeText(input.title);

  if (title) {
    titleSearchKeywordEntries.forEach((keyword) => {
      if (title.includes(keyword)) {
        keywords.push(keyword);
      }
    });
  }

  return [...new Set(keywords.filter(Boolean))];
}

function resolveCategoryFamilyKey(item: SearchCategoryItem) {
  const label = normalizeText(item.depth3Name || item.depth2Name || String(item.depth3Id));
  return label.replace(CATEGORY_VARIANT_SUFFIX_PATTERN, '').trim();
}

function expandCategoryVariants(categories: SearchCategoryItem[]) {
  const familyMap = new Map<string, SearchCategoryItem[]>();

  categories.forEach((item) => {
    const familyKey = resolveCategoryFamilyKey(item);
    const existing = familyMap.get(familyKey);

    if (existing) {
      existing.push(item);
      return;
    }

    familyMap.set(familyKey, [item]);
  });

  const expanded: SearchCategoryItem[] = [];
  const seenCategoryIds = new Set<number>();

  categories.forEach((item) => {
    const familyItems = familyMap.get(resolveCategoryFamilyKey(item)) ?? [item];

    familyItems.forEach((familyItem) => {
      if (typeof familyItem.depth3Id !== 'number' || seenCategoryIds.has(familyItem.depth3Id)) {
        return;
      }

      seenCategoryIds.add(familyItem.depth3Id);
      expanded.push(familyItem);
    });
  });

  return expanded;
}

async function fetchText(url: string) {
  const controller = new AbortController();
  const timeout = setTimeout(() => controller.abort(), TICKET_BAY_FETCH_TIMEOUT_MS);

  let response: Response;

  try {
    response = await fetch(url, {
      signal: controller.signal,
      headers: {
        'user-agent':
          'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/136.0.0.0 Safari/537.36',
        accept: 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8',
      },
    });
  } catch (error) {
    if (error instanceof DOMException && error.name === 'AbortError') {
      throw new Error(`티켓베이 요청 시간 초과(${TICKET_BAY_FETCH_TIMEOUT_MS}ms): ${url}`);
    }

    throw error;
  } finally {
    clearTimeout(timeout);
  }

  if (!response.ok) {
    throw new Error(`티켓베이 요청 실패: ${response.status} ${url}`);
  }

  return response.text();
}

async function fetchJson<T>(url: string, init?: RequestInit) {
  const controller = new AbortController();
  const timeout = setTimeout(() => controller.abort(), TICKET_BAY_FETCH_TIMEOUT_MS);

  let response: Response;

  try {
    response = await fetch(url, {
      ...init,
      signal: controller.signal,
      headers: {
        'user-agent':
          'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/136.0.0.0 Safari/537.36',
        accept: 'application/json, text/plain, */*',
        'content-type': 'application/json',
        ...(init?.headers ?? {}),
      },
    });
  } catch (error) {
    if (error instanceof DOMException && error.name === 'AbortError') {
      throw new Error(`티켓베이 요청 시간 초과(${TICKET_BAY_FETCH_TIMEOUT_MS}ms): ${url}`);
    }

    throw error;
  } finally {
    clearTimeout(timeout);
  }

  if (!response.ok) {
    throw new Error(`티켓베이 요청 실패: ${response.status} ${url}`);
  }

  return (await response.json()) as T;
}

function parseNextDataJson(html: string) {
  const match = html.match(/<script id="__NEXT_DATA__" type="application\/json">([\s\S]*?)<\/script>/);

  if (!match?.[1]) {
    throw new Error('티켓베이 응답에서 NEXT_DATA를 찾지 못했습니다.');
  }

  return JSON.parse(match[1]) as {
    props?: {
      pageProps?: Record<string, unknown>;
    };
  };
}

async function fetchSearchCategories(keyword: string) {
  const html = await fetchText(`${TICKET_BAY_ORIGIN}/search?keyword=${encodeURIComponent(keyword)}`);
  const pageProps = parseNextDataJson(html).props?.pageProps ?? {};
  const searchObj = (pageProps.searchObj ?? {}) as Record<string, SearchCategoryItem[] | undefined>;
  return Array.isArray(searchObj[SPORTS_SEARCH_KEY]) ? searchObj[SPORTS_SEARCH_KEY]! : [];
}

async function fetchProductListPage(categoryId: number, page: number, input: TicketAlertSearchInput) {
  const requestBody: Record<string, unknown> = {
    category_id: categoryId,
    depth1_id: 5,
    size: TICKET_BAY_PRODUCT_PAGE_SIZE,
    offset: page,
    // TicketBay public products API narrows results incorrectly when time is appended.
    start_perform_date: input.eventDate,
  };

  if (input.team !== '전체') {
    requestBody.seat_floor = `vs ${input.team}`;
  }

  if (input.seatCount > 0) {
    requestBody.sale_quantity = Math.min(input.seatCount, 5);
  }

  if (input.seatCount > 1) {
    requestBody.is_together = 'YES';
  }

  const response = await fetchJson<{
    data?: {
      content?: TicketBayProductListItem[];
      totalPages?: number;
      totalElements?: number;
    };
  }>(`${TICKET_BAY_ORIGIN}/ticketbayApi/product/v1/public/products`, {
    method: 'POST',
    body: JSON.stringify(requestBody),
  });
  const listServer = response.data ?? {};

  return {
    items: Array.isArray(listServer.content) ? listServer.content : [],
    totalPages: typeof listServer.totalPages === 'number' ? listServer.totalPages : 0,
    totalElements: typeof listServer.totalElements === 'number' ? listServer.totalElements : 0,
  };
}

async function fetchProductDetail(displayNumber: string) {
  const html = await fetchText(`${TICKET_BAY_ORIGIN}/product/view/${encodeURIComponent(displayNumber)}`);
  const pageProps = parseNextDataJson(html).props?.pageProps ?? {};
  return {
    info: ((pageProps.info ?? null) as TicketBayProductDetailInfo | null) ?? null,
    categoryInfo: ((pageProps.categoryInfo ?? null) as TicketBayCategoryInfo | null) ?? null,
  };
}

function matchesZone(zone: string, item: TicketBayProductListItem) {
  if (zone === '전체') {
    return true;
  }

  const haystack = [item.grade, item.name, item.description, item.addinfo, item.area].map(normalizeText).join(' ');
  const zoneKeywords: Record<string, string[]> = {
    내야: ['내야', '중앙', '1루', '3루', '레드석', '블루석', '네이비석'],
    외야: ['외야'],
    테이블석: ['테이블'],
    중앙석: ['중앙'],
    응원석: ['응원'],
    프리미엄석: ['프리미엄', 'vip', '익사이팅'],
    휠체어석: ['휠체어'],
  };

  const keywords = zoneKeywords[zone] ?? [zone];
  return keywords.some((keyword) => haystack.includes(keyword));
}

function matchesAisleSide(aisleSide: string, item: TicketBayProductListItem) {
  if (aisleSide === '전체') {
    return true;
  }

  const haystack = [item.name, item.description, item.addinfo].map((value) => normalizeText(value).toLowerCase()).join(' ');

  if (aisleSide === '통로측') {
    return haystack.includes('통로');
  }

  if (aisleSide === '중앙측') {
    return haystack.includes('중앙');
  }

  if (aisleSide === '내측') {
    return haystack.includes('내측');
  }

  if (aisleSide === '외측') {
    return haystack.includes('외측');
  }

  return haystack.includes(aisleSide.toLowerCase());
}

function matchesDirections(seatDirections: string[], item: TicketBayProductListItem) {
  if (!seatDirections.length) {
    return true;
  }

  const haystack = [item.grade, item.floor, item.name, item.description].map(normalizeText).join(' ');
  return seatDirections.some((direction) => haystack.includes(direction));
}

function matchesSeatCount(requiredSeatCount: number, item: TicketBayProductListItem) {
  const saleQuantity = typeof item.sale_quantity === 'number' ? item.sale_quantity : 0;

  if (requiredSeatCount <= 1) {
    return saleQuantity >= 1;
  }

  return saleQuantity >= requiredSeatCount && normalizeText(item.is_together) === 'YES';
}

function evaluateAlertMatch(input: TicketAlertSearchInput, item: TicketBayProductListItem): AlertMatchEvaluation {
  if (normalizeText(item.product_status) !== 'SALE') {
    return { matched: false, failedReason: 'status' };
  }

  if (normalizeDateOnly(item.start_perform_date) !== input.eventDate) {
    return { matched: false, failedReason: 'eventDate' };
  }

  if (input.team !== '전체' && normalizeText(item.floor) !== `vs ${input.team}`) {
    return { matched: false, failedReason: 'team' };
  }

  if (input.maxPrice !== null && typeof item.price === 'number' && item.price > input.maxPrice) {
    return { matched: false, failedReason: 'maxPrice' };
  }

  if (!matchesSeatCount(input.seatCount, item)) {
    return { matched: false, failedReason: 'seatCount' };
  }

  if (!matchesZone(input.zone, item)) {
    return { matched: false, failedReason: 'zone' };
  }

  if (!matchesAisleSide(input.aisleSide, item)) {
    return { matched: false, failedReason: 'aisleSide' };
  }

  if (!matchesDirections(input.seatDirections, item)) {
    return { matched: false, failedReason: 'seatDirections' };
  }

  return { matched: true };
}

function resolveFailureReasonLabel(reason: AlertMatchFailureReason) {
  switch (reason) {
    case 'status':
      return '판매중 상태 아님';
    case 'eventDate':
      return '경기 날짜 불일치';
    case 'team':
      return '상대팀 불일치';
    case 'maxPrice':
      return '가격 초과';
    case 'seatCount':
      return '연석/수량 불일치';
    case 'zone':
      return '구역 조건 불일치';
    case 'aisleSide':
      return '통로측 조건 불일치';
    case 'seatDirections':
      return '방향 조건 불일치';
    default:
      return '조건 불일치';
  }
}

function buildAbsoluteTicketBayUrl(path: string | null | undefined) {
  const normalized = normalizeText(path);

  if (!normalized) {
    return null;
  }

  if (normalized.startsWith('http://') || normalized.startsWith('https://')) {
    return normalized;
  }

  return `${TICKET_BAY_ORIGIN}/${normalized.replace(/^\/+/, '')}`;
}

export const baseballTicketBaySearchBodySchema = ticketAlertSearchSchema;

export async function searchBaseballTicketBayListings(payload: unknown) {
  const input = baseballTicketBaySearchBodySchema.parse(payload);
  const searchKeywords = buildCategorySearchKeywords(input);
  const keyword = searchKeywords[0] ?? (teamKeywordMap[input.team] ?? input.team);
  const categoryMap = new Map<number, SearchCategoryItem>();

  for (const searchKeyword of searchKeywords) {
    const categories = await fetchSearchCategories(searchKeyword);

    categories.forEach((item) => {
      if (item.depth1Id !== 5 || typeof item.depth3Id !== 'number') {
        return;
      }

      if (!categoryMap.has(item.depth3Id)) {
        categoryMap.set(item.depth3Id, item);
      }
    });
  }

  const filteredCategories = expandCategoryVariants([...categoryMap.values()]).slice(0, MAX_CATEGORY_COUNT);

  const scannedCategories: Array<{ categoryId: number; categoryName: string; pageCount: number; scannedItemCount: number }> = [];
  const candidates: TicketBayProductListItem[] = [];
  const rejectionSummaryMap = new Map<AlertMatchFailureReason, { count: number }>();
  let scannedItemTotalCount = 0;

  for (const category of filteredCategories) {
    let pageCount = 0;
    let scannedItemCount = 0;

    for (let page = 0; ; page += 1) {
      const listPage = await fetchProductListPage(category.depth3Id!, page, input);
      pageCount += 1;
      scannedItemCount += listPage.items.length;
      scannedItemTotalCount += listPage.items.length;

      listPage.items.forEach((item) => {
        const evaluation = evaluateAlertMatch(input, item);

        if (evaluation.matched) {
          candidates.push(item);
          return;
        }

        const current = rejectionSummaryMap.get(evaluation.failedReason) ?? { count: 0 };
        current.count += 1;

        rejectionSummaryMap.set(evaluation.failedReason, current);
      });

      const totalPages = listPage.totalPages > 0 ? listPage.totalPages : 1;

      if (page + 1 >= totalPages || listPage.items.length === 0) {
        break;
      }
    }

    scannedCategories.push({
      categoryId: category.depth3Id!,
      categoryName: normalizeText(category.depth3Name || category.depth2Name || String(category.depth3Id)),
      pageCount,
      scannedItemCount,
    });
  }

  const deduped = [...new Map(candidates.map((item) => [item.display_number, item])).values()].slice(0, MAX_RESULT_COUNT);
  const results = [];
  const rejectionSummary = [...rejectionSummaryMap.entries()]
    .sort((left, right) => right[1].count - left[1].count)
    .map(([reason, summary]) => ({
      reason,
      label: resolveFailureReasonLabel(reason),
      count: summary.count,
      samples: [],
    }));

  for (const item of deduped) {
    const detail = await fetchProductDetail(item.display_number);
    const info = detail.info ?? item;
    const photoUrls = Array.isArray(detail.info?.photo_res)
      ? detail.info!.photo_res
          .map((photo) => buildAbsoluteTicketBayUrl(photo.photo_url))
          .filter((value): value is string => Boolean(value))
      : [];

    results.push({
      productId: info.id,
      displayNumber: normalizeText(info.display_number),
      saleUrl: `${TICKET_BAY_ORIGIN}/product/view/${encodeURIComponent(normalizeText(info.display_number))}`,
      title: normalizeText(info.name),
      eventDateTime: normalizeText(info.start_perform_date),
      categoryName: normalizeText(info.depth3_name),
      teamName: normalizeText(info.depth2_name),
      area: normalizeText(info.area),
      rowLabel: normalizeText(info.seat_number_type || 'ROW'),
      row: normalizeText(info.seat_number),
      opponentOrFloor: normalizeText(info.floor),
      grade: normalizeText(info.grade),
      addInfo: normalizeText(info.addinfo),
      seatCount: typeof info.sale_quantity === 'number' ? info.sale_quantity : null,
      together: normalizeText(info.is_together) === 'YES',
      price: typeof info.price === 'number' ? info.price : null,
      totalPrice: typeof info.total_price === 'number' ? info.total_price : null,
      transactionType: normalizeText(info.transaction_type),
      sellerPost: normalizeText(info.description),
      productRemarks: Array.isArray(detail.info?.product_remark)
        ? detail.info!.product_remark.map((remark) => normalizeText(remark.content)).filter(Boolean)
        : [],
      seatRemarks: Array.isArray(detail.info?.seat_remark)
        ? detail.info!.seat_remark.map((remark) => normalizeText(remark.content)).filter(Boolean)
        : [],
      seatMapImageUrl: buildAbsoluteTicketBayUrl(detail.categoryInfo?.seat_image),
      photoUrls,
      sellerPhotoCount: photoUrls.length,
      createdAt: normalizeText(info.created_at),
      safeTrade: normalizeText(info.is_use_safe) === 'YES',
      pinTrade: normalizeText(info.is_use_pin) === 'YES',
      deliveryTrade: normalizeText(info.is_use_delivery) === 'YES',
      fieldTrade: normalizeText(info.is_use_field) === 'YES',
      etcTrade: normalizeText(info.is_use_etc) === 'YES',
    });
  }

  return {
    ok: true,
    keyword,
    scannedCategoryCount: filteredCategories.length,
    scannedCategories,
    scannedItemTotalCount,
    resultCount: results.length,
    results,
    rejectionSummary,
  };
}

export const BASEBALL_TICKET_BAY_ALERT_TABLE = 'baseball_ticket_bay_alerts';
export const BASEBALL_TICKET_BAY_LOG_TABLE = 'baseball_ticket_bay_logs';
export const BASEBALL_TICKET_BAY_SEEN_PRODUCT_TABLE = 'baseball_ticket_bay_seen_products';

export type BaseballTicketBayTimeWindow = {
  id: string;
  start: string;
  end: string;
};

export type BaseballTicketBayAlertItem = {
  id: string;
  clientId: string;
  ownerType: BaseballTicketBayOwnerType;
  ownerId: string;
  ownerLabel?: string | null;
  title: string;
  eventDate: string;
  team: string;
  zone: string;
  aisleSide: string;
  seatDirections: string[];
  maxPrice: number | null;
  seatCount: number;
  batchIntervalMinutes: number;
  sameProductAlertEnabled: boolean;
  sameProductNotifyOnce: boolean;
  active: boolean;
  timeWindows: BaseballTicketBayTimeWindow[];
  createdAt: string;
  updatedAt: string;
  lastRunAt: string | null;
  lastMatchAt: string | null;
};

export type BaseballTicketBayAlertLogAction = 'create' | 'run' | 'pause' | 'resume' | 'delete' | 'push';
export type BaseballTicketBayAlertLogStatus = 'info' | 'success' | 'warning' | 'error';

export type BaseballTicketBayAlertLogItem = {
  id: string;
  clientId: string;
  ownerType: BaseballTicketBayOwnerType;
  ownerId: string;
  ownerLabel?: string | null;
  alertId: string | null;
  alertTitle: string;
  action: BaseballTicketBayAlertLogAction;
  status: BaseballTicketBayAlertLogStatus;
  message: string;
  detail: string;
  createdAt: string;
  payload?: BaseballTicketBayRunPayload | null;
};

export type BaseballTicketBayRunPayload = {
  keyword: string;
  scannedCategoryCount: number;
  scannedCategories: Array<{
    categoryId: number;
    categoryName: string;
    pageCount: number;
    scannedItemCount: number;
  }>;
  scannedItemTotalCount: number;
  results: Array<Awaited<ReturnType<typeof searchBaseballTicketBayListings>>['results'][number]>;
  rejectionSummary: Array<{
    reason: AlertMatchFailureReason;
    label: string;
    count: number;
    samples: string[];
  }>;
};

export type BaseballTicketBayAlertMutation = {
  title: string;
  eventDate: string;
  team: string;
  zone: string;
  aisleSide: string;
  seatDirections: string[];
  maxPrice: number | null;
  seatCount: number;
  batchIntervalMinutes: number;
  sameProductAlertEnabled: boolean;
  sameProductNotifyOnce: boolean;
  active: boolean;
  timeWindows: BaseballTicketBayTimeWindow[];
};

type BaseballTicketBayOwnerType = 'client' | 'shared-token';

type BaseballTicketBayOwnerScope =
  | { kind: 'all' }
  | {
      kind: 'owner';
      ownerType: BaseballTicketBayOwnerType;
      ownerId: string;
    };

type BaseballTicketBayAlertRow = {
  id: string;
  client_id: string;
  owner_type: BaseballTicketBayOwnerType;
  owner_id: string;
  app_origin: string | null;
  app_domain: string | null;
  title: string;
  event_date: string;
  team: string;
  zone: string;
  aisle_side: string;
  seat_directions_json: string;
  max_price: number | string | null;
  seat_count: number | string;
  batch_interval_minutes: number | string;
  same_product_alert_enabled: boolean;
  same_product_notify_once: boolean;
  active: boolean;
  time_windows_json: string;
  created_at: string;
  updated_at: string;
  last_run_at: string | null;
  last_match_at: string | null;
};

type BaseballTicketBayLogRow = {
  id: string;
  client_id: string;
  owner_type: BaseballTicketBayOwnerType;
  owner_id: string;
  alert_id: string | null;
  alert_title: string;
  action: BaseballTicketBayAlertLogAction;
  status: BaseballTicketBayAlertLogStatus;
  message: string;
  detail: string;
  created_at: string;
  payload_json?: string | null;
};

type BaseballTicketBaySeenProductRow = {
  id: string;
  alert_id: string;
  product_id: string;
  created_at: string;
  updated_at: string;
};

let baseballTicketBayTableSetupPromise: Promise<void> | null = null;

function createId(prefix: string) {
  return `${prefix}-${crypto.randomUUID()}`;
}

function normalizeOwnerType(value: unknown): BaseballTicketBayOwnerType {
  return normalizeText(value) === 'shared-token' ? 'shared-token' : 'client';
}

function normalizeOwnerId(row: { owner_id?: unknown; client_id?: unknown }) {
  return normalizeText(row.owner_id) || normalizeText(row.client_id);
}

function applyOwnerScope(query: Knex.QueryBuilder, scope: BaseballTicketBayOwnerScope) {
  if (scope.kind === 'all') {
    return query;
  }

  return query.where({
    owner_type: scope.ownerType,
    owner_id: scope.ownerId,
  });
}

function normalizeNumericValue(value: unknown) {
  if (typeof value === 'number' && Number.isFinite(value)) {
    return value;
  }

  if (typeof value === 'string' && value.trim()) {
    const parsed = Number(value);
    return Number.isFinite(parsed) ? parsed : null;
  }

  return null;
}

function parseStringArray(value: string) {
  try {
    const parsed = JSON.parse(value) as unknown;
    return Array.isArray(parsed) ? parsed.map((item) => normalizeText(item)).filter(Boolean) : [];
  } catch {
    return [];
  }
}

function parseTimeWindows(value: string) {
  try {
    const parsed = JSON.parse(value) as unknown;

    if (!Array.isArray(parsed)) {
      return [] as BaseballTicketBayTimeWindow[];
    }

    return parsed
      .map((item) => {
        const row = item && typeof item === 'object' ? (item as Record<string, unknown>) : {};
        return {
          id: normalizeText(row.id) || createId('window'),
          start: normalizeText(row.start) || '00:00',
          end: normalizeText(row.end) || '23:59',
        };
      })
      .filter((item) => item.start <= item.end);
  } catch {
    return [];
  }
}

function mapAlertRow(row: BaseballTicketBayAlertRow): BaseballTicketBayAlertItem {
  return {
    id: normalizeText(row.id),
    clientId: normalizeText(row.client_id),
    ownerType: normalizeOwnerType(row.owner_type),
    ownerId: normalizeOwnerId(row),
    ownerLabel: null,
    title: normalizeText(row.title),
    eventDate: normalizeText(row.event_date),
    team: normalizeText(row.team) || '전체',
    zone: normalizeText(row.zone) || '전체',
    aisleSide: normalizeText(row.aisle_side) || '전체',
    seatDirections: parseStringArray(row.seat_directions_json),
    maxPrice: normalizeNumericValue(row.max_price),
    seatCount: normalizeNumericValue(row.seat_count) ?? 1,
    batchIntervalMinutes: normalizeNumericValue(row.batch_interval_minutes) ?? 3,
    sameProductAlertEnabled: row.same_product_alert_enabled !== false,
    sameProductNotifyOnce: row.same_product_notify_once === true,
    active: row.active !== false,
    timeWindows: parseTimeWindows(row.time_windows_json),
    createdAt: normalizeDateTime(row.created_at),
    updatedAt: normalizeDateTime(row.updated_at),
    lastRunAt: row.last_run_at ? normalizeDateTime(row.last_run_at) : null,
    lastMatchAt: row.last_match_at ? normalizeDateTime(row.last_match_at) : null,
  };
}

function mapLogRow(row: BaseballTicketBayLogRow): BaseballTicketBayAlertLogItem {
  let payload: BaseballTicketBayRunPayload | null = null;

  if (normalizeText(row.payload_json)) {
    try {
      payload = JSON.parse(String(row.payload_json)) as BaseballTicketBayRunPayload;
    } catch {
      payload = null;
    }
  }

  return {
    id: normalizeText(row.id),
    clientId: normalizeText(row.client_id),
    ownerType: normalizeOwnerType(row.owner_type),
    ownerId: normalizeOwnerId(row),
    ownerLabel: null,
    alertId: row.alert_id ? normalizeText(row.alert_id) : null,
    alertTitle: normalizeText(row.alert_title),
    action: row.action,
    status: row.status,
    message: normalizeText(row.message),
    detail: normalizeText(row.detail),
    createdAt: normalizeDateTime(row.created_at),
    payload,
  };
}

async function attachOwnerLabels<T extends { ownerType: BaseballTicketBayOwnerType; ownerId: string; ownerLabel?: string | null }>(items: T[]) {
  const sharedTokenOwnerIds = Array.from(
    new Set(
      items
        .filter((item) => item.ownerType === 'shared-token')
        .map((item) => normalizeText(item.ownerId))
        .filter(Boolean),
    ),
  );

  if (!sharedTokenOwnerIds.length) {
    return items;
  }

  const labelEntries = await Promise.all(
    sharedTokenOwnerIds.map(async (tokenId) => {
      const detail = await getSharedResourceTokenDetail(tokenId);
      return [tokenId, detail?.token.resourceLabel?.trim() || detail?.token.name?.trim() || null] as const;
    }),
  );
  const labelMap = new Map(labelEntries);

  return items.map((item) => ({
    ...item,
    ownerLabel: item.ownerType === 'shared-token' ? labelMap.get(normalizeText(item.ownerId)) ?? null : null,
  }));
}

function formatTimeInKst(date: Date) {
  return new Intl.DateTimeFormat('en-GB', {
    timeZone: 'Asia/Seoul',
    hour: '2-digit',
    minute: '2-digit',
    hour12: false,
  }).format(date);
}

function isWithinBlockedTime(timeWindows: BaseballTicketBayTimeWindow[], now: Date) {
  if (!timeWindows.length) {
    return false;
  }

  const currentTime = formatTimeInKst(now);
  return timeWindows.some((item) => item.start <= currentTime && currentTime <= item.end);
}

function buildSummaryLine(item: {
  eventDateTime?: string;
  opponentOrFloor?: string;
  grade?: string;
  area?: string;
  title?: string;
  addInfo?: string;
  price?: number | null;
  seatCount?: number | null;
  sellerPhotoCount?: number | null;
}) {
  return [
    normalizeText(item.eventDateTime),
    normalizeText(item.opponentOrFloor),
    normalizeText(item.grade),
    normalizeText(item.area),
    normalizeText(item.title),
    item.price != null ? `${item.price.toLocaleString()}원` : '',
    item.seatCount != null ? `${item.seatCount}매` : '',
    normalizeText(item.addInfo),
    item.sellerPhotoCount === 0 ? '직관샷 없음' : item.sellerPhotoCount != null ? `직관샷 ${item.sellerPhotoCount}장` : '',
  ]
    .filter(Boolean)
    .join(' · ');
}

function buildNotificationBody(alert: BaseballTicketBayAlertItem, items: Array<Record<string, unknown>>) {
  const header = `${alert.team} · ${alert.eventDate} · ${items.length}개 티켓 발견`;
  const lines = items.slice(0, 5).map((item) =>
    `- ${buildSummaryLine({
      eventDateTime: normalizeText(item.eventDateTime),
      opponentOrFloor: normalizeText(item.opponentOrFloor),
      grade: normalizeText(item.grade),
      area: normalizeText(item.area),
      title: normalizeText(item.title),
      addInfo: normalizeText(item.addInfo),
      price: typeof item.price === 'number' ? item.price : null,
      seatCount: typeof item.seatCount === 'number' ? item.seatCount : null,
      sellerPhotoCount: typeof item.sellerPhotoCount === 'number' ? item.sellerPhotoCount : null,
    })}`,
  );

  return [header, ...lines].join('\n');
}

function pickProductId(item: Record<string, unknown>) {
  return normalizeText(item.displayNumber) || normalizeText(item.productId);
}

export async function ensureBaseballTicketBayTables() {
  if (!baseballTicketBayTableSetupPromise) {
    baseballTicketBayTableSetupPromise = (async () => {
      const hasAlertTable = await db.schema.hasTable(BASEBALL_TICKET_BAY_ALERT_TABLE);

      if (!hasAlertTable) {
        await db.schema.createTable(BASEBALL_TICKET_BAY_ALERT_TABLE, (table) => {
          table.string('id', 120).primary();
          table.string('client_id', 200).notNullable().index();
          table.string('owner_type', 40).notNullable().defaultTo('client').index();
          table.string('owner_id', 200).notNullable().defaultTo('').index();
          table.text('app_origin').nullable();
          table.string('app_domain', 255).nullable();
          table.string('title', 255).notNullable();
          table.string('event_date', 20).notNullable().index();
          table.string('team', 50).notNullable().defaultTo('전체');
          table.string('zone', 100).notNullable().defaultTo('전체');
          table.string('aisle_side', 100).notNullable().defaultTo('전체');
          table.text('seat_directions_json').notNullable().defaultTo('[]');
          table.integer('max_price').nullable();
          table.integer('seat_count').notNullable().defaultTo(1);
          table.integer('batch_interval_minutes').notNullable().defaultTo(3);
          table.boolean('same_product_alert_enabled').notNullable().defaultTo(true);
          table.boolean('same_product_notify_once').notNullable().defaultTo(false);
          table.boolean('active').notNullable().defaultTo(true);
          table.text('time_windows_json').notNullable().defaultTo('[]');
          table.timestamp('last_run_at').nullable();
          table.timestamp('last_match_at').nullable();
          table.timestamp('created_at').notNullable().defaultTo(db.fn.now());
          table.timestamp('updated_at').notNullable().defaultTo(db.fn.now());
        });
      }

      const hasLogTable = await db.schema.hasTable(BASEBALL_TICKET_BAY_LOG_TABLE);

      if (!hasLogTable) {
        await db.schema.createTable(BASEBALL_TICKET_BAY_LOG_TABLE, (table) => {
          table.string('id', 120).primary();
          table.string('client_id', 200).notNullable().index();
          table.string('owner_type', 40).notNullable().defaultTo('client').index();
          table.string('owner_id', 200).notNullable().defaultTo('').index();
          table.string('alert_id', 120).nullable().index();
          table.string('alert_title', 255).notNullable();
          table.string('action', 40).notNullable();
          table.string('status', 40).notNullable();
          table.string('message', 255).notNullable();
          table.text('detail').notNullable().defaultTo('');
          table.text('payload_json').nullable();
          table.timestamp('created_at').notNullable().defaultTo(db.fn.now()).index();
        });
      }

      const hasPayloadJsonColumn = await db.schema.hasColumn(BASEBALL_TICKET_BAY_LOG_TABLE, 'payload_json');

      if (!hasPayloadJsonColumn) {
        await db.schema.alterTable(BASEBALL_TICKET_BAY_LOG_TABLE, (table) => {
          table.text('payload_json').nullable();
        });
      }

      const hasAlertOwnerTypeColumn = await db.schema.hasColumn(BASEBALL_TICKET_BAY_ALERT_TABLE, 'owner_type');

      if (!hasAlertOwnerTypeColumn) {
        await db.schema.alterTable(BASEBALL_TICKET_BAY_ALERT_TABLE, (table) => {
          table.string('owner_type', 40).notNullable().defaultTo('client').index();
        });
      }

      const hasAlertOwnerIdColumn = await db.schema.hasColumn(BASEBALL_TICKET_BAY_ALERT_TABLE, 'owner_id');

      if (!hasAlertOwnerIdColumn) {
        await db.schema.alterTable(BASEBALL_TICKET_BAY_ALERT_TABLE, (table) => {
          table.string('owner_id', 200).notNullable().defaultTo('').index();
        });
      }

      await db(BASEBALL_TICKET_BAY_ALERT_TABLE)
        .where((builder) => {
          builder.whereNull('owner_type').orWhere('owner_type', '');
        })
        .update({ owner_type: 'client' });
      await db(BASEBALL_TICKET_BAY_ALERT_TABLE)
        .where((builder) => {
          builder.whereNull('owner_id').orWhere('owner_id', '');
        })
        .update({ owner_id: db.ref('client_id') });

      const hasLogOwnerTypeColumn = await db.schema.hasColumn(BASEBALL_TICKET_BAY_LOG_TABLE, 'owner_type');

      if (!hasLogOwnerTypeColumn) {
        await db.schema.alterTable(BASEBALL_TICKET_BAY_LOG_TABLE, (table) => {
          table.string('owner_type', 40).notNullable().defaultTo('client').index();
        });
      }

      const hasLogOwnerIdColumn = await db.schema.hasColumn(BASEBALL_TICKET_BAY_LOG_TABLE, 'owner_id');

      if (!hasLogOwnerIdColumn) {
        await db.schema.alterTable(BASEBALL_TICKET_BAY_LOG_TABLE, (table) => {
          table.string('owner_id', 200).notNullable().defaultTo('').index();
        });
      }

      await db(BASEBALL_TICKET_BAY_LOG_TABLE)
        .where((builder) => {
          builder.whereNull('owner_type').orWhere('owner_type', '');
        })
        .update({ owner_type: 'client' });
      await db(BASEBALL_TICKET_BAY_LOG_TABLE)
        .where((builder) => {
          builder.whereNull('owner_id').orWhere('owner_id', '');
        })
        .update({ owner_id: db.ref('client_id') });

      const hasSeenTable = await db.schema.hasTable(BASEBALL_TICKET_BAY_SEEN_PRODUCT_TABLE);

      if (!hasSeenTable) {
        await db.schema.createTable(BASEBALL_TICKET_BAY_SEEN_PRODUCT_TABLE, (table) => {
          table.string('id', 120).primary();
          table.string('alert_id', 120).notNullable().index();
          table.string('product_id', 200).notNullable();
          table.timestamp('created_at').notNullable().defaultTo(db.fn.now());
          table.timestamp('updated_at').notNullable().defaultTo(db.fn.now());
          table.unique(['alert_id', 'product_id']);
        });
      }
    })().catch((error) => {
      baseballTicketBayTableSetupPromise = null;
      throw error;
    });
  }

  return baseballTicketBayTableSetupPromise;
}

export async function listBaseballTicketBayAlerts(scope: BaseballTicketBayOwnerScope) {
  await ensureBaseballTicketBayTables();
  const query = applyOwnerScope(db(BASEBALL_TICKET_BAY_ALERT_TABLE).select('*'), scope);

  const rows = (await query.orderBy([
    { column: 'event_date', order: 'asc' },
    { column: 'owner_type', order: 'asc' },
    { column: 'owner_id', order: 'asc' },
    { column: 'client_id', order: 'asc' },
    { column: 'created_at', order: 'desc' },
  ])) as BaseballTicketBayAlertRow[];
  return attachOwnerLabels(rows.map((row: BaseballTicketBayAlertRow) => mapAlertRow(row)));
}

export async function listBaseballTicketBayLogs(
  scope: BaseballTicketBayOwnerScope,
  alertId?: string,
) {
  await ensureBaseballTicketBayTables();
  let query = applyOwnerScope(db(BASEBALL_TICKET_BAY_LOG_TABLE).select('*'), scope).orderBy('created_at', 'desc').limit(200);

  if (alertId) {
    query = query.andWhere({ alert_id: alertId });
  }

  const rows = (await query) as BaseballTicketBayLogRow[];
  return attachOwnerLabels(rows.map((row: BaseballTicketBayLogRow) => mapLogRow(row)));
}

export async function createBaseballTicketBayLog(args: {
  clientId: string;
  ownerType: BaseballTicketBayOwnerType;
  ownerId: string;
  alertId?: string | null;
  alertTitle: string;
  action: BaseballTicketBayAlertLogAction;
  status: BaseballTicketBayAlertLogStatus;
  message: string;
  detail?: string;
  payload?: BaseballTicketBayRunPayload | null;
}) {
  await ensureBaseballTicketBayTables();
  const row: BaseballTicketBayLogRow = {
    id: createId('log'),
    client_id: args.clientId,
    owner_type: args.ownerType,
    owner_id: args.ownerId,
    alert_id: args.alertId ?? null,
    alert_title: args.alertTitle,
    action: args.action,
    status: args.status,
    message: args.message,
    detail: args.detail ?? '',
    created_at: new Date().toISOString(),
    payload_json: args.payload ? JSON.stringify(args.payload) : null,
  };
  await db(BASEBALL_TICKET_BAY_LOG_TABLE).insert(row);
  return attachOwnerLabels([mapLogRow(row)]).then(([item]) => item);
}

export async function deleteBaseballTicketBayLog(logId: string, scope: BaseballTicketBayOwnerScope) {
  await ensureBaseballTicketBayTables();

  const existing = await applyOwnerScope(
    db(BASEBALL_TICKET_BAY_LOG_TABLE)
      .select('*')
      .where({
        id: logId,
      }),
    scope,
  ).first();

  if (!existing) {
    return null;
  }

  await applyOwnerScope(
    db(BASEBALL_TICKET_BAY_LOG_TABLE).where({
      id: logId,
    }),
    scope,
  ).delete();

  return attachOwnerLabels([mapLogRow(existing as BaseballTicketBayLogRow)]).then(([item]) => item);
}

export async function createBaseballTicketBayAlert(
  payload: BaseballTicketBayAlertMutation,
  context: { clientId: string; ownerType: BaseballTicketBayOwnerType; ownerId: string; appOrigin?: string; appDomain?: string },
) {
  await ensureBaseballTicketBayTables();
  const now = new Date().toISOString();
  const row: BaseballTicketBayAlertRow = {
    id: createId('alert'),
    client_id: context.clientId,
    owner_type: context.ownerType,
    owner_id: context.ownerId,
    app_origin: normalizeText(context.appOrigin) || null,
    app_domain: normalizeText(context.appDomain) || null,
    title: payload.title.trim(),
    event_date: payload.eventDate,
    team: payload.team,
    zone: payload.zone,
    aisle_side: payload.aisleSide,
    seat_directions_json: JSON.stringify(payload.seatDirections),
    max_price: payload.maxPrice,
    seat_count: payload.seatCount,
    batch_interval_minutes: payload.batchIntervalMinutes,
    same_product_alert_enabled: payload.sameProductAlertEnabled,
    same_product_notify_once: payload.sameProductNotifyOnce,
    active: payload.active,
    time_windows_json: JSON.stringify(payload.timeWindows),
    created_at: now,
    updated_at: now,
    last_run_at: null,
    last_match_at: null,
  };
  await db(BASEBALL_TICKET_BAY_ALERT_TABLE).insert(row);
  return attachOwnerLabels([mapAlertRow(row)]).then(([item]) => item);
}

export async function updateBaseballTicketBayAlert(
  alertId: string,
  payload: Partial<BaseballTicketBayAlertMutation>,
  context:
    | { scope: BaseballTicketBayOwnerScope; appOrigin?: string; appDomain?: string }
    | { clientId: string; ownerType: BaseballTicketBayOwnerType; ownerId: string; appOrigin?: string; appDomain?: string },
) {
  await ensureBaseballTicketBayTables();
  const scope =
    'scope' in context
      ? context.scope
      : ({ kind: 'owner', ownerType: context.ownerType, ownerId: context.ownerId } as const);
  const current = await applyOwnerScope(
    db(BASEBALL_TICKET_BAY_ALERT_TABLE)
      .select('*')
      .where({ id: alertId }),
    scope,
  ).first();

  if (!current) {
    throw new Error('수정할 알림을 찾지 못했습니다.');
  }

  const patch: Record<string, unknown> = {
    updated_at: new Date().toISOString(),
  };

  if (payload.title !== undefined) patch.title = payload.title.trim();
  if (payload.eventDate !== undefined) patch.event_date = payload.eventDate;
  if (payload.team !== undefined) patch.team = payload.team;
  if (payload.zone !== undefined) patch.zone = payload.zone;
  if (payload.aisleSide !== undefined) patch.aisle_side = payload.aisleSide;
  if (payload.seatDirections !== undefined) patch.seat_directions_json = JSON.stringify(payload.seatDirections);
  if (payload.maxPrice !== undefined) patch.max_price = payload.maxPrice;
  if (payload.seatCount !== undefined) patch.seat_count = payload.seatCount;
  if (payload.batchIntervalMinutes !== undefined) patch.batch_interval_minutes = payload.batchIntervalMinutes;
  if (payload.sameProductAlertEnabled !== undefined) patch.same_product_alert_enabled = payload.sameProductAlertEnabled;
  if (payload.sameProductNotifyOnce !== undefined) patch.same_product_notify_once = payload.sameProductNotifyOnce;
  if (payload.active !== undefined) patch.active = payload.active;
  if (payload.timeWindows !== undefined) patch.time_windows_json = JSON.stringify(payload.timeWindows);
  if (context.appOrigin) patch.app_origin = normalizeText(context.appOrigin);
  if (context.appDomain) patch.app_domain = normalizeText(context.appDomain);

  await applyOwnerScope(db(BASEBALL_TICKET_BAY_ALERT_TABLE).where({ id: alertId }), scope).update(patch);

  const updated = await applyOwnerScope(
    db(BASEBALL_TICKET_BAY_ALERT_TABLE)
      .select('*')
      .where({ id: alertId }),
    scope,
  ).first();
  return attachOwnerLabels([mapAlertRow(updated as BaseballTicketBayAlertRow)]).then(([item]) => item);
}

export async function deleteBaseballTicketBayAlert(alertId: string, scope: BaseballTicketBayOwnerScope) {
  await ensureBaseballTicketBayTables();
  const row = await applyOwnerScope(
    db(BASEBALL_TICKET_BAY_ALERT_TABLE)
      .select('*')
      .where({ id: alertId }),
    scope,
  ).first();

  if (!row) {
    return null;
  }

  await applyOwnerScope(
    db(BASEBALL_TICKET_BAY_ALERT_TABLE).where({ id: alertId }),
    scope,
  ).delete();
  return attachOwnerLabels([mapAlertRow(row as BaseballTicketBayAlertRow)]).then(([item]) => item);
}

async function getAlertRow(alertId: string, scope?: BaseballTicketBayOwnerScope) {
  await ensureBaseballTicketBayTables();
  const scopedQuery = scope
    ? applyOwnerScope(
        db(BASEBALL_TICKET_BAY_ALERT_TABLE)
          .select('*')
          .where({ id: alertId }),
        scope,
      )
    : db(BASEBALL_TICKET_BAY_ALERT_TABLE)
        .select('*')
        .where({ id: alertId });
  const row = await scopedQuery.first();
  return row ? (row as BaseballTicketBayAlertRow) : null;
}

async function getSeenProductIds(alertId: string) {
  await ensureBaseballTicketBayTables();
  const rows = await db(BASEBALL_TICKET_BAY_SEEN_PRODUCT_TABLE)
    .select('product_id')
    .where({ alert_id: alertId });
  return new Set(rows.map((row) => normalizeText(row.product_id)).filter(Boolean));
}

async function markSeenProductIds(alertId: string, productIds: string[]) {
  const uniqueProductIds = [...new Set(productIds.map((value) => normalizeText(value)).filter(Boolean))];

  if (!uniqueProductIds.length) {
    return;
  }

  const now = new Date().toISOString();
  const rows: BaseballTicketBaySeenProductRow[] = uniqueProductIds.map((productId) => ({
    id: createId('seen'),
    alert_id: alertId,
    product_id: productId,
    created_at: now,
    updated_at: now,
  }));

  await db(BASEBALL_TICKET_BAY_SEEN_PRODUCT_TABLE)
    .insert(rows)
    .onConflict(['alert_id', 'product_id'])
    .merge({ updated_at: now });
}

async function updateAlertRunTimestamp(alertId: string, patch: { lastRunAt: string; lastMatchAt?: string | null }) {
  const normalizedLastRunAt = normalizeTimestampForDb(patch.lastRunAt) ?? new Date().toISOString();
  const normalizedLastMatchAt = normalizeTimestampForDb(patch.lastMatchAt);

  await db(BASEBALL_TICKET_BAY_ALERT_TABLE)
    .where({ id: alertId })
    .update({
      last_run_at: normalizedLastRunAt,
      last_match_at: normalizedLastMatchAt,
      updated_at: normalizedLastRunAt,
    });
}

export async function runBaseballTicketBayAlert(
  alertId: string,
  options?: { ignoreTimeWindow?: boolean; scope?: BaseballTicketBayOwnerScope },
) {
  const row = await getAlertRow(alertId, options?.scope);

  if (!row) {
    throw new Error('실행할 알림을 찾지 못했습니다.');
  }

  const alert = mapAlertRow(row);
  const now = new Date();

  if (!options?.ignoreTimeWindow && isWithinBlockedTime(alert.timeWindows, now)) {
    const log = await createBaseballTicketBayLog({
      clientId: row.client_id,
      ownerType: normalizeOwnerType(row.owner_type),
      ownerId: normalizeOwnerId(row),
      alertId: alert.id,
      alertTitle: alert.title,
      action: 'run',
      status: 'warning',
      message: '안받을 시간대라 이번 배치를 건너뛰었습니다.',
      detail: alert.timeWindows.map((item) => `${item.start}-${item.end}`).join(', '),
    });
    await updateAlertRunTimestamp(alert.id, { lastRunAt: now.toISOString(), lastMatchAt: alert.lastMatchAt });
    return { alert, matches: [] as Array<Record<string, unknown>>, notifiedMatches: [] as Array<Record<string, unknown>>, log };
  }

  const searchResult = await searchBaseballTicketBayListings({
    title: alert.title,
    eventDate: alert.eventDate,
    team: alert.team,
    zone: alert.zone,
    aisleSide: alert.aisleSide,
    seatDirections: alert.seatDirections,
    maxPrice: alert.maxPrice,
    seatCount: alert.seatCount,
  });
  const matches = Array.isArray(searchResult.results) ? searchResult.results : [];
  const payload: BaseballTicketBayRunPayload = {
    keyword: searchResult.keyword,
    scannedCategoryCount: searchResult.scannedCategoryCount,
    scannedCategories: searchResult.scannedCategories,
    scannedItemTotalCount: searchResult.scannedItemTotalCount,
    results: matches,
    rejectionSummary: searchResult.rejectionSummary,
  };
  const seenIds =
    !alert.sameProductAlertEnabled || alert.sameProductNotifyOnce
      ? await getSeenProductIds(alert.id)
      : new Set<string>();
  const notifiedMatches =
    !alert.sameProductAlertEnabled || alert.sameProductNotifyOnce
      ? matches.filter((item) => !seenIds.has(pickProductId(item as Record<string, unknown>)))
      : matches;

  if (!notifiedMatches.length) {
    const topRejection = payload.rejectionSummary[0] ?? null;
    const hasRejectedListings = matches.length === 0 && payload.scannedItemTotalCount > 0 && Boolean(topRejection);
    const rejectionDetailLines = topRejection
      ? [`검색 제외 주된 사유: ${topRejection.label} ${topRejection.count}건`]
      : [];
    const log = await createBaseballTicketBayLog({
      clientId: row.client_id,
      ownerType: normalizeOwnerType(row.owner_type),
      ownerId: normalizeOwnerId(row),
      alertId: alert.id,
      alertTitle: alert.title,
      action: 'run',
      status: matches.length > 0 || hasRejectedListings ? 'warning' : 'info',
      message:
        matches.length > 0
          ? '조건에 맞는 티켓은 있지만 같은 상품 제외 규칙으로 건너뛰었습니다.'
          : hasRejectedListings
            ? `티켓베이 공개 상품 API에서 검색된 판매글은 있었지만 ${topRejection?.label ?? '조건 불일치'} 때문에 제외됐습니다.`
            : '티켓베이 공개 상품 API 기준 조건에 맞는 티켓을 찾지 못했습니다.',
      detail:
        matches.length > 0
          ? matches.slice(0, 5).map((item) => buildSummaryLine(item as Record<string, unknown>)).join('\n')
          : rejectionDetailLines.join('\n'),
      payload,
    });
    await updateAlertRunTimestamp(alert.id, { lastRunAt: now.toISOString(), lastMatchAt: alert.lastMatchAt });
    return { alert, matches, notifiedMatches, log };
  }

  const notificationResult = await sendNotifications({
    title: alert.title,
    body: buildNotificationBody(alert, notifiedMatches as Array<Record<string, unknown>>),
    targetClientIds: [row.client_id],
    targetAppOrigins: row.app_origin ? [row.app_origin] : undefined,
    targetAppDomains: row.app_domain ? [row.app_domain] : undefined,
    data: {
      category: 'play-app',
      type: 'baseball-ticket-bay',
      notificationScope: 'automation',
      notificationKey: `baseball-ticket-bay:${alert.id}:${now.toISOString()}`,
      targetUrl: row.app_origin ? `${row.app_origin}/?topMenu=play&playSection=apps&app=baseball-ticket-bay` : '/?topMenu=play&playSection=apps&app=baseball-ticket-bay',
      alertId: alert.id,
      alertTitle: alert.title,
      eventDate: alert.eventDate,
    },
  });
  const sentCount = notificationResult.web.sentCount + notificationResult.ios.sentCount;
  const detail = [
    ...notifiedMatches.slice(0, 5).map((item) => buildSummaryLine(item as Record<string, unknown>)),
    `알림 전송: web ${notificationResult.web.sentCount}건 · ios ${notificationResult.ios.sentCount}건`,
  ].join('\n');
  const log = await createBaseballTicketBayLog({
    clientId: row.client_id,
    ownerType: normalizeOwnerType(row.owner_type),
    ownerId: normalizeOwnerId(row),
    alertId: alert.id,
    alertTitle: alert.title,
    action: 'run',
    status: 'success',
    message: `${notifiedMatches.length}개 티켓을 찾았습니다.${sentCount > 0 ? ` 알림 ${sentCount}건 전송.` : ''}`,
    detail,
    payload,
  });

  if (!alert.sameProductAlertEnabled || alert.sameProductNotifyOnce) {
    await markSeenProductIds(
      alert.id,
      notifiedMatches.map((item) => pickProductId(item as Record<string, unknown>)),
    );
  }

  await updateAlertRunTimestamp(alert.id, { lastRunAt: now.toISOString(), lastMatchAt: now.toISOString() });
  return { alert, matches, notifiedMatches, log };
}

function isAlertDue(alert: BaseballTicketBayAlertItem, now: Date) {
  if (!alert.active) {
    return false;
  }

  if (isWithinBlockedTime(alert.timeWindows, now)) {
    return false;
  }

  if (!alert.lastRunAt) {
    return true;
  }

  const lastRunAt = Date.parse(alert.lastRunAt);

  if (Number.isNaN(lastRunAt)) {
    return true;
  }

  return now.getTime() - lastRunAt >= alert.batchIntervalMinutes * 60 * 1000;
}

function readBooleanLikeValue(value: unknown) {
  return value === true || value === 't' || value === 'true' || value === 1 || value === '1';
}

async function tryAcquireBaseballTicketBayBatchLock() {
  const result = (await db.raw('select pg_try_advisory_lock(?) as locked', [
    BASEBALL_TICKET_BAY_BATCH_ADVISORY_LOCK_KEY,
  ])) as { rows?: Array<{ locked?: unknown }> };
  return readBooleanLikeValue(result.rows?.[0]?.locked);
}

async function releaseBaseballTicketBayBatchLock() {
  await db.raw('select pg_advisory_unlock(?)', [BASEBALL_TICKET_BAY_BATCH_ADVISORY_LOCK_KEY]);
}

export async function processDueBaseballTicketBayAlerts(now = new Date()) {
  if (!(await tryAcquireBaseballTicketBayBatchLock())) {
    return [];
  }

  try {
    await ensureBaseballTicketBayTables();
    const rows = await db(BASEBALL_TICKET_BAY_ALERT_TABLE).select('*').where({ active: true });
    const results: Array<{ alertId: string; ok: boolean; message?: string }> = [];

    for (const row of rows as BaseballTicketBayAlertRow[]) {
      const alert = mapAlertRow(row);

      if (!isAlertDue(alert, now)) {
        continue;
      }

      try {
        await runBaseballTicketBayAlert(alert.id);
        results.push({ alertId: alert.id, ok: true });
      } catch (error) {
        const handledError = error instanceof Error ? error : new Error(String(error));
        await createBaseballTicketBayLog({
          clientId: row.client_id,
          ownerType: normalizeOwnerType(row.owner_type),
          ownerId: normalizeOwnerId(row),
          alertId: alert.id,
          alertTitle: alert.title,
          action: 'run',
          status: 'error',
          message: handledError.message || '배치 실행에 실패했습니다.',
          detail: '',
        });
        await updateAlertRunTimestamp(alert.id, { lastRunAt: now.toISOString(), lastMatchAt: alert.lastMatchAt });
        results.push({ alertId: alert.id, ok: false, message: handledError.message });
      }
    }

    return results;
  } finally {
    await releaseBaseballTicketBayBatchLock();
  }
}
