import { sendNotifications } from './notification-service.js';
import { db } from '../db/client.js';

export const STOCK_ALERT_TABLE = 'stock_alerts';
export const STOCK_ALERT_VOLUME_SNAPSHOT_TABLE = 'stock_alert_volume_snapshots';
export const STOCK_ALERT_VOLUME_HISTORY_TABLE = 'stock_alert_volume_histories';
export const STOCK_ALERT_LAYOUT_NAME = 'stock알림';

export const STOCK_ALERT_TYPE_OPTIONS = [
  { value: 'all', label: '전체' },
  { value: 'price', label: '현재가' },
  { value: 'top3', label: '등락폭이 큰 상위3종목' },
] as const;

export type StockAlertFilterType = (typeof STOCK_ALERT_TYPE_OPTIONS)[number]['value'];
export type StockAlertType = Exclude<StockAlertFilterType, 'all'>;

export type StockAlertRow = {
  id: string;
  stock_code: string;
  stock_name: string;
  alert_type: StockAlertType;
  created_at: string;
  updated_at: string;
};

export type StockAlertVolumeSnapshotRow = {
  stock_code: string;
  stock_name: string;
  previous_volume: number | string | null;
  current_volume: number | string | null;
  volume_increase_percent: number | string | null;
  current_price: number | string | null;
  change_rate: number | string | null;
  quoted_at: string | null;
  created_at: string;
  updated_at: string;
};

export type StockAlertQuote = {
  currentPrice: number | null;
  changeRate: number | null;
  volumeRate5d: number | null;
  currentVolume: number | null;
  quotedAt: string | null;
  stockName: string | null;
};

export type StockAlertItem = {
  id: string;
  stockCode: string;
  stockName: string;
  alertTypes: StockAlertType[];
  alertTypeLabels: string[];
  currentPrice: number | null;
  changeRate: number | null;
  volumeRate5d: number | null;
  currentVolume: number | null;
  quotedAt: string | null;
  createdAt: string;
  updatedAt: string;
};

export type StockAlertVolumeSnapshot = {
  stockCode: string;
  stockName: string;
  previousVolume: number | null;
  currentVolume: number | null;
  volumeIncreasePercent: number | null;
  currentPrice: number | null;
  changeRate: number | null;
  quotedAt: string | null;
  createdAt: string;
  updatedAt: string;
};

export type StockAlertVolumeHistoryRow = {
  id: string;
  stock_code: string;
  stock_name: string;
  baseline_volume: number | string | null;
  current_volume: number | string | null;
  volume_increase_percent: number | string | null;
  current_price: number | string | null;
  change_rate: number | string | null;
  quoted_at: string | null;
  created_at: string;
};

export type StockAlertVolumeHistory = {
  id: string;
  stockCode: string;
  stockName: string;
  baselineVolume: number | null;
  currentVolume: number | null;
  volumeIncreasePercent: number | null;
  currentPrice: number | null;
  changeRate: number | null;
  quotedAt: string | null;
  createdAt: string;
};

export type StockAlertVolumeSpikeCandidate = {
  stockCode: string;
  stockName: string;
  currentPrice: number | null;
  changeRate: number | null;
  currentVolume: number | null;
  previousVolume: number | null;
  volumeIncreasePercent: number | null;
  recentMaxVolumeIncreasePercent: number | null;
  volumeAmplificationGrowthPercent: number | null;
  quotedAt: string | null;
};

type YahooQuoteRow = {
  symbol?: string;
  regularMarketPrice?: number;
  preMarketPrice?: number;
  postMarketPrice?: number;
  regularMarketChangePercent?: number;
  preMarketChangePercent?: number;
  postMarketChangePercent?: number;
  regularMarketTime?: number;
  preMarketTime?: number;
  postMarketTime?: number;
  shortName?: string;
  longName?: string;
};

type YahooSearchQuote = {
  symbol?: string;
  shortname?: string;
  longname?: string;
  exchange?: string;
  exchDisp?: string;
  typeDisp?: string;
};

type NaverRealtimeRow = {
  cd?: string;
  nm?: string;
  nv?: number;
  cr?: number;
  rf?: string;
  ms?: string;
  tyn?: string;
  pcv?: number;
  aq?: number | string;
  accumulatedTradingVolume?: number | string;
  nxtOverMarketPriceInfo?: {
    tradingSessionType?: string;
    overMarketStatus?: string;
    overPrice?: string;
    accumulatedTradingVolume?: string;
    fluctuationsRatio?: string;
    compareToPreviousClosePrice?: string;
    compareToPreviousPrice?: {
      code?: string;
      name?: string;
      text?: string;
    };
    localTradedAt?: string;
  };
};

type NaverCompareToPreviousPrice = {
  code?: string;
  name?: string;
  text?: string;
};

export type StockAlertSearchItem = {
  stockCode: string;
  stockName: string;
  market: string;
};

type KrxListedStock = {
  stockCode: string;
  stockName: string;
  market: string;
};

const STOCK_ALERT_LABEL_MAP = new Map<string, string>(
  STOCK_ALERT_TYPE_OPTIONS.map((option) => [option.value, option.label]),
);

const STOCK_ALERT_VALUE_SET = new Set<StockAlertType>(['price', 'top3']);
const KRX_CORP_LIST_URL = 'https://kind.krx.co.kr/corpgeneral/corpList.do?method=download&searchType=13';
const KRX_CORP_LIST_CACHE_TTL_MS = 1000 * 60 * 60 * 12;
const STOCK_ALERT_NOTIFICATION_TITLE = '현재 주가';
const STOCK_ALERT_NOTIFICATION_TARGET_DOMAIN = 'test.sm-home.cloud';
const STOCK_ALERT_NOTIFICATION_SCOPE = 'schedule-2-stock-alert';
const STOCK_ALERT_NOTIFICATION_TARGET_URL = `https://${STOCK_ALERT_NOTIFICATION_TARGET_DOMAIN}/?topMenu=play&playMenu=layout`;
const KOREA_TIMEZONE = 'Asia/Seoul';
const KOREA_PREOPEN_RESET_HOUR = 5;
const KOREA_REGULAR_OPEN_HOUR = 9;

let cachedKrxListedStocks: {
  expiresAt: number;
  items: KrxListedStock[];
} | null = null;

function normalizeTimestamp(value: Date | string | undefined) {
  if (!value) {
    return new Date().toISOString();
  }

  if (value instanceof Date) {
    return value.toISOString();
  }

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

function normalizeAlertType(value: string): StockAlertType {
  const normalized = value.trim().toLowerCase();

  if (!STOCK_ALERT_VALUE_SET.has(normalized as StockAlertType)) {
    throw new Error('알림유형은 현재가 또는 등락폭이 큰 상위3종목만 저장할 수 있습니다.');
  }

  return normalized as StockAlertType;
}

function normalizeStockCode(value: string) {
  const digits = value.replace(/\D+/g, '');
  return digits.length === 6 ? digits : '';
}

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

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

  if (typeof value !== 'string') {
    return null;
  }

  const normalized = value.replace(/[^0-9.-]+/g, '');

  if (!normalized) {
    return null;
  }

  const parsed = Number(normalized);
  return Number.isFinite(parsed) ? parsed : null;
}

function applySign(value: number | null, sign: -1 | 1 | null) {
  if (value === null || sign === null || value === 0) {
    return value;
  }

  return Math.abs(value) * sign;
}

function resolveNaverDirectionSign(compareToPreviousPrice: NaverCompareToPreviousPrice | undefined) {
  const direction = compareToPreviousPrice?.name?.trim().toUpperCase() || compareToPreviousPrice?.code?.trim();

  if (direction === 'FALLING' || direction === '4' || direction === '5') {
    return -1;
  }

  if (direction === 'RISING' || direction === '2') {
    return 1;
  }

  return null;
}

function resolveSignedNaverChangeRate(
  rate: number | null,
  compareToPreviousClosePrice: unknown,
  compareToPreviousPrice: NaverCompareToPreviousPrice | undefined,
) {
  const signedChangeAmount = parseLooseNumber(compareToPreviousClosePrice);

  if (signedChangeAmount !== null && signedChangeAmount !== 0) {
    return applySign(rate, signedChangeAmount < 0 ? -1 : 1);
  }

  return applySign(rate, resolveNaverDirectionSign(compareToPreviousPrice));
}

function resolveCapturedTimestampMs(value: number | string | null | undefined) {
  if (typeof value === 'number' && Number.isFinite(value)) {
    return value;
  }

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

  return null;
}

function extractKoreaHour(timestampMs: number) {
  const formatted = new Intl.DateTimeFormat('en-GB', {
    timeZone: KOREA_TIMEZONE,
    hour: '2-digit',
    hour12: false,
  }).format(new Date(timestampMs));
  const hour = Number(formatted);
  return Number.isFinite(hour) ? hour : null;
}

function isKoreaMorningResetWindow(timestampMs: number | null) {
  if (!Number.isFinite(timestampMs)) {
    return false;
  }

  const koreaHour = extractKoreaHour(timestampMs as number);
  return koreaHour !== null && koreaHour >= KOREA_PREOPEN_RESET_HOUR && koreaHour < KOREA_REGULAR_OPEN_HOUR;
}

function getAlertTypeLabel(value: StockAlertType) {
  return STOCK_ALERT_LABEL_MAP.get(value) ?? value;
}

function average(values: number[]) {
  if (!values.length) {
    return null;
  }

  const sum = values.reduce((acc, value) => acc + value, 0);
  return sum / values.length;
}

export function resolveVolumeRate5dFromHistory(
  currentVolume: number | null | undefined,
  historicalVolumes: Array<number | null | undefined>,
) {
  const normalizedCurrentVolume = isFiniteNumber(currentVolume) && currentVolume >= 0 ? currentVolume : null;
  const normalizedVolumes = historicalVolumes.filter((value): value is number => isFiniteNumber(value) && value >= 0);

  if (normalizedVolumes.length < 2) {
    return null;
  }

  const latestVolume = normalizedCurrentVolume ?? normalizedVolumes[normalizedVolumes.length - 1] ?? null;
  const previousFiveAverage = average(normalizedVolumes.slice(-6, -1));

  if (latestVolume === null || previousFiveAverage === null || previousFiveAverage <= 0) {
    return null;
  }

  return (latestVolume / previousFiveAverage) * 100;
}

function normalizeNonNegativeVolume(value: unknown) {
  const parsed = parseLooseNumber(value);

  if (!isFiniteNumber(parsed) || parsed < 0) {
    return null;
  }

  return Math.round(parsed);
}

function calculateVolumeIncreasePercent(currentVolume: number | null, previousVolume: number | null) {
  if (!isFiniteNumber(currentVolume) || !isFiniteNumber(previousVolume) || previousVolume <= 0 || currentVolume < previousVolume) {
    return null;
  }

  return ((currentVolume - previousVolume) / previousVolume) * 100;
}

function calculateVolumeAmplificationGrowthPercent(currentIncreasePercent: number | null, recentMaxIncreasePercent: number | null) {
  if (
    !isFiniteNumber(currentIncreasePercent)
    || !isFiniteNumber(recentMaxIncreasePercent)
    || recentMaxIncreasePercent <= 0
    || currentIncreasePercent < recentMaxIncreasePercent
  ) {
    return null;
  }

  return ((currentIncreasePercent - recentMaxIncreasePercent) / recentMaxIncreasePercent) * 100;
}

function resolveComparableVolumeBaseline(item: StockAlertItem, previousSnapshot: StockAlertVolumeSnapshot | null) {
  const snapshotBaseline = previousSnapshot?.currentVolume ?? null;

  if (snapshotBaseline !== null) {
    return snapshotBaseline;
  }

  return null;
}

function normalizeStockAlertVolumeSnapshotRow(row: StockAlertVolumeSnapshotRow): StockAlertVolumeSnapshot {
  return {
    stockCode: normalizeStockCode(row.stock_code),
    stockName: String(row.stock_name ?? '').trim() || normalizeStockCode(row.stock_code),
    previousVolume: normalizeNonNegativeVolume(row.previous_volume),
    currentVolume: normalizeNonNegativeVolume(row.current_volume),
    volumeIncreasePercent: parseLooseNumber(row.volume_increase_percent),
    currentPrice: parseLooseNumber(row.current_price),
    changeRate: parseLooseNumber(row.change_rate),
    quotedAt: row.quoted_at ? normalizeTimestamp(row.quoted_at) : null,
    createdAt: normalizeTimestamp(row.created_at),
    updatedAt: normalizeTimestamp(row.updated_at),
  };
}

function normalizeStockAlertVolumeHistoryRow(row: StockAlertVolumeHistoryRow): StockAlertVolumeHistory {
  return {
    id: String(row.id ?? '').trim(),
    stockCode: normalizeStockCode(row.stock_code),
    stockName: String(row.stock_name ?? '').trim() || normalizeStockCode(row.stock_code),
    baselineVolume: normalizeNonNegativeVolume(row.baseline_volume),
    currentVolume: normalizeNonNegativeVolume(row.current_volume),
    volumeIncreasePercent: parseLooseNumber(row.volume_increase_percent),
    currentPrice: parseLooseNumber(row.current_price),
    changeRate: parseLooseNumber(row.change_rate),
    quotedAt: row.quoted_at ? normalizeTimestamp(row.quoted_at) : null,
    createdAt: normalizeTimestamp(row.created_at),
  };
}

function buildStockAlertVolumeSnapshotRecord(
  item: StockAlertItem,
  currentVolume: number | null,
  previousSnapshot: StockAlertVolumeSnapshot | null,
) {
  const now = new Date().toISOString();
  const normalizedCurrentVolume = normalizeNonNegativeVolume(currentVolume);
  const previousCurrentVolume = previousSnapshot?.currentVolume ?? null;
  const shouldResetBaseline =
    normalizedCurrentVolume !== null &&
    previousCurrentVolume !== null &&
    normalizedCurrentVolume < previousCurrentVolume;
  const comparisonBaseline = shouldResetBaseline ? normalizedCurrentVolume : previousCurrentVolume;
  const volumeIncreasePercent = calculateVolumeIncreasePercent(normalizedCurrentVolume, comparisonBaseline);
  const nextPreviousVolume =
    shouldResetBaseline
      ? normalizedCurrentVolume
      : previousCurrentVolume !== null
        ? previousCurrentVolume
        : normalizedCurrentVolume;

  return {
    stock_code: item.stockCode,
    stock_name: item.stockName,
    previous_volume: nextPreviousVolume,
    current_volume: normalizedCurrentVolume,
    volume_increase_percent: volumeIncreasePercent,
    current_price: item.currentPrice,
    change_rate: item.changeRate,
    quoted_at: item.quotedAt,
    created_at: previousSnapshot?.createdAt ?? now,
    updated_at: now,
  } satisfies Omit<StockAlertVolumeSnapshotRow, 'quoted_at'> & { quoted_at: string | null };
}

function buildStockAlertVolumeHistoryRecord(
  item: StockAlertItem,
  baselineVolume: number | null,
  currentVolume: number | null,
) {
  const now = new Date().toISOString();
  const normalizedBaselineVolume = normalizeNonNegativeVolume(baselineVolume);
  const normalizedCurrentVolume = normalizeNonNegativeVolume(currentVolume);
  const quotedAt = item.quotedAt ?? now;

  return {
    id: `${item.stockCode}:${quotedAt}`,
    stock_code: item.stockCode,
    stock_name: item.stockName,
    baseline_volume: normalizedBaselineVolume,
    current_volume: normalizedCurrentVolume,
    volume_increase_percent: calculateVolumeIncreasePercent(normalizedCurrentVolume, normalizedBaselineVolume),
    current_price: item.currentPrice,
    change_rate: item.changeRate,
    quoted_at: item.quotedAt,
    created_at: now,
  } satisfies Omit<StockAlertVolumeHistoryRow, 'quoted_at'> & { quoted_at: string | null };
}

function buildStockSymbols(stockCode: string) {
  const normalizedCode = normalizeStockCode(stockCode);

  if (!normalizedCode) {
    return [];
  }

  return [`${normalizedCode}.KS`, `${normalizedCode}.KQ`];
}

function extractStockCodeFromSymbol(symbol: string) {
  const match = symbol.trim().match(/^(\d{6})\.(?:KS|KQ)$/i);
  return match ? match[1] : '';
}

function resolveMarketLabel(quote: YahooSearchQuote) {
  const symbol = quote.symbol?.trim().toUpperCase() ?? '';

  if (symbol.endsWith('.KS')) {
    return 'KOSPI';
  }

  if (symbol.endsWith('.KQ')) {
    return 'KOSDAQ';
  }

  const exchange = quote.exchDisp?.trim() || quote.exchange?.trim() || quote.typeDisp?.trim();

  if (!exchange) {
    return '기타';
  }

  if (/KOSDAQ/i.test(exchange)) {
    return 'KOSDAQ';
  }

  if (/KOSPI|KOSE/i.test(exchange)) {
    return 'KOSPI';
  }

  return exchange;
}

async function ensureStockAlertTable() {
  const exists = await db.schema.hasTable(STOCK_ALERT_TABLE);

  if (exists) {
    return;
  }

  await db.schema.createTable(STOCK_ALERT_TABLE, (table) => {
    table.text('id').primary();
    table.text('stock_code').notNullable();
    table.text('stock_name').notNullable();
    table.text('alert_type').notNullable();
    table.timestamp('created_at', { useTz: true }).notNullable();
    table.timestamp('updated_at', { useTz: true }).notNullable();
  });
}

async function ensureStockAlertVolumeSnapshotTable() {
  const exists = await db.schema.hasTable(STOCK_ALERT_VOLUME_SNAPSHOT_TABLE);

  if (exists) {
    return;
  }

  await db.schema.createTable(STOCK_ALERT_VOLUME_SNAPSHOT_TABLE, (table) => {
    table.text('stock_code').primary();
    table.text('stock_name').notNullable();
    table.bigInteger('previous_volume').nullable();
    table.bigInteger('current_volume').nullable();
    table.decimal('volume_increase_percent', 10, 2).nullable();
    table.decimal('current_price', 14, 2).nullable();
    table.decimal('change_rate', 10, 4).nullable();
    table.timestamp('quoted_at', { useTz: true }).nullable();
    table.timestamp('created_at', { useTz: true }).notNullable();
    table.timestamp('updated_at', { useTz: true }).notNullable();
  });
}

async function ensureStockAlertVolumeHistoryTable() {
  const exists = await db.schema.hasTable(STOCK_ALERT_VOLUME_HISTORY_TABLE);

  if (exists) {
    return;
  }

  await db.schema.createTable(STOCK_ALERT_VOLUME_HISTORY_TABLE, (table) => {
    table.text('id').primary();
    table.text('stock_code').notNullable();
    table.text('stock_name').notNullable();
    table.bigInteger('baseline_volume').nullable();
    table.bigInteger('current_volume').nullable();
    table.decimal('volume_increase_percent', 10, 2).nullable();
    table.decimal('current_price', 14, 2).nullable();
    table.decimal('change_rate', 10, 4).nullable();
    table.timestamp('quoted_at', { useTz: true }).nullable();
    table.timestamp('created_at', { useTz: true }).notNullable();
  });
}

async function fetchJson<T>(url: URL, init?: RequestInit) {
  const response = await fetch(url, {
    ...init,
    headers: {
      accept: 'application/json',
      'user-agent': 'ai-code-app/stock-alert',
      ...(init?.headers ?? {}),
    },
  });

  if (!response.ok) {
    throw new Error(`외부 시세 응답을 불러오지 못했습니다. (${response.status})`);
  }

  return response.json() as Promise<T>;
}

async function fetchText(url: string, init?: RequestInit) {
  const response = await fetch(url, {
    ...init,
    headers: {
      accept: 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8',
      'user-agent': 'ai-code-app/stock-alert',
      ...(init?.headers ?? {}),
    },
  });

  if (!response.ok) {
    throw new Error(`외부 종목 검색 응답을 불러오지 못했습니다. (${response.status})`);
  }

  return response.arrayBuffer();
}

function decodeEucKr(value: ArrayBuffer) {
  return new TextDecoder('euc-kr').decode(value);
}

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

function stripHtmlTags(value: string) {
  return decodeHtmlEntities(value.replace(/<[^>]+>/g, ' ').replace(/\s+/g, ' ').trim());
}

function normalizeSearchKeyword(value: string) {
  return value.trim().replace(/\s+/g, '').toLowerCase();
}

function normalizeMarketLabel(value: string) {
  const trimmedValue = value.trim();

  if (/코스닥/i.test(trimmedValue)) {
    return 'KOSDAQ';
  }

  if (/유가증권|코스피|유가/i.test(trimmedValue)) {
    return 'KOSPI';
  }

  return trimmedValue || '기타';
}

function parseKrxListedStocks(html: string) {
  const rowMatches = html.match(/<tr[\s\S]*?<\/tr>/gi) ?? [];
  const items: KrxListedStock[] = [];

  rowMatches.forEach((rowHtml) => {
    const cellMatches = rowHtml.match(/<td[\s\S]*?<\/td>/gi) ?? [];

    if (cellMatches.length < 3) {
      return;
    }

    const stockName = stripHtmlTags(cellMatches[0] ?? '');
    const market = normalizeMarketLabel(stripHtmlTags(cellMatches[1] ?? ''));
    const stockCode = normalizeStockCode(stripHtmlTags(cellMatches[2] ?? ''));

    if (!stockCode || !stockName) {
      return;
    }

    items.push({
      stockCode,
      stockName,
      market,
    });
  });

  return items;
}

async function findKrxListedStockByCode(stockCode: string) {
  const normalizedCode = normalizeStockCode(stockCode);

  if (!normalizedCode) {
    return null;
  }

  const items = await fetchKrxListedStocks();
  return items.find((item) => item.stockCode === normalizedCode) ?? null;
}

async function fetchKrxListedStocks() {
  if (cachedKrxListedStocks && cachedKrxListedStocks.expiresAt > Date.now()) {
    return cachedKrxListedStocks.items;
  }

  const buffer = await fetchText(KRX_CORP_LIST_URL);
  const decodedHtml = decodeEucKr(buffer);
  const items = parseKrxListedStocks(decodedHtml);

  cachedKrxListedStocks = {
    expiresAt: Date.now() + KRX_CORP_LIST_CACHE_TTL_MS,
    items,
  };

  return items;
}

async function searchKrxListedStocks(query: string, limit = 20) {
  const normalizedKeyword = normalizeSearchKeyword(query);

  if (!normalizedKeyword) {
    return [];
  }

  const items = await fetchKrxListedStocks();

  const matchedItems = items.filter((item) => {
    const normalizedCode = item.stockCode.toLowerCase();
    const normalizedName = normalizeSearchKeyword(item.stockName);
    return normalizedCode.includes(normalizedKeyword) || normalizedName.includes(normalizedKeyword);
  });

  matchedItems.sort((left, right) => {
    const trimmedQuery = query.trim();
    const leftExactCode = left.stockCode === trimmedQuery ? 1 : 0;
    const rightExactCode = right.stockCode === trimmedQuery ? 1 : 0;

    if (leftExactCode !== rightExactCode) {
      return rightExactCode - leftExactCode;
    }

    const leftExactName = normalizeSearchKeyword(left.stockName) === normalizedKeyword ? 1 : 0;
    const rightExactName = normalizeSearchKeyword(right.stockName) === normalizedKeyword ? 1 : 0;

    if (leftExactName !== rightExactName) {
      return rightExactName - leftExactName;
    }

    const leftStartsWith = normalizeSearchKeyword(left.stockName).startsWith(normalizedKeyword) ? 1 : 0;
    const rightStartsWith = normalizeSearchKeyword(right.stockName).startsWith(normalizedKeyword) ? 1 : 0;

    if (leftStartsWith !== rightStartsWith) {
      return rightStartsWith - leftStartsWith;
    }

    const leftLengthGap = Math.abs(left.stockName.length - trimmedQuery.length);
    const rightLengthGap = Math.abs(right.stockName.length - trimmedQuery.length);

    if (leftLengthGap !== rightLengthGap) {
      return leftLengthGap - rightLengthGap;
    }

    return left.stockName.localeCompare(right.stockName, 'ko-KR');
  });

  return matchedItems.slice(0, Math.max(1, Math.min(50, limit)));
}

async function resolveStockIdentity(input: { stockCode?: string; stockName?: string }) {
  const codeFromInput = normalizeStockCode(input.stockCode ?? '');
  const trimmedName = input.stockName?.trim() ?? '';

  if (codeFromInput) {
    const krxMatch = await findKrxListedStockByCode(codeFromInput);

    if (krxMatch) {
      return {
        stockCode: codeFromInput,
        stockName: krxMatch.stockName,
      };
    }

    if (trimmedName) {
      return {
        stockCode: codeFromInput,
        stockName: trimmedName,
      };
    }

    const quotes = await fetchQuotesByCodes([codeFromInput]);
    const quote = quotes.get(codeFromInput);

    if (quote) {
      return {
        stockCode: codeFromInput,
        stockName: quote.stockName ?? codeFromInput,
      };
    }

    return {
      stockCode: codeFromInput,
      stockName: codeFromInput,
    };
  }

  if (!trimmedName) {
    throw new Error('종목명을 입력해 주세요.');
  }

  const krxMatches = await searchKrxListedStocks(trimmedName, 10);
  const exactMatch =
    krxMatches.find((item) => normalizeSearchKeyword(item.stockName) === normalizeSearchKeyword(trimmedName)) ?? krxMatches[0] ?? null;

  if (exactMatch) {
    return {
      stockCode: exactMatch.stockCode,
      stockName: exactMatch.stockName,
    };
  }

  const searchUrl = new URL('https://query2.finance.yahoo.com/v1/finance/search');
  searchUrl.searchParams.set('q', trimmedName);
  searchUrl.searchParams.set('quotesCount', '10');
  searchUrl.searchParams.set('newsCount', '0');
  searchUrl.searchParams.set('lang', 'ko-KR');
  searchUrl.searchParams.set('region', 'KR');

  const payload = await fetchJson<{ quotes?: YahooSearchQuote[] }>(searchUrl);
  const matchedQuote =
    payload.quotes?.find((quote) => typeof quote.symbol === 'string' && extractStockCodeFromSymbol(quote.symbol).length === 6) ??
    null;

  if (!matchedQuote?.symbol) {
    throw new Error(`종목명 "${trimmedName}"에 해당하는 종목코드를 찾지 못했습니다.`);
  }

  const resolvedCode = extractStockCodeFromSymbol(matchedQuote.symbol);

  if (!resolvedCode) {
    throw new Error(`종목명 "${trimmedName}"에 해당하는 종목코드를 찾지 못했습니다.`);
  }

  return {
    stockCode: resolvedCode,
    stockName: matchedQuote.shortname?.trim() || matchedQuote.longname?.trim() || trimmedName,
  };
}

async function searchYahooStocks(query: string, quotesCount = 20) {
  const trimmedQuery = query.trim();

  if (!trimmedQuery) {
    return [];
  }

  const searchUrl = new URL('https://query2.finance.yahoo.com/v1/finance/search');
  searchUrl.searchParams.set('q', trimmedQuery);
  searchUrl.searchParams.set('quotesCount', String(Math.max(1, Math.min(50, quotesCount))));
  searchUrl.searchParams.set('newsCount', '0');
  searchUrl.searchParams.set('lang', 'ko-KR');
  searchUrl.searchParams.set('region', 'KR');

  try {
    const payload = await fetchJson<{ quotes?: YahooSearchQuote[] }>(searchUrl);
    return payload.quotes ?? [];
  } catch (error) {
    // Yahoo search rejects some non-code Korean queries with HTTP 400.
    if (/[^\x00-\x7F]/.test(trimmedQuery)) {
      return [];
    }

    throw error;
  }
}

export async function searchStockAlertCandidates(query: string, limit = 20) {
  const normalizedLimit = Math.max(1, Math.min(50, limit));
  const [krxItems, quotes] = await Promise.all([
    searchKrxListedStocks(query, normalizedLimit),
    searchYahooStocks(query, normalizedLimit * 2),
  ]);
  const seenCodes = new Set<string>();
  const items: StockAlertSearchItem[] = [...krxItems];
  const krxByCode = new Map(krxItems.map((item) => [item.stockCode, item]));

  krxItems.forEach((item) => {
    seenCodes.add(item.stockCode);
  });

  quotes.forEach((quote) => {
    if (!quote.symbol) {
      return;
    }

    const stockCode = extractStockCodeFromSymbol(quote.symbol);

    if (!stockCode || seenCodes.has(stockCode)) {
      return;
    }

    const stockName = quote.shortname?.trim() || quote.longname?.trim() || stockCode;
    const krxMatch = krxByCode.get(stockCode);
    seenCodes.add(stockCode);
    items.push({
      stockCode,
      stockName: krxMatch?.stockName ?? stockName,
      market: krxMatch?.market ?? resolveMarketLabel(quote),
    });
  });

  return items.slice(0, normalizedLimit);
}

async function ensureNoDuplicateStockCode(stockCode: string, currentId?: string) {
  const normalizedCode = normalizeStockCode(stockCode);

  if (!normalizedCode) {
    throw new Error('종목코드를 확인할 수 없습니다.');
  }

  const existing = (await db<StockAlertRow>(STOCK_ALERT_TABLE)
    .select('id')
    .where({ stock_code: normalizedCode })
    .modify((query) => {
      if (currentId?.trim()) {
        query.whereNot('id', currentId.trim());
      }
    })
    .first()) as Pick<StockAlertRow, 'id'> | undefined;

  if (existing?.id) {
    throw new Error('이미 추가된 종목입니다.');
  }
}

export function resolveLatestQuoteFromMeta(meta: {
  regularMarketPrice?: number;
  regularMarketTime?: number;
  regularMarketChangePercent?: number;
  chartPreviousClose?: number;
  previousClose?: number;
  preMarketPrice?: number;
  preMarketTime?: number;
  preMarketChangePercent?: number;
  postMarketPrice?: number;
  postMarketTime?: number;
  postMarketChangePercent?: number;
  marketState?: string;
  shortName?: string;
  longName?: string;
}) {
  const marketState = String(meta.marketState ?? '')
    .trim()
    .toUpperCase();
  const shouldPreferPremarket = ['PRE', 'PREPRE'].includes(marketState);
  const shouldPreferPostmarket = ['POST', 'POSTPOST', 'CLOSED'].includes(marketState);
  const preferredCandidate = shouldPreferPremarket
    ? {
        price: meta.preMarketPrice,
        changeRate: meta.preMarketChangePercent,
        time: meta.preMarketTime,
      }
    : shouldPreferPostmarket
      ? {
          price: meta.postMarketPrice,
          changeRate: meta.postMarketChangePercent,
          time: meta.postMarketTime,
        }
      : {
          price: meta.regularMarketPrice,
          changeRate: meta.regularMarketChangePercent,
          time: meta.regularMarketTime,
        };
  const quoteCandidates: Array<{ price?: number; changeRate?: number; time?: number }> = [
    {
      price: meta.regularMarketPrice,
      changeRate: meta.regularMarketChangePercent,
      time: meta.regularMarketTime,
    },
    {
      price: meta.preMarketPrice,
      changeRate: meta.preMarketChangePercent,
      time: meta.preMarketTime,
    },
    {
      price: meta.postMarketPrice,
      changeRate: meta.postMarketChangePercent,
      time: meta.postMarketTime,
    },
  ];
  const latestCandidate = quoteCandidates
    .flatMap((item) =>
      isFiniteNumber(item.price) && isFiniteNumber(item.time)
        ? [
            {
              price: item.price,
              changeRate: item.changeRate,
              time: item.time,
            },
          ]
        : [],
    )
    .sort((left, right) => right.time - left.time)[0] ?? null;
  const resolvedCandidate =
    isFiniteNumber(preferredCandidate.price) && isFiniteNumber(preferredCandidate.time) ? preferredCandidate : latestCandidate;
  const currentPrice = isFiniteNumber(resolvedCandidate?.price)
    ? resolvedCandidate.price
    : isFiniteNumber(meta.regularMarketPrice)
      ? meta.regularMarketPrice
      : null;
  const quotedAt = isFiniteNumber(resolvedCandidate?.time)
    ? new Date(resolvedCandidate.time * 1000).toISOString()
    : isFiniteNumber(meta.regularMarketTime)
      ? new Date(meta.regularMarketTime * 1000).toISOString()
      : null;
  const previousClose =
    typeof meta.chartPreviousClose === 'number'
      ? meta.chartPreviousClose
      : typeof meta.previousClose === 'number'
        ? meta.previousClose
        : null;
  const changeRate = isFiniteNumber(resolvedCandidate?.changeRate)
    ? resolvedCandidate.changeRate
    : currentPrice !== null && previousClose !== null && previousClose !== 0
      ? ((currentPrice - previousClose) / previousClose) * 100
      : null;

  return {
    currentPrice,
    changeRate,
    volumeRate5d: null,
    currentVolume: null,
    quotedAt,
    stockName: meta.shortName?.trim() || meta.longName?.trim() || null,
  } satisfies StockAlertQuote;
}

export function resolveLatestQuoteFromNaverRealtime(data: NaverRealtimeRow, capturedAt: number | string | null | undefined) {
  const overMarketInfo = data.nxtOverMarketPriceInfo;
  const overPrice = parseLooseNumber(overMarketInfo?.overPrice);
  const overChangeRate = resolveSignedNaverChangeRate(
    parseLooseNumber(overMarketInfo?.fluctuationsRatio),
    overMarketInfo?.compareToPreviousClosePrice,
    overMarketInfo?.compareToPreviousPrice,
  );
  const overQuotedAt = overMarketInfo?.localTradedAt?.trim() || null;
  const hasExtendedSessionQuote = overPrice !== null && overQuotedAt;
  const baseQuotedAt =
    typeof capturedAt === 'number' && Number.isFinite(capturedAt)
      ? new Date(capturedAt).toISOString()
      : typeof capturedAt === 'string' && capturedAt.trim()
        ? new Date(capturedAt).toISOString()
        : null;
  const capturedTimestampMs = resolveCapturedTimestampMs(capturedAt);
  const basePrice = isFiniteNumber(data.nv) ? data.nv : null;
  const baseChangeRate = applySign(
    isFiniteNumber(data.cr) ? data.cr : null,
    resolveNaverDirectionSign(data.rf?.trim() ? { code: data.rf } : undefined),
  );
  const previousClosePrice = isFiniteNumber(data.pcv) ? data.pcv : null;
  const currentVolume =
    normalizeNonNegativeVolume(overMarketInfo?.accumulatedTradingVolume) ??
    normalizeNonNegativeVolume(data.accumulatedTradingVolume) ??
    normalizeNonNegativeVolume(data.aq);
  const shouldResetToPreviousClose =
    !hasExtendedSessionQuote &&
    isKoreaMorningResetWindow(capturedTimestampMs) &&
    previousClosePrice !== null;

  return {
    currentPrice: hasExtendedSessionQuote ? overPrice : shouldResetToPreviousClose ? previousClosePrice : basePrice,
    changeRate: hasExtendedSessionQuote ? overChangeRate : shouldResetToPreviousClose ? 0 : baseChangeRate,
    volumeRate5d: null,
    currentVolume,
    quotedAt: hasExtendedSessionQuote ? overQuotedAt : baseQuotedAt,
    stockName: data.nm?.trim() || null,
  } satisfies StockAlertQuote;
}

function choosePreferredQuote(primary: StockAlertQuote | null, fallback: StockAlertQuote | null) {
  if (primary?.currentPrice === null && fallback?.currentPrice === null) {
    return primary ?? fallback ?? null;
  }

  if (!primary) {
    return fallback;
  }

  if (!fallback) {
    return primary;
  }

  const primaryQuotedAt = primary.quotedAt ? Date.parse(primary.quotedAt) : Number.NaN;
  const fallbackQuotedAt = fallback.quotedAt ? Date.parse(fallback.quotedAt) : Number.NaN;

  if (Number.isFinite(primaryQuotedAt) && Number.isFinite(fallbackQuotedAt) && fallbackQuotedAt > primaryQuotedAt) {
    return {
      ...fallback,
      stockName: fallback.stockName ?? primary.stockName,
      currentVolume: fallback.currentVolume ?? primary.currentVolume,
    } satisfies StockAlertQuote;
  }

  return {
    ...primary,
    stockName: primary.stockName ?? fallback.stockName,
    currentPrice: primary.currentPrice ?? fallback.currentPrice,
    changeRate: primary.changeRate ?? fallback.changeRate,
    volumeRate5d: primary.volumeRate5d ?? fallback.volumeRate5d,
    currentVolume: primary.currentVolume ?? fallback.currentVolume,
    quotedAt: primary.quotedAt ?? fallback.quotedAt,
  } satisfies StockAlertQuote;
}

async function fetchNaverRealtimeQuoteByCode(stockCode: string) {
  const quoteUrl = new URL('https://polling.finance.naver.com/api/realtime');
  quoteUrl.searchParams.set('query', `SERVICE_ITEM:${stockCode}`);

  const payload = await fetchJson<{
    result?: {
      time?: number;
      areas?: Array<{
        name?: string;
        datas?: NaverRealtimeRow[];
      }>;
    };
  }>(quoteUrl, {
    headers: {
      accept: '*/*',
    },
  });
  const data = payload.result?.areas?.find((area) => area.name === 'SERVICE_ITEM')?.datas?.[0];

  if (!data) {
    return null;
  }

  return resolveLatestQuoteFromNaverRealtime(data, payload.result?.time);
}

async function fetchQuoteBySymbol(symbol: string) {
  const quoteUrl = new URL(`https://query1.finance.yahoo.com/v8/finance/chart/${symbol}`);
  quoteUrl.searchParams.set('range', '3mo');
  quoteUrl.searchParams.set('interval', '1d');
  quoteUrl.searchParams.set('includePrePost', 'true');
  quoteUrl.searchParams.set('lang', 'ko-KR');
  quoteUrl.searchParams.set('region', 'KR');

  const payload = await fetchJson<{
    chart?: {
      result?: Array<{
        timestamp?: number[];
        meta?: {
          regularMarketPrice?: number;
          regularMarketTime?: number;
          regularMarketChangePercent?: number;
          previousClose?: number;
          preMarketPrice?: number;
          preMarketTime?: number;
          preMarketChangePercent?: number;
          postMarketPrice?: number;
          postMarketTime?: number;
          postMarketChangePercent?: number;
          marketState?: string;
          shortName?: string;
          longName?: string;
          chartPreviousClose?: number;
        };
        indicators?: {
          quote?: Array<{
            volume?: Array<number | null>;
            close?: Array<number | null>;
          }>;
        };
      }>;
    };
  }>(quoteUrl);
  const result = payload.chart?.result?.[0];
  const meta = result?.meta;

  if (!meta) {
    return null;
  }

  const quote = resolveLatestQuoteFromMeta(meta);
  const dailyVolumes = result?.indicators?.quote?.[0]?.volume ?? [];

  return {
    ...quote,
    currentVolume: dailyVolumes[dailyVolumes.length - 1] ?? null,
    volumeRate5d: resolveVolumeRate5dFromHistory(dailyVolumes[dailyVolumes.length - 1] ?? null, dailyVolumes),
  } satisfies StockAlertQuote;
}

export async function fetchQuotesByCodes(stockCodes: string[]) {
  const normalizedCodes = Array.from(
    new Set(
      stockCodes
        .map((value) => normalizeStockCode(value))
        .filter(Boolean),
    ),
  );
  const quoteMap = new Map<string, StockAlertQuote>();

  if (!normalizedCodes.length) {
    return quoteMap;
  }

  await Promise.all(
    normalizedCodes.map(async (stockCode) => {
      let preferredQuote: StockAlertQuote | null = null;

      try {
        preferredQuote = await fetchNaverRealtimeQuoteByCode(stockCode);
      } catch {
        // Ignore realtime provider failures and fall back to the next source.
      }

      const symbols = buildStockSymbols(stockCode);

      for (const symbol of symbols) {
        try {
          const yahooQuote = await fetchQuoteBySymbol(symbol);
          preferredQuote = choosePreferredQuote(preferredQuote, yahooQuote);

          if (
            preferredQuote &&
            (preferredQuote.currentPrice !== null || preferredQuote.stockName) &&
            preferredQuote.volumeRate5d !== null
          ) {
            quoteMap.set(stockCode, preferredQuote);
            return;
          }
        } catch {
          // Ignore per-symbol failures and try the next market suffix.
        }
      }

      if (preferredQuote && (preferredQuote.currentPrice !== null || preferredQuote.stockName || preferredQuote.volumeRate5d !== null)) {
        quoteMap.set(stockCode, preferredQuote);
      }
    }),
  );

  return quoteMap;
}

export async function listStockAlerts(filterType: StockAlertFilterType = 'all') {
  await ensureStockAlertTable();

  let rows: StockAlertRow[] = [];

  if (filterType === 'all') {
    rows = (await db<StockAlertRow>(STOCK_ALERT_TABLE).select('*').orderBy('updated_at', 'desc')) as StockAlertRow[];
  } else {
    const matchedCodes = (
      (await db<StockAlertRow>(STOCK_ALERT_TABLE)
        .select('stock_code')
        .where({ alert_type: normalizeAlertType(filterType) })
        .groupBy('stock_code')) as Array<Pick<StockAlertRow, 'stock_code'>>
    ).map((row) => row.stock_code);

    if (!matchedCodes.length) {
      return [];
    }

    rows = (await db<StockAlertRow>(STOCK_ALERT_TABLE)
      .select('*')
      .whereIn('stock_code', matchedCodes)
      .orderBy('updated_at', 'desc')) as StockAlertRow[];
  }

  const quotes = await fetchQuotesByCodes(rows.map((row) => row.stock_code));
  const krxItems = await fetchKrxListedStocks();
  const krxByCode = new Map(krxItems.map((item) => [item.stockCode, item]));
  const groupedItems = new Map<string, StockAlertItem>();

  rows.forEach((row) => {
    const alertType = normalizeAlertType(row.alert_type);
    const quote = quotes.get(row.stock_code);
    const krxMatch = krxByCode.get(row.stock_code);
    const existing = groupedItems.get(row.stock_code);

    if (existing) {
      if (!existing.alertTypes.includes(alertType)) {
        existing.alertTypes.push(alertType);
        existing.alertTypeLabels.push(getAlertTypeLabel(alertType));
      }

      const updatedAtTime = Date.parse(normalizeTimestamp(row.updated_at));
      const existingUpdatedAtTime = Date.parse(existing.updatedAt);

      if (Number.isFinite(updatedAtTime) && (!Number.isFinite(existingUpdatedAtTime) || updatedAtTime > existingUpdatedAtTime)) {
        existing.updatedAt = normalizeTimestamp(row.updated_at);
      }

      return;
    }

    groupedItems.set(row.stock_code, {
      id: row.stock_code,
      stockCode: row.stock_code,
      stockName: row.stock_name || krxMatch?.stockName || quote?.stockName || row.stock_code,
      alertTypes: [alertType],
      alertTypeLabels: [getAlertTypeLabel(alertType)],
      currentPrice: quote?.currentPrice ?? null,
      changeRate: quote?.changeRate ?? null,
      volumeRate5d: quote?.volumeRate5d ?? null,
      currentVolume: quote?.currentVolume ?? null,
      quotedAt: quote?.quotedAt ?? null,
      createdAt: normalizeTimestamp(row.created_at),
      updatedAt: normalizeTimestamp(row.updated_at),
    });
  });

  return Array.from(groupedItems.values()).sort((left, right) => Date.parse(right.updatedAt) - Date.parse(left.updatedAt));
}

export async function createStockAlert(input: {
  stockCode?: string;
  stockName?: string;
  alertTypes: string[];
}) {
  await ensureStockAlertTable();

  const identity = await resolveStockIdentity(input);
  const alertTypes = Array.from(new Set(input.alertTypes.map((value) => normalizeAlertType(value))));

  if (!alertTypes.length) {
    throw new Error('알림유형을 하나 이상 선택해 주세요.');
  }

  const existingRows = (await db<StockAlertRow>(STOCK_ALERT_TABLE)
    .select('*')
    .where({ stock_code: identity.stockCode })) as StockAlertRow[];

  if (existingRows.length) {
    const mergedAlertTypes = Array.from(
      new Set([...existingRows.map((row) => normalizeAlertType(row.alert_type)), ...alertTypes]),
    );

    return updateStockAlert(identity.stockCode, {
      stockCode: identity.stockCode,
      stockName: identity.stockName,
      alertTypes: mergedAlertTypes,
    });
  }

  const now = new Date().toISOString();

  await db(STOCK_ALERT_TABLE).insert(
    alertTypes.map((alertType) => ({
      id: `stock-alert-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`,
      stock_code: identity.stockCode,
      stock_name: identity.stockName,
      alert_type: alertType,
      created_at: now,
      updated_at: now,
    })),
  );

  const [created] = await listStockAlerts('all').then((rows) => rows.filter((row) => row.stockCode === identity.stockCode));

  if (!created) {
    throw new Error('저장된 종목 알림을 다시 불러오지 못했습니다.');
  }

  return created;
}

export async function updateStockAlert(
  id: string,
  input: {
    stockCode?: string;
    stockName?: string;
    alertTypes: string[];
  },
) {
  await ensureStockAlertTable();

  const currentRows = (await db<StockAlertRow>(STOCK_ALERT_TABLE)
    .select('*')
    .where((query) => {
      query.where({ stock_code: normalizeStockCode(id) || '__never__' }).orWhere({ id });
    })) as StockAlertRow[];

  if (!currentRows.length) {
    throw new Error('수정할 종목 알림을 찾을 수 없습니다.');
  }

  const existing = currentRows[0];
  const identity = await resolveStockIdentity({
    stockCode: input.stockCode ?? existing?.stock_code,
    stockName: input.stockName ?? existing?.stock_name,
  });
  const updatedAt = new Date().toISOString();
  const alertTypes = Array.from(new Set(input.alertTypes.map((value) => normalizeAlertType(value))));

  if (!alertTypes.length) {
    throw new Error('알림유형을 하나 이상 선택해 주세요.');
  }

  if (identity.stockCode !== existing.stock_code) {
    await ensureNoDuplicateStockCode(identity.stockCode);
  }

  await db.transaction(async (trx) => {
    await trx(STOCK_ALERT_TABLE).where({ stock_code: existing.stock_code }).delete();
    await trx(STOCK_ALERT_TABLE).insert(
      alertTypes.map((alertType) => ({
        id: `stock-alert-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`,
        stock_code: identity.stockCode,
        stock_name: identity.stockName,
        alert_type: alertType,
        created_at: currentRows.find((row) => row.alert_type === alertType)?.created_at ?? updatedAt,
        updated_at: updatedAt,
      })),
    );
  });

  const [updated] = await listStockAlerts('all').then((rows) => rows.filter((row) => row.stockCode === identity.stockCode));

  if (!updated) {
    throw new Error('수정된 종목 알림을 다시 불러오지 못했습니다.');
  }

  return updated;
}

export async function deleteStockAlert(id: string) {
  await ensureStockAlertTable();
  const normalizedCode = normalizeStockCode(id);
  const count = normalizedCode
    ? await db(STOCK_ALERT_TABLE).where({ stock_code: normalizedCode }).delete()
    : await db(STOCK_ALERT_TABLE).where({ id }).delete();

  if (!count) {
    throw new Error('삭제할 종목 알림을 찾을 수 없습니다.');
  }
}

export async function saveStockAlerts(
  items: Array<{
    id?: string;
    stockCode?: string;
    stockName?: string;
    alertTypes: string[];
  }>,
) {
  await ensureStockAlertTable();

  const seenCodes = new Set<string>();

  for (const item of items) {
    const normalizedCode = normalizeStockCode(item.stockCode ?? '');

    if (!normalizedCode) {
      continue;
    }

    if (seenCodes.has(normalizedCode)) {
      throw new Error('동일한 종목코드를 중복 저장할 수 없습니다.');
    }

    if (!item.alertTypes.length) {
      throw new Error('알림유형을 하나 이상 선택해 주세요.');
    }

    seenCodes.add(normalizedCode);
  }

  const savedItems: StockAlertItem[] = [];

  for (const item of items) {
    const trimmedId = item.id?.trim();
    const savedItem = trimmedId
      ? await updateStockAlert(trimmedId, item)
      : await createStockAlert(item);
    savedItems.push(savedItem);
  }

  return savedItems;
}

function formatStockAlertPrice(value: number | null) {
  if (!isFiniteNumber(value)) {
    return '-';
  }

  return `${Math.round(value).toLocaleString('ko-KR')}₩`;
}

function formatStockAlertChangeRate(value: number | null) {
  if (!isFiniteNumber(value)) {
    return '(변동률 확인불가)';
  }

  if (value > 0) {
    return `(+${value.toFixed(2)}% ▲)`;
  }

  if (value < 0) {
    return `(${value.toFixed(2)}% ▼)`;
  }

  return '(0.00% -)';
}

function canBuildCurrentPriceStockAlertLine(item: StockAlertItem) {
  return item.alertTypes.includes('price') && isFiniteNumber(item.currentPrice) && isFiniteNumber(item.changeRate);
}

function canBuildChangeThresholdStockAlertLine(item: StockAlertItem) {
  return isFiniteNumber(item.currentPrice) && isFiniteNumber(item.changeRate);
}

export function buildCurrentPriceStockAlertLines(items: StockAlertItem[]) {
  return items
    .filter(canBuildCurrentPriceStockAlertLine)
    .map((item) => `${item.stockName} ${formatStockAlertPrice(item.currentPrice)} ${formatStockAlertChangeRate(item.changeRate)}`);
}

export function buildChangeRateThresholdStockAlertLines(items: StockAlertItem[], thresholdPercent: number) {
  return items
    .filter((item) => canBuildChangeThresholdStockAlertLine(item) && Math.abs(item.changeRate ?? 0) >= thresholdPercent)
    .sort((left, right) => {
      const changeRateGap = Math.abs((right.changeRate ?? 0)) - Math.abs((left.changeRate ?? 0));

      if (changeRateGap !== 0) {
        return changeRateGap;
      }

      return left.stockName.localeCompare(right.stockName, 'ko-KR');
    })
    .map((item) => `${item.stockName} ${formatStockAlertPrice(item.currentPrice)} ${formatStockAlertChangeRate(item.changeRate)}`);
}

async function listStockAlertVolumeSnapshots() {
  await ensureStockAlertVolumeSnapshotTable();

  const rows = (await db<StockAlertVolumeSnapshotRow>(STOCK_ALERT_VOLUME_SNAPSHOT_TABLE)
    .select('*')
    .orderBy('updated_at', 'desc')) as StockAlertVolumeSnapshotRow[];

  return new Map(
    rows
      .map((row) => normalizeStockAlertVolumeSnapshotRow(row))
      .filter((row) => row.stockCode)
      .map((row) => [row.stockCode, row] as const),
  );
}

async function listRecentStockAlertVolumeHistories(limitPerStock = 5) {
  await ensureStockAlertVolumeHistoryTable();

  const rows = (await db<StockAlertVolumeHistoryRow>(STOCK_ALERT_VOLUME_HISTORY_TABLE)
    .select('*')
    .orderBy('created_at', 'desc')) as StockAlertVolumeHistoryRow[];
  const historyMap = new Map<string, StockAlertVolumeHistory[]>();

  rows.forEach((row) => {
    const normalized = normalizeStockAlertVolumeHistoryRow(row);

    if (!normalized.stockCode) {
      return;
    }

    const items = historyMap.get(normalized.stockCode) ?? [];

    if (items.length >= limitPerStock) {
      return;
    }

    items.push(normalized);
    historyMap.set(normalized.stockCode, items);
  });

  return historyMap;
}

async function upsertStockAlertVolumeSnapshots(
  items: StockAlertItem[],
  previousSnapshots: Map<string, StockAlertVolumeSnapshot>,
) {
  await ensureStockAlertVolumeSnapshotTable();

  if (!items.length) {
    return;
  }

  const records = items.map((item) =>
    buildStockAlertVolumeSnapshotRecord(item, item.currentVolume, previousSnapshots.get(item.stockCode) ?? null),
  );

  await db(STOCK_ALERT_VOLUME_SNAPSHOT_TABLE)
    .insert(records)
    .onConflict('stock_code')
    .merge({
      stock_name: db.ref('excluded.stock_name'),
      previous_volume: db.ref('excluded.previous_volume'),
      current_volume: db.ref('excluded.current_volume'),
      volume_increase_percent: db.ref('excluded.volume_increase_percent'),
      current_price: db.ref('excluded.current_price'),
      change_rate: db.ref('excluded.change_rate'),
      quoted_at: db.ref('excluded.quoted_at'),
      updated_at: db.ref('excluded.updated_at'),
    });
}

async function insertStockAlertVolumeHistories(
  items: StockAlertItem[],
  previousSnapshots: Map<string, StockAlertVolumeSnapshot>,
) {
  await ensureStockAlertVolumeHistoryTable();

  if (!items.length) {
    return;
  }

  const records = items
    .map((item) => {
      const previousSnapshot = previousSnapshots.get(item.stockCode) ?? null;
      const baselineVolume = resolveComparableVolumeBaseline(item, previousSnapshot);
      return buildStockAlertVolumeHistoryRecord(item, baselineVolume, item.currentVolume);
    })
    .filter((record) => record.current_volume !== null);

  if (!records.length) {
    return;
  }

  await db(STOCK_ALERT_VOLUME_HISTORY_TABLE).insert(records).onConflict('id').ignore();
}

export function buildChangeRateAndVolumeSpikeStockAlertCandidates(
  items: StockAlertItem[],
  previousSnapshots: Map<string, StockAlertVolumeSnapshot>,
  recentHistories: Map<string, StockAlertVolumeHistory[]>,
  options: {
    thresholdPercent: number;
    minVolumeIncreasePercent: number;
  },
) {
  return items
    .flatMap((item) => {
      if (!canBuildChangeThresholdStockAlertLine(item)) {
        return [];
      }

      const previousSnapshot = previousSnapshots.get(item.stockCode);
      const previousVolume = resolveComparableVolumeBaseline(item, previousSnapshot ?? null);
      const currentVolume = normalizeNonNegativeVolume(item.currentVolume);
      const volumeIncreasePercent = calculateVolumeIncreasePercent(currentVolume, previousVolume);
      const recentMaxVolumeIncreasePercent = Math.max(
        ...((recentHistories.get(item.stockCode) ?? [])
          .map((history) => history.volumeIncreasePercent)
          .filter((value): value is number => isFiniteNumber(value))),
      );
      const normalizedRecentMaxVolumeIncreasePercent = Number.isFinite(recentMaxVolumeIncreasePercent)
        ? recentMaxVolumeIncreasePercent
        : null;
      const volumeAmplificationGrowthPercent = calculateVolumeAmplificationGrowthPercent(
        volumeIncreasePercent,
        normalizedRecentMaxVolumeIncreasePercent,
      );

      if (
        volumeAmplificationGrowthPercent === null ||
        Math.abs(item.changeRate ?? 0) < options.thresholdPercent ||
        volumeAmplificationGrowthPercent < options.minVolumeIncreasePercent
      ) {
        return [];
      }

      return [
        {
          stockCode: item.stockCode,
          stockName: item.stockName,
          currentPrice: item.currentPrice,
          changeRate: item.changeRate,
          currentVolume,
          previousVolume,
          volumeIncreasePercent,
          recentMaxVolumeIncreasePercent: normalizedRecentMaxVolumeIncreasePercent,
          volumeAmplificationGrowthPercent,
          quotedAt: item.quotedAt,
        } satisfies StockAlertVolumeSpikeCandidate,
      ];
    })
    .sort((left, right) => {
      const changeRateGap = Math.abs((right.changeRate ?? 0)) - Math.abs((left.changeRate ?? 0));

      if (changeRateGap !== 0) {
        return changeRateGap;
      }

      const volumeGap = (right.volumeAmplificationGrowthPercent ?? 0) - (left.volumeAmplificationGrowthPercent ?? 0);

      if (volumeGap !== 0) {
        return volumeGap;
      }

      return left.stockName.localeCompare(right.stockName, 'ko-KR');
    });
}

export function buildChangeRateAndVolumeSpikeStockAlertLines(
  items: StockAlertItem[],
  previousSnapshots: Map<string, StockAlertVolumeSnapshot>,
  recentHistories: Map<string, StockAlertVolumeHistory[]>,
  options: {
    thresholdPercent: number;
    minVolumeIncreasePercent: number;
  },
) {
  return buildChangeRateAndVolumeSpikeStockAlertCandidates(items, previousSnapshots, recentHistories, options).map(
    (item) => `${item.stockName} ${formatStockAlertPrice(item.currentPrice)} ${formatStockAlertChangeRate(item.changeRate)}`,
  );
}

function createSkippedNotificationResult(reason: string) {
  const skippedWebResult = {
    ok: true,
    skipped: true,
    reason,
    sentCount: 0,
    failedCount: 0,
    invalidEndpoints: [],
  };
  const skippedIosResult = {
    ok: true,
    skipped: true,
    reason,
    sentCount: 0,
    failedCount: 0,
    invalidTokens: [],
  };

  return {
    ios: skippedIosResult,
    web: skippedWebResult,
  };
}

export function buildStockAlertNotificationIdentity(options: {
  scheduleId: number;
  serviceKey: string;
  mode: 'price' | 'change-threshold' | 'change-threshold-volume-spike';
}) {
  const modeKey =
    options.mode === 'price'
      ? 'current-price'
      : options.mode === 'change-threshold'
        ? 'change-threshold'
        : 'change-threshold-volume-spike';
  const legacyNotificationKey = `${options.serviceKey}:current-price`;
  const legacyModeNotificationKey = `${options.serviceKey}:${modeKey}`;

  return {
    threadId: `schedule-stock-alert:${options.scheduleId}`,
    notificationKey: `schedule-stock-alert:${options.scheduleId}`,
    notificationScope: `schedule-stock-alert:${options.scheduleId}`,
    notificationAliases: [...new Set([
      options.serviceKey,
      legacyNotificationKey,
      legacyModeNotificationKey,
      options.scheduleId === 2 ? STOCK_ALERT_NOTIFICATION_SCOPE : '',
      options.scheduleId === 2 ? `${STOCK_ALERT_NOTIFICATION_SCOPE}:current-price` : '',
    ].filter(Boolean))],
  };
}

export async function sendManagedStockAlertWebPush(options: {
  scheduleId: number;
  serviceKey: string;
  title: string;
  mode: 'price' | 'change-threshold' | 'change-threshold-volume-spike';
  thresholdPercent?: number;
  minVolumeIncreasePercent?: number;
}) {
  const thresholdPercent = Math.max(0, Number(options.thresholdPercent ?? 5));
  const minVolumeIncreasePercent = Math.max(0, Number(options.minVolumeIncreasePercent ?? 300));
  const items = await listStockAlerts(options.mode === 'price' ? 'price' : 'all');
  const previousSnapshots =
    options.mode === 'change-threshold-volume-spike' ? await listStockAlertVolumeSnapshots() : new Map<string, StockAlertVolumeSnapshot>();
  const recentHistories =
    options.mode === 'change-threshold-volume-spike' ? await listRecentStockAlertVolumeHistories() : new Map<string, StockAlertVolumeHistory[]>();
  const lines =
    options.mode === 'price'
      ? buildCurrentPriceStockAlertLines(items)
      : options.mode === 'change-threshold'
        ? buildChangeRateThresholdStockAlertLines(items, thresholdPercent)
        : buildChangeRateAndVolumeSpikeStockAlertLines(items, previousSnapshots, recentHistories, {
            thresholdPercent,
            minVolumeIncreasePercent,
          });
  const hasRegisteredTargets = options.mode === 'price' ? items.some((item) => item.alertTypes.includes('price')) : items.length > 0;
  const hasComparableVolumeBaseline =
    options.mode !== 'change-threshold-volume-spike'
      ? false
      : items.some((item) => {
          const previousSnapshot = previousSnapshots.get(item.stockCode);
          return resolveComparableVolumeBaseline(item, previousSnapshot ?? null) !== null;
        });
  const hasRecentVolumeHistory =
    options.mode !== 'change-threshold-volume-spike'
      ? false
      : items.some((item) =>
          (recentHistories.get(item.stockCode) ?? []).some((history) => isFiniteNumber(history.volumeIncreasePercent) && history.volumeIncreasePercent > 0),
        );
  const skippedReason =
    options.mode === 'price'
      ? hasRegisteredTargets
        ? '현재가 시세를 확인할 수 있는 종목이 없습니다.'
        : '현재가로 등록된 종목이 없습니다.'
      : options.mode === 'change-threshold'
        ? hasRegisteredTargets
          ? `${thresholdPercent}% 이상 변동 종목이 없습니다.`
          : '등록된 종목이 없습니다.'
        : !hasRegisteredTargets
          ? '등록된 종목이 없습니다.'
        : !hasComparableVolumeBaseline
            ? '이전 배치 실행 row 기준 거래량이 없어 스냅샷만 갱신했습니다.'
            : !hasRecentVolumeHistory
              ? '배치 실행 거래량 히스토리 row 5건 비교 기준이 없어 히스토리만 적재했습니다.'
              : `등락률 ${thresholdPercent}% 이상이면서 이번 거래량 증폭수가 최근 배치 실행 row 5건 최대값 대비 ${minVolumeIncreasePercent}% 이상 증가한 종목이 없습니다.`;
  const skippedResult = createSkippedNotificationResult(skippedReason);

  if (options.mode === 'change-threshold-volume-spike') {
    await upsertStockAlertVolumeSnapshots(items, previousSnapshots);
    await insertStockAlertVolumeHistories(items, previousSnapshots);
  }

  if (!lines.length) {
    return {
      ok: true,
      skipped: true,
      reason: skippedReason,
      title: options.title,
      body: '',
      itemCount: 0,
      lines: [],
      ios: skippedResult.ios,
      web: skippedResult.web,
    };
  }

  const body = lines.join('\n');
  const notificationIdentity = buildStockAlertNotificationIdentity(options);
  const result = await sendNotifications(
    {
      title: options.title,
      body,
      threadId: notificationIdentity.threadId,
      targetAppDomains: [STOCK_ALERT_NOTIFICATION_TARGET_DOMAIN],
      data: {
        category: 'stock-alert',
        eventType:
          options.mode === 'price'
            ? 'stock-alert-current-price'
            : options.mode === 'change-threshold'
              ? 'stock-alert-change-threshold'
              : 'stock-alert-change-threshold-volume-spike',
        notificationKey: notificationIdentity.notificationKey,
        notificationScope: notificationIdentity.notificationScope,
        notificationAliases: JSON.stringify(notificationIdentity.notificationAliases),
        replaceExistingScope: 'true',
        source: options.serviceKey,
        targetUrl: STOCK_ALERT_NOTIFICATION_TARGET_URL,
      },
    },
    {
      disableIos: true,
    },
  );

  return {
    ...result,
    title: options.title,
    body,
    itemCount: lines.length,
    lines,
  };
}

export async function sendCurrentPriceStockAlertWebPush() {
  return sendManagedStockAlertWebPush({
    scheduleId: 2,
    serviceKey: STOCK_ALERT_NOTIFICATION_SCOPE,
    title: STOCK_ALERT_NOTIFICATION_TITLE,
    mode: 'price',
  });
}

export async function updateStockAlertLayoutFeatureDescription() {
  await ensureStockAlertTable();

  const layoutRecord = await db('play_layouts').select('id', 'tree').where({ name: STOCK_ALERT_LAYOUT_NAME }).first();

  if (!layoutRecord || typeof layoutRecord !== 'object') {
    return false;
  }

  const tree = layoutRecord.tree as
    | {
        root?: unknown;
        interactions?: Array<{
          id?: string;
          title?: string;
          description?: string;
          implementationNotes?: string;
        }>;
        interactionMode?: string;
      }
    | null;

  if (!tree || !Array.isArray(tree.interactions)) {
    return false;
  }

  let changed = false;
  const nextInteractions = tree.interactions.map((interaction) => {
    const title = interaction.title?.trim();

    if (title === '그리드 기본정의') {
      const nextDescription = [
        '## 그리드 필드를 아래로 정의하세요.',
        ' - 종목명, 등락률, 현재가, 기준일시, 알림유형',
        '## 숨긴필드',
        ' - 종목코드',
        '## DB관리 데이터',
        ' - 종목코드, 알림유형',
        '## 외부 API 및 가공 데이터',
        ' - DB에 저장된 종목코드에 대한 현재가 등락률 기준일시를 표현',
        ' - 현재가 데이터는 정규장이 아닌 가격도 모두 가져오도록 해주세요.',
        '## 서비스 구현',
        ' - 해당 그리드 데이터를 저장할 DB 및 서비스 API를 구현허고 연결하세요. CRUD',
        ' - 알림유형은 한종목에 멀티로 저장되어야 합니다.',
        '## 입력',
        '알림유형의 경우 멀티선택 가능하게 해주세요.',
      ].join('\n');
      const nextNotes =
        'GET /api/stock-alerts?alertType=... 로 조회하고 PUT /api/stock-alerts/batch, DELETE /api/stock-alerts/:id 로 저장/삭제합니다. 알림유형은 그리드 행에서 멀티선택 드롭다운으로 편집되며 한 종목에 멀티선택으로 저장됩니다. 현재가·등락률·기준일시는 네이버 실시간 시세를 우선 사용하고, 필요 시 Yahoo Finance를 보조로 사용하며 비정규장 가격도 반영합니다. 거래량 비교는 배치 실행 때마다 적재되는 종목별 거래량 스냅샷과 최근 히스토리 row 5건 기준으로 계산해 제공합니다.';

      if (interaction.description !== nextDescription || interaction.implementationNotes !== nextNotes) {
        changed = true;
        return {
          ...interaction,
          description: nextDescription,
          implementationNotes: nextNotes,
        };
      }
    }

    if (title === '얼림유형 검색') {
      const nextNotes =
        'Primary Pane Select Input 값은 all, price, top3 코드로 유지하고 Secondary Pane 그리드 조회 파라미터 alertType 으로 바로 전달합니다.';

      if (interaction.implementationNotes !== nextNotes) {
        changed = true;
        return {
          ...interaction,
          implementationNotes: nextNotes,
        };
      }
    }

    if (title === '행추가 기능') {
      const nextNotes =
        'GET /api/stock-alerts/search?query=... 로 종목명/종목코드를 조회하고, 선택한 종목코드·종목명·시장구분을 그리드 신규 행으로 매핑합니다. 동일 종목코드는 중복 추가를 막고, 선택 후 해당 행으로 포커스를 이동합니다.';

      if (interaction.implementationNotes !== nextNotes) {
        changed = true;
        return {
          ...interaction,
          implementationNotes: nextNotes,
        };
      }
    }

    return interaction;
  });

  if (!changed) {
    return false;
  }

  await db('play_layouts')
    .where({ id: layoutRecord.id as string })
    .update({
      tree: {
        ...tree,
        interactions: nextInteractions,
      },
    });

  return true;
}
