import { Buffer } from 'node:buffer';
import { createHash } from 'node:crypto';
import { mkdir, readFile, rename, writeFile } from 'node:fs/promises';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import type { FastifyInstance } from 'fastify';
import { z } from 'zod';

const READER_FETCH_TIMEOUT_MS = 12_000;
const READER_FETCH_MAX_BYTES = 1_500_000;
const DEFAULT_USER_AGENT =
  'Mozilla/5.0 (compatible; SMHome-EReader/1.0; +https://preview.sm-home.cloud/)';

const extractArticleQuerySchema = z.object({
  url: z.string().trim().url(),
});

const searchReaderNewsQuerySchema = z.object({
  keyword: z.string().trim().optional(),
  topic: z.preprocess(
    (value) => {
      if (Array.isArray(value)) {
        return value;
      }

      if (typeof value === 'string' && value.trim()) {
        return [value];
      }

      return undefined;
    },
    z.array(z.enum(['politics', 'economy', 'society', 'culture', 'world', 'it'])).optional(),
  ),
  source: z.preprocess(
    (value) => {
      if (Array.isArray(value)) {
        return value;
      }

      if (typeof value === 'string' && value.trim()) {
        return [value];
      }

      return undefined;
    },
    z.array(z.string().trim().min(1)).optional(),
  ),
  dateFrom: z.string().trim().regex(/^\d{4}-\d{2}-\d{2}$/u),
  dateTo: z.string().trim().regex(/^\d{4}-\d{2}-\d{2}$/u),
  limit: z.coerce.number().int().min(1).max(36).optional(),
});

const readerLibraryArticleSchema = z.object({
  id: z.string().trim().min(1),
  title: z.string().trim().min(1),
  sourceLabel: z.string().trim().min(1),
  url: z.string().trim().url(),
  lead: z.string().trim().min(1),
  body: z.string().trim().min(1),
  htmlBody: z.string().trim().min(1).optional(),
  tags: z.array(z.string().trim().min(1)).optional(),
  signals: z.array(z.string().trim().min(1)).max(4).optional(),
  listedDate: z.string().trim().regex(/^\d{4}-\d{2}-\d{2}$/u).optional(),
  publishedAt: z.string().trim().min(1).optional(),
});

const saveReaderLibraryBodySchema = z.object({
  item: readerLibraryArticleSchema,
});

type ReaderLibraryArticle = z.infer<typeof readerLibraryArticleSchema>;

type ReaderLibraryStoredRecord = {
  ownerKey: string;
  article: ReaderLibraryArticle;
  createdAt: string;
  updatedAt: string;
};

type ReaderLibraryResponseItem = ReaderLibraryArticle & {
  createdAt: string;
  updatedAt: string;
};

type ReaderNewsSearchItem = {
  id: string;
  title: string;
  sourceLabel: string;
  url: string;
  lead: string;
  body: string;
  htmlBody?: string;
  tags?: string[];
  signals?: string[];
  listedDate?: string;
  publishedAt?: string;
};

type ReaderNewsSectionKey = 'politics' | 'economy' | 'society' | 'culture' | 'world' | 'it';

type ReaderNewsSectionTarget = {
  key: Exclude<ReaderNewsSectionKey, 'all'>;
  label: string;
  sid: string;
};

const NAVER_NEWS_SECTION_TARGETS: ReaderNewsSectionTarget[] = [
  { key: 'politics', label: '정치', sid: '100' },
  { key: 'economy', label: '경제', sid: '101' },
  { key: 'society', label: '사회', sid: '102' },
  { key: 'culture', label: '생활/문화', sid: '103' },
  { key: 'world', label: '세계', sid: '104' },
  { key: 'it', label: 'IT/과학', sid: '105' },
];

const WORK_SERVER_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', '..');
const READER_LIBRARY_FILE_PATH = path.join(WORK_SERVER_ROOT, 'data', 'e-reader-library.json');

type ReaderContentBlock =
  | {
      kind: 'text';
      tag: 'p' | 'h2' | 'h3' | 'blockquote' | 'li';
      text: string;
    }
  | {
      kind: 'image';
      src: string;
      alt: string;
      caption: string;
    };

function decodeHtmlEntities(value: string) {
  return value
    .replace(/&nbsp;/giu, ' ')
    .replace(/&amp;/giu, '&')
    .replace(/&quot;/giu, '"')
    .replace(/&#39;/giu, "'")
    .replace(/&lt;/giu, '<')
    .replace(/&gt;/giu, '>')
    .replace(/&#(\d+);/gu, (_, codePoint: string) => String.fromCodePoint(Number(codePoint)))
    .replace(/&#x([0-9a-f]+);/giu, (_, codePoint: string) => String.fromCodePoint(Number.parseInt(codePoint, 16)));
}

function resolveReaderClientId(headers: Record<string, unknown>) {
  return String(headers['x-client-id'] ?? '').trim();
}

function resolveReaderLibraryOwnerKey(headers: Record<string, unknown>) {
  const accessToken = String(headers['x-access-token'] ?? '').trim();

  if (accessToken) {
    return `token:${createHash('sha256').update(accessToken).digest('hex')}`;
  }

  const clientId = resolveReaderClientId(headers);
  return clientId ? `client:${clientId}` : '';
}

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

  if (!trimmedValue) {
    return '';
  }

  try {
    const url = new URL(trimmedValue);
    url.hash = '';
    url.pathname = url.pathname.replace(/\/+$/u, '') || '/';
    return url.toString();
  } catch {
    return trimmedValue.replace(/#.*$/u, '').replace(/\/+$/u, '');
  }
}

async function readReaderLibraryRecords() {
  try {
    const raw = await readFile(READER_LIBRARY_FILE_PATH, 'utf8');
    const parsed = parseReaderLibraryFilePayload(raw);
    const records = Array.isArray(parsed) ? parsed : Array.isArray(parsed?.items) ? parsed.items : [];

    return records
      .map((record) => {
        const articleResult = readerLibraryArticleSchema.safeParse(record?.article);
        const ownerKey = typeof record?.ownerKey === 'string' && record.ownerKey.trim()
          ? record.ownerKey.trim()
          : typeof (record as { clientId?: unknown })?.clientId === 'string' && String((record as { clientId?: string }).clientId).trim()
            ? `client:${String((record as { clientId?: string }).clientId).trim()}`
            : '';

        if (!articleResult.success || !ownerKey) {
          return null;
        }

        return {
          ownerKey,
          article: articleResult.data,
          createdAt:
            typeof record.createdAt === 'string' && record.createdAt.trim()
              ? record.createdAt
              : new Date().toISOString(),
          updatedAt:
            typeof record.updatedAt === 'string' && record.updatedAt.trim()
              ? record.updatedAt
              : typeof record.createdAt === 'string' && record.createdAt.trim()
                ? record.createdAt
                : new Date().toISOString(),
        } satisfies ReaderLibraryStoredRecord;
      })
      .filter((record): record is ReaderLibraryStoredRecord => Boolean(record));
  } catch (error) {
    if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
      return [];
    }

    throw error;
  }
}

async function writeReaderLibraryRecords(records: ReaderLibraryStoredRecord[]) {
  await mkdir(path.dirname(READER_LIBRARY_FILE_PATH), { recursive: true });
  const tempFilePath = `${READER_LIBRARY_FILE_PATH}.tmp-${process.pid}`;
  await writeFile(tempFilePath, `${JSON.stringify({ items: records }, null, 2)}\n`, 'utf8');
  await rename(tempFilePath, READER_LIBRARY_FILE_PATH);
}

function parseReaderLibraryFilePayload(raw: string) {
  try {
    return JSON.parse(raw) as { items?: ReaderLibraryStoredRecord[] } | ReaderLibraryStoredRecord[] | null;
  } catch (error) {
    const recovered = extractLeadingJsonValue(raw);

    if (recovered) {
      return JSON.parse(recovered) as { items?: ReaderLibraryStoredRecord[] } | ReaderLibraryStoredRecord[] | null;
    }

    throw error;
  }
}

function extractLeadingJsonValue(raw: string) {
  const trimmed = raw.trimStart();

  if (!trimmed) {
    return '';
  }

  const rootStart = trimmed[0];

  if (rootStart !== '{' && rootStart !== '[') {
    return '';
  }

  let depth = 0;
  let inString = false;
  let isEscaped = false;

  for (let index = 0; index < trimmed.length; index += 1) {
    const character = trimmed[index];

    if (inString) {
      if (isEscaped) {
        isEscaped = false;
        continue;
      }

      if (character === '\\') {
        isEscaped = true;
        continue;
      }

      if (character === '"') {
        inString = false;
      }

      continue;
    }

    if (character === '"') {
      inString = true;
      continue;
    }

    if (character === '{' || character === '[') {
      depth += 1;
      continue;
    }

    if (character === '}' || character === ']') {
      depth -= 1;

      if (depth === 0) {
        const candidate = trimmed.slice(0, index + 1);
        return candidate.trim();
      }
    }
  }

  return '';
}

function toReaderLibraryResponseItem(record: ReaderLibraryStoredRecord) {
  return {
    ...record.article,
    createdAt: record.createdAt,
    updatedAt: record.updatedAt,
  } satisfies ReaderLibraryResponseItem;
}

function stripHtmlTags(value: string) {
  return decodeHtmlEntities(
    value
      .replace(/<script\b[^>]*>[\s\S]*?<\/script>/giu, ' ')
      .replace(/<style\b[^>]*>[\s\S]*?<\/style>/giu, ' ')
      .replace(/<noscript\b[^>]*>[\s\S]*?<\/noscript>/giu, ' ')
      .replace(/<!--[\s\S]*?-->/gu, ' ')
      .replace(/<(?:br|hr)\s*\/?>/giu, '\n')
      .replace(/<\/(?:p|div|section|article|li|ul|ol|h[1-6]|blockquote|main|aside|figure|figcaption|table|tr)>/giu, '\n\n')
      .replace(/<[^>]+>/gu, ' '),
  )
    .replace(/\r\n?/gu, '\n')
    .replace(/[ \t\f\v]+/gu, ' ')
    .replace(/\n[ \t]+/gu, '\n')
    .replace(/[ \t]+\n/gu, '\n')
    .replace(/\n{3,}/gu, '\n\n')
    .trim();
}

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

function unwrapCdata(value: string) {
  return value.replace(/^<!\[CDATA\[/u, '').replace(/\]\]>$/u, '').trim();
}

function extractXmlTagValue(source: string, tag: string) {
  const match = source.match(new RegExp(`<${tag}\\b[^>]*>([\\s\\S]*?)<\\/${tag}>`, 'iu'));
  return unwrapCdata(match?.[1] ?? '');
}

function extractXmlTagAttribute(source: string, tag: string, attribute: string) {
  const match = source.match(new RegExp(`<${tag}\\b[^>]*\\s${attribute}=["']([^"']+)["'][^>]*>`, 'iu'));
  return decodeHtmlEntities(match?.[1] ?? '').trim();
}

function extractMetaContent(html: string, key: string, attribute: 'property' | 'name' = 'property') {
  const pattern = new RegExp(
    `<meta[^>]+${attribute}=["']${key.replace(/[.*+?^${}()|[\]\\]/gu, '\\$&')}["'][^>]+content=["']([^"']+)["'][^>]*>`,
    'iu',
  );
  const reversePattern = new RegExp(
    `<meta[^>]+content=["']([^"']+)["'][^>]+${attribute}=["']${key.replace(/[.*+?^${}()|[\]\\]/gu, '\\$&')}["'][^>]*>`,
    'iu',
  );
  return decodeHtmlEntities(html.match(pattern)?.[1] ?? html.match(reversePattern)?.[1] ?? '').trim();
}

function normalizeSourceLabel(value: string) {
  return decodeHtmlEntities(value)
    .replace(/\s*\|\s*네이버\s*$/u, '')
    .replace(/\s*:\s*네이버\s*뉴스\s*$/u, '')
    .replace(/\s+/gu, ' ')
    .trim();
}

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

  if (!trimmedValue) {
    return '';
  }

  const normalizedValue = trimmedValue.replace(/\s+/gu, ' ');

  if (/^\d{4}-\d{2}-\d{2}$/u.test(normalizedValue)) {
    return normalizedValue;
  }

  if (/^\d{4}\.\d{2}\.\d{2}$/u.test(normalizedValue)) {
    return normalizedValue.replace(/\./gu, '-');
  }

  if (/^\d{4}\/\d{2}\/\d{2}$/u.test(normalizedValue)) {
    return normalizedValue.replace(/\//gu, '-');
  }

  const koreanDateTimeMatch = normalizedValue.match(
    /(\d{4})[./-]\s*(\d{1,2})[./-]\s*(\d{1,2})\.?\s*(오전|오후)\s*(\d{1,2}):(\d{2})(?::(\d{2}))?/u,
  );

  if (koreanDateTimeMatch) {
    const [, year, month, day, meridiem, hourText, minute, second = '00'] = koreanDateTimeMatch;
    let hour = Number.parseInt(hourText, 10);

    if (meridiem === '오전') {
      if (hour === 12) {
        hour = 0;
      }
    } else if (hour < 12) {
      hour += 12;
    }

    return `${year}-${month.padStart(2, '0')}-${day.padStart(2, '0')}T${`${hour}`.padStart(2, '0')}:${minute}:${second}+09:00`;
  }

  const localDateTimeMatch = normalizedValue.match(
    /(\d{4})-(\d{2})-(\d{2})\s+(\d{2}):(\d{2})(?::(\d{2}))?/u,
  );

  if (localDateTimeMatch) {
    const [, year, month, day, hour, minute, second = '00'] = localDateTimeMatch;
    return `${year}-${month}-${day}T${hour}:${minute}:${second}+09:00`;
  }

  const parsedDate = new Date(normalizedValue);

  if (!Number.isNaN(parsedDate.getTime())) {
    return parsedDate.toISOString();
  }

  const looseDateMatch = normalizedValue.match(/(\d{4})[./-]\s*(\d{1,2})[./-]\s*(\d{1,2})/u);

  if (looseDateMatch) {
    const [, year, month, day] = looseDateMatch;
    return `${year}-${month.padStart(2, '0')}-${day.padStart(2, '0')}`;
  }

  return '';
}

function extractJsonLdPublishedAt(html: string) {
  const scriptMatches = html.matchAll(
    /<script\b[^>]*type=["']application\/ld\+json["'][^>]*>([\s\S]*?)<\/script>/giu,
  );

  const visitNode = (value: unknown): string => {
    if (!value) {
      return '';
    }

    if (Array.isArray(value)) {
      for (const item of value) {
        const found = visitNode(item);

        if (found) {
          return found;
        }
      }

      return '';
    }

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

    const record = value as Record<string, unknown>;
    const directCandidate = [
      record.datePublished,
      record.dateCreated,
      record.uploadDate,
      record.pubDate,
      record.dateModified,
    ].find((candidate) => typeof candidate === 'string' && candidate.trim());

    if (typeof directCandidate === 'string') {
      const normalized = normalizePublishedAt(directCandidate);

      if (normalized) {
        return normalized;
      }
    }

    return visitNode(record['@graph']);
  };

  for (const match of scriptMatches) {
    const rawContent = decodeHtmlEntities(match[1] ?? '').trim();

    if (!rawContent) {
      continue;
    }

    try {
      const parsed = JSON.parse(rawContent) as unknown;
      const found = visitNode(parsed);

      if (found) {
        return found;
      }
    } catch {
      continue;
    }
  }

  return '';
}

function extractPublishedAt(html: string) {
  const metaCandidates = [
    extractMetaContent(html, 'article:published_time'),
    extractMetaContent(html, 'og:article:published_time'),
    extractMetaContent(html, 'article:modified_time'),
    extractMetaContent(html, 'og:article:modified_time'),
    extractMetaContent(html, 'publish_date', 'name'),
    extractMetaContent(html, 'pubdate', 'name'),
    extractMetaContent(html, 'date', 'name'),
    extractMetaContent(html, 'dc.date', 'name'),
    extractMetaContent(html, 'dc.date.issued', 'name'),
    extractMetaContent(html, 'datePublished', 'name'),
    html.match(/\bdata-date-time=["']([^"']+)["']/iu)?.[1] ?? '',
    html.match(/class=["'][^"']*\b_ARTICLE_DATE_TIME\b[^"']*["'][^>]*>([\s\S]*?)<\/[^>]+>/iu)?.[1] ?? '',
  ];

  for (const candidate of metaCandidates) {
    const normalized = normalizePublishedAt(candidate);

    if (normalized) {
      return normalized;
    }
  }

  return extractJsonLdPublishedAt(html);
}

function extractTitle(html: string) {
  const candidates = [
    extractMetaContent(html, 'og:title'),
    extractMetaContent(html, 'twitter:title', 'name'),
    decodeHtmlEntities(html.match(/<title[^>]*>([\s\S]*?)<\/title>/iu)?.[1] ?? '').trim(),
  ].filter(Boolean);
  return candidates[0] ?? '가져온 원문';
}

function extractSiteName(html: string, url: URL) {
  const sourceCandidates = [
    extractMetaContent(html, 'og:article:author'),
    extractMetaContent(html, 'twitter:creator', 'name'),
    html.match(/<a[^>]+class=["'][^"']*\bmedia_end_head_top_logo\b[^"']*["'][^>]*>[\s\S]*?<img[^>]+alt=["']([^"']+)["']/iu)?.[1] ?? '',
    html.match(/<a[^>]+class=["'][^"']*\bmedia_end_head_top_logo\b[^"']*["'][^>]*>[\s\S]*?<span[^>]*class=["'][^"']*\bmedia_end_head_top_logo_text\b[^"']*["'][^>]*>([\s\S]*?)<\/span>/iu)?.[1] ?? '',
    extractMetaContent(html, 'og:site_name'),
    extractMetaContent(html, 'application-name', 'name'),
  ];

  for (const candidate of sourceCandidates) {
    const normalized = normalizeSourceLabel(candidate);

    if (normalized) {
      return normalized;
    }
  }

  return url.hostname.replace(/^www\./iu, '');
}

function pickPreferredArticleFragment(html: string) {
  const preferredPatterns = [
    /<article\b[^>]+id=["']dic_area["'][^>]*>([\s\S]*?)<\/article>/iu,
    /<div\b[^>]+id=["']dic_area["'][^>]*>([\s\S]*?)<\/div>/iu,
    /<div\b[^>]+id=["']newsct_article["'][^>]*>([\s\S]*?)<\/div>/iu,
    /<div\b[^>]+id=["']articleBodyContents["'][^>]*>([\s\S]*?)<\/div>/iu,
    /<div\b[^>]+class=["'][^"']*\barticle_view\b[^"']*["'][^>]*>([\s\S]*?)<\/div>/iu,
  ];

  for (const pattern of preferredPatterns) {
    const matched = html.match(pattern)?.[1]?.trim();
    if (matched && stripHtmlTags(matched).length >= 120) {
      return matched;
    }
  }

  return '';
}

function collectArticleCandidates(html: string) {
  const patterns = [
    /<article\b[^>]*>([\s\S]*?)<\/article>/giu,
    /<main\b[^>]*>([\s\S]*?)<\/main>/giu,
    /<(?:section|div)\b[^>]+(?:content|article|story|post|entry|main|body)[^>]*>([\s\S]*?)<\/(?:section|div)>/giu,
    /<body\b[^>]*>([\s\S]*?)<\/body>/giu,
  ];

  const matches: string[] = [];

  patterns.forEach((pattern) => {
    for (const match of html.matchAll(pattern)) {
      const candidate = stripHtmlTags(match[1] ?? '');
      if (candidate.length >= 280) {
        matches.push(candidate);
      }
    }
  });

  return matches;
}

function collectArticleHtmlFragments(html: string) {
  const patterns = [
    /<article\b[^>]*>([\s\S]*?)<\/article>/giu,
    /<main\b[^>]*>([\s\S]*?)<\/main>/giu,
    /<(?:section|div)\b[^>]+(?:content|article|story|post|entry|main|body)[^>]*>([\s\S]*?)<\/(?:section|div)>/giu,
    /<body\b[^>]*>([\s\S]*?)<\/body>/giu,
  ];

  const matches: Array<{ html: string; textLength: number }> = [];

  patterns.forEach((pattern) => {
    for (const match of html.matchAll(pattern)) {
      const fragment = match[1] ?? '';
      const textLength = stripHtmlTags(fragment).length;

      if (textLength >= 280) {
        matches.push({ html: fragment, textLength });
      }
    }
  });

  return matches;
}

function pickBestArticleHtml(html: string) {
  const preferred = pickPreferredArticleFragment(html);
  if (preferred) {
    return preferred;
  }

  const candidates = collectArticleHtmlFragments(html);

  if (candidates.length) {
    return candidates.sort((left, right) => right.textLength - left.textLength)[0]?.html ?? html;
  }

  return html;
}

function pickBestArticleBody(html: string) {
  const preferred = pickPreferredArticleFragment(html);
  if (preferred) {
    return stripHtmlTags(preferred);
  }

  const candidates = collectArticleCandidates(html);

  if (candidates.length) {
    return candidates.sort((left, right) => right.length - left.length)[0];
  }

  return stripHtmlTags(html);
}

function normalizeParagraphs(body: string) {
  return body
    .split(/\n{2,}/u)
    .map((paragraph) => paragraph.replace(/\s+/gu, ' ').trim())
    .filter((paragraph) => paragraph.length >= 24)
    .join('\n\n');
}

function resolveImageSource(rawValue: string, baseUrl: URL) {
  const sanitized = decodeHtmlEntities(rawValue)
    .trim()
    .replace(/\s+/gu, ' ');

  if (!sanitized) {
    return '';
  }

  const srcCandidate = sanitized
    .split(',')
    .map((item) => item.trim().split(/\s+/u)[0] ?? '')
    .find(Boolean);

  if (!srcCandidate || /^data:/iu.test(srcCandidate)) {
    return '';
  }

  try {
    const resolved = new URL(srcCandidate, baseUrl);
    return /^https?:$/iu.test(resolved.protocol) ? resolved.toString() : '';
  } catch {
    return '';
  }
}

function extractImageSource(fragment: string, baseUrl: URL) {
  const sourceCandidates = [
    fragment.match(/\ssrc=["']([^"']+)["']/iu)?.[1],
    fragment.match(/\sdata-src=["']([^"']+)["']/iu)?.[1],
    fragment.match(/\sdata-original=["']([^"']+)["']/iu)?.[1],
    fragment.match(/\ssrcset=["']([^"']+)["']/iu)?.[1],
    fragment.match(/\sdata-srcset=["']([^"']+)["']/iu)?.[1],
  ].filter((value): value is string => Boolean(value));

  for (const candidate of sourceCandidates) {
    const resolved = resolveImageSource(candidate, baseUrl);

    if (resolved) {
      return resolved;
    }
  }

  return '';
}

function normalizeTextBlock(fragment: string) {
  return decodeHtmlEntities(
    fragment
      .replace(/<br\s*\/?>/giu, '\n')
      .replace(/<\/p>|<\/div>|<\/section>|<\/article>|<\/blockquote>|<\/h[1-6]>/giu, '\n\n')
      .replace(/<[^>]+>/gu, ' '),
  )
    .replace(/\r\n?/gu, '\n')
    .replace(/[ \t\f\v]+/gu, ' ')
    .replace(/\n[ \t]+/gu, '\n')
    .replace(/[ \t]+\n/gu, '\n')
    .replace(/\n{3,}/gu, '\n\n')
    .trim();
}

function pushLooseTextBlocks(blocks: ReaderContentBlock[], fragment: string) {
  const normalized = normalizeTextBlock(fragment);
  if (!normalized) {
    return;
  }

  normalized
    .split(/\n{2,}/u)
    .map((paragraph) => paragraph.replace(/\s+/gu, ' ').trim())
    .filter((paragraph) => paragraph.length >= 18)
    .forEach((text) => {
      blocks.push({ kind: 'text', tag: 'p', text });
    });
}

function extractReaderContentBlocks(fragment: string, baseUrl: URL) {
  const matches = fragment.matchAll(
    /<figure\b[^>]*>[\s\S]*?<\/figure>|<table\b[^>]*>[\s\S]*?<\/table>|<img\b[^>]*>|<h[1-3]\b[^>]*>[\s\S]*?<\/h[1-3]>|<blockquote\b[^>]*>[\s\S]*?<\/blockquote>|<p\b[^>]*>[\s\S]*?<\/p>|<(?:ul|ol)\b[^>]*>[\s\S]*?<\/(?:ul|ol)>/giu,
  );
  const blocks: ReaderContentBlock[] = [];
  const seenImages = new Set<string>();
  let cursor = 0;

  for (const match of matches) {
    const rawBlock = match[0] ?? '';
    const matchIndex = match.index ?? 0;

    if (!rawBlock) {
      continue;
    }

    if (matchIndex > cursor) {
      pushLooseTextBlocks(blocks, fragment.slice(cursor, matchIndex));
    }

    if (/^<figure\b/iu.test(rawBlock)) {
      const imageFragment = rawBlock.match(/<img\b[^>]*>/iu)?.[0] ?? '';
      const src = extractImageSource(imageFragment, baseUrl);

      if (!src || seenImages.has(src)) {
        continue;
      }

      seenImages.add(src);
      blocks.push({
        kind: 'image',
        src,
        alt: normalizeTextBlock(rawBlock.match(/<img\b[^>]*\salt=["']([^"']*)["'][^>]*>/iu)?.[1] ?? ''),
        caption: normalizeTextBlock(rawBlock.match(/<figcaption\b[^>]*>([\s\S]*?)<\/figcaption>/iu)?.[1] ?? ''),
      });
      cursor = matchIndex + rawBlock.length;
      continue;
    }

    if (/^<table\b/iu.test(rawBlock)) {
      const imageFragment = rawBlock.match(/<img\b[^>]*>/iu)?.[0] ?? '';
      const src = extractImageSource(imageFragment, baseUrl);
      const caption = normalizeTextBlock(
        rawBlock
          .replace(/<img\b[^>]*>/giu, ' ')
          .replace(/<tbody\b[^>]*>|<\/tbody>|<tr\b[^>]*>|<\/tr>|<td\b[^>]*>|<\/td>/giu, ' '),
      );

      if (src && !seenImages.has(src)) {
        seenImages.add(src);
        blocks.push({
          kind: 'image',
          src,
          alt: normalizeTextBlock(rawBlock.match(/<img\b[^>]*\salt=["']([^"']*)["'][^>]*>/iu)?.[1] ?? ''),
          caption,
        });
      } else if (caption.length >= 18) {
        blocks.push({ kind: 'text', tag: 'p', text: caption });
      }

      cursor = matchIndex + rawBlock.length;
      continue;
    }

    if (/^<img\b/iu.test(rawBlock)) {
      const src = extractImageSource(rawBlock, baseUrl);

      if (!src || seenImages.has(src)) {
        continue;
      }

      seenImages.add(src);
      blocks.push({
        kind: 'image',
        src,
        alt: normalizeTextBlock(rawBlock.match(/\salt=["']([^"']*)["']/iu)?.[1] ?? ''),
        caption: '',
      });
      cursor = matchIndex + rawBlock.length;
      continue;
    }

    if (/^<(?:ul|ol)\b/iu.test(rawBlock)) {
      for (const item of rawBlock.matchAll(/<li\b[^>]*>([\s\S]*?)<\/li>/giu)) {
        const text = normalizeTextBlock(item[1] ?? '');

        if (text.length >= 12) {
          blocks.push({ kind: 'text', tag: 'li', text });
        }
      }
      cursor = matchIndex + rawBlock.length;
      continue;
    }

    const text = normalizeTextBlock(rawBlock);

    if (text.length < 18) {
      continue;
    }

    const tag = rawBlock.startsWith('<h1') || rawBlock.startsWith('<h2')
      ? 'h2'
      : rawBlock.startsWith('<h3')
        ? 'h3'
        : rawBlock.startsWith('<blockquote')
          ? 'blockquote'
          : 'p';

    blocks.push({ kind: 'text', tag, text });
    cursor = matchIndex + rawBlock.length;
  }

  if (cursor < fragment.length) {
    pushLooseTextBlocks(blocks, fragment.slice(cursor));
  }

  return blocks;
}

function buildArticleHtml(blocks: ReaderContentBlock[]) {
  return blocks
    .map((block) => {
      if (block.kind === 'image') {
        const alt = escapeHtml(block.alt || block.caption || '기사 이미지');
        const caption = block.caption ? `<figcaption>${escapeHtml(block.caption)}</figcaption>` : '';
        return `<figure class="e-reader-html__figure"><img src="${escapeHtml(block.src)}" alt="${alt}" loading="lazy" referrerpolicy="no-referrer" />${caption}</figure>`;
      }

      if (block.tag === 'li') {
        return `<ul class="e-reader-html__list"><li>${escapeHtml(block.text)}</li></ul>`;
      }

      return `<${block.tag}>${escapeHtml(block.text)}</${block.tag}>`;
    })
    .join('');
}

async function fetchHtml(targetUrl: string) {
  const controller = new AbortController();
  const timeoutId = setTimeout(() => controller.abort(), READER_FETCH_TIMEOUT_MS);

  try {
    const response = await fetch(targetUrl, {
      headers: {
        'User-Agent': DEFAULT_USER_AGENT,
        Accept: 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8',
      },
      redirect: 'follow',
      signal: controller.signal,
    });

    if (!response.ok) {
      throw new Error(`원문을 가져오지 못했습니다. (${response.status})`);
    }

    const contentType = response.headers.get('content-type') ?? '';
    if (!/text\/html|application\/xhtml\+xml/iu.test(contentType)) {
      throw new Error('HTML 원문만 가져올 수 있습니다.');
    }

    const reader = response.body?.getReader();
    if (!reader) {
      throw new Error('응답 본문을 읽지 못했습니다.');
    }

    const chunks: Uint8Array[] = [];
    let totalLength = 0;

    while (true) {
      const { done, value } = await reader.read();
      if (done) {
        break;
      }

      if (!value) {
        continue;
      }

      totalLength += value.byteLength;
      if (totalLength > READER_FETCH_MAX_BYTES) {
        throw new Error('원문이 너무 길어 가져오기를 중단했습니다.');
      }

      chunks.push(value);
    }

    return Buffer.concat(chunks.map((chunk) => Buffer.from(chunk))).toString('utf8');
  } finally {
    clearTimeout(timeoutId);
  }
}

async function fetchText(targetUrl: string, options?: { accept?: string; contentTypePattern?: RegExp }) {
  const controller = new AbortController();
  const timeoutId = setTimeout(() => controller.abort(), READER_FETCH_TIMEOUT_MS);

  try {
    const response = await fetch(targetUrl, {
      headers: {
        'User-Agent': DEFAULT_USER_AGENT,
        Accept: options?.accept ?? 'text/plain,*/*;q=0.8',
      },
      redirect: 'follow',
      signal: controller.signal,
    });

    if (!response.ok) {
      throw new Error(`원문을 가져오지 못했습니다. (${response.status})`);
    }

    const contentType = response.headers.get('content-type') ?? '';
    if (options?.contentTypePattern && !options.contentTypePattern.test(contentType)) {
      throw new Error('지원하지 않는 뉴스 응답 형식입니다.');
    }

    return response.text();
  } finally {
    clearTimeout(timeoutId);
  }
}

function resolveReaderNewsSections(topics: ReaderNewsSectionKey[] | undefined) {
  if (!topics?.length) {
    return NAVER_NEWS_SECTION_TARGETS;
  }

  const topicSet = new Set(topics);
  return NAVER_NEWS_SECTION_TARGETS.filter((section) => topicSet.has(section.key));
}

function extractNaverSectionArticleUrls(html: string) {
  const seen = new Set<string>();
  const directMatches = html.matchAll(/https:\/\/n\.news\.naver\.com\/(?:mnews\/)?article\/\d+\/\d+/gu);
  const legacyMatches = html.matchAll(/https:\/\/news\.naver\.com\/main\/read\.naver\?[^"'\\s>]+/gu);
  const urls: string[] = [];

  for (const match of directMatches) {
    const url = match[0];

    if (!url || seen.has(url)) {
      continue;
    }

    seen.add(url);
    urls.push(url);
  }

  for (const match of legacyMatches) {
    const rawUrl = decodeHtmlEntities(match[0] ?? '').trim();

    if (!rawUrl) {
      continue;
    }

    try {
      const parsedUrl = new URL(rawUrl);
      const oid = parsedUrl.searchParams.get('oid')?.trim();
      const aid = parsedUrl.searchParams.get('aid')?.trim();

      if (!oid || !aid) {
        continue;
      }

      const normalizedUrl = `https://n.news.naver.com/mnews/article/${oid}/${aid}`;

      if (seen.has(normalizedUrl)) {
        continue;
      }

      seen.add(normalizedUrl);
      urls.push(normalizedUrl);
    } catch {
      continue;
    }
  }

  return urls;
}

function resolvePublishedDateKey(value: string | undefined) {
  const normalized = normalizePublishedAt(value ?? '');

  if (!normalized) {
    return '';
  }

  if (/^\d{4}-\d{2}-\d{2}$/u.test(normalized)) {
    return normalized;
  }

  const parsed = new Date(normalized);
  return Number.isNaN(parsed.getTime()) ? '' : parsed.toISOString().slice(0, 10);
}

function buildReaderNewsArticleId(url: string) {
  return `naver-news-${createHash('sha1').update(normalizeReaderArticleUrl(url) || url).digest('hex').slice(0, 16)}`;
}

function buildReaderNewsTags(sectionLabel: string) {
  return ['네이버뉴스', sectionLabel];
}

function buildReaderNewsSignals(input: {
  title: string;
  lead: string;
  sourceLabel: string;
  sectionOrder: number;
}) {
  const haystack = `${input.title} ${input.lead} ${input.sourceLabel}`.toLowerCase();
  const signals: string[] = [];

  if (input.sectionOrder < 6) {
    signals.push('상단노출');
  } else if (input.sectionOrder < 12) {
    signals.push('주목');
  }

  if (/(속보|긴급)/u.test(haystack)) {
    signals.push('속보');
  }

  if (/(단독|심층|기획|특집|해설|분석|인터뷰)/u.test(haystack)) {
    signals.push('핵심');
  }

  return signals.slice(0, 3);
}

function upsertReaderLibraryRecord(
  records: ReaderLibraryStoredRecord[],
  ownerKey: string,
  article: ReaderLibraryArticle,
  updatedAt: string,
) {
  const normalizedUrl = normalizeReaderArticleUrl(article.url);
  const existingIndex = records.findIndex(
    (record) => record.ownerKey === ownerKey && (
      record.article.id === article.id
      || (normalizedUrl && normalizeReaderArticleUrl(record.article.url) === normalizedUrl)
    ),
  );

  if (existingIndex >= 0) {
    const current = records[existingIndex];
    records[existingIndex] = {
      ownerKey,
      article: {
        ...article,
        id: current.article.id,
        url: normalizedUrl || article.url,
      },
      createdAt: current.createdAt,
      updatedAt,
    };

    return toReaderLibraryResponseItem(records[existingIndex]);
  }

  const nextRecord = {
    ownerKey,
    article: {
      ...article,
      url: normalizedUrl || article.url,
    },
    createdAt: updatedAt,
    updatedAt,
  } satisfies ReaderLibraryStoredRecord;

  records.unshift(nextRecord);
  return toReaderLibraryResponseItem(nextRecord);
}

async function collectNaverNewsArticles(query: z.infer<typeof searchReaderNewsQuerySchema>) {
  const dateRange: string[] = [];
  const currentDate = new Date(`${query.dateFrom}T00:00:00Z`);
  const endDate = new Date(`${query.dateTo}T00:00:00Z`);

  while (currentDate.getTime() <= endDate.getTime()) {
    dateRange.push(currentDate.toISOString().slice(0, 10));
    currentDate.setUTCDate(currentDate.getUTCDate() + 1);
  }

  const sections = resolveReaderNewsSections(query.topic);

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

  const perSectionTarget = Math.max(8, Math.ceil(((query.limit ?? 24) * 2) / sections.length));
  const sectionPages = await Promise.all(
    sections.map(async (section) => {
      const pageCount = Math.max(1, Math.min(4, Math.ceil(perSectionTarget / 12)));
      const listPages = await Promise.all(
        dateRange.flatMap((targetDate) =>
          Array.from({ length: pageCount }, (_, index) =>
            fetchText(
              `https://news.naver.com/main/list.naver?mode=LSD&mid=shm&sid1=${section.sid}&date=${targetDate.replace(/-/gu, '')}&page=${index + 1}`,
            ).then((html) => ({
              html,
              targetDate,
            })),
          ),
        ),
      );

      return {
        section,
        urls: listPages
          .flatMap(({ html, targetDate }) =>
            extractNaverSectionArticleUrls(html).map((url, index) => ({
              url,
              sectionOrder: index,
              listedDate: targetDate,
            })),
          )
          .slice(0, perSectionTarget),
      };
    }),
  );

  const urlQueue = sectionPages.flatMap(({ section, urls }) =>
    urls.map((item) => ({
      url: item.url,
      sectionOrder: item.sectionOrder,
      listedDate: item.listedDate,
      section,
    })),
  );

  const seenUrls = new Set<string>();
  const uniqueQueue = urlQueue.filter((item) => {
    const normalizedUrl = normalizeReaderArticleUrl(item.url);

    if (!normalizedUrl || seenUrls.has(normalizedUrl)) {
      return false;
    }

    seenUrls.add(normalizedUrl);
    return true;
  });

  const keywordFilter = query.keyword?.trim().toLowerCase() ?? '';
  const sourceFilters = (query.source ?? [])
    .map((source) => source.trim().toLowerCase())
    .filter(Boolean);
  const limit = query.limit ?? 24;
  const results: ReaderNewsSearchItem[] = [];
  let cursor = 0;

  const workers = Array.from({ length: Math.min(4, uniqueQueue.length) }, async () => {
    while (results.length < limit && cursor < uniqueQueue.length) {
      const currentIndex = cursor;
      cursor += 1;
      const candidate = uniqueQueue[currentIndex];

      if (!candidate) {
        return;
      }

      try {
        const targetUrl = new URL(candidate.url);
        const html = await fetchHtml(targetUrl.toString());
        const sourceLabel = extractSiteName(html, targetUrl);

        if (sourceFilters.length && !sourceFilters.some((sourceFilter) => sourceLabel.toLowerCase().includes(sourceFilter))) {
          continue;
        }

        const articleHtml = pickBestArticleHtml(html);
        const rawBody = pickBestArticleBody(html);
        const body = normalizeParagraphs(rawBody);

        if (!body || body.length < 120) {
          continue;
        }

        const publishedAt = extractPublishedAt(html) || undefined;
        const publishedDateKey = resolvePublishedDateKey(publishedAt);
        const matchesListedDate =
          candidate.listedDate >= query.dateFrom
          && candidate.listedDate <= query.dateTo;
        const matchesPublishedDate =
          Boolean(publishedDateKey)
          && publishedDateKey >= query.dateFrom
          && publishedDateKey <= query.dateTo;

        if (!matchesListedDate && !matchesPublishedDate) {
          continue;
        }

        const title = extractTitle(html).trim();
        const lead = body.split('\n')[0]?.trim() ?? '';
        const keywordHaystack = `${title} ${sourceLabel} ${lead} ${body}`.toLowerCase();

        if (keywordFilter && !keywordHaystack.includes(keywordFilter)) {
          continue;
        }

        const contentBlocks = extractReaderContentBlocks(articleHtml, targetUrl);
        const htmlBody = buildArticleHtml(contentBlocks);

        results.push({
          id: buildReaderNewsArticleId(targetUrl.toString()),
          title: title || `${candidate.section.label} 뉴스`,
          sourceLabel,
          url: targetUrl.toString(),
          lead: lead || `${candidate.section.label} 뉴스`,
          body,
          htmlBody: htmlBody || undefined,
          tags: buildReaderNewsTags(candidate.section.label),
          signals: buildReaderNewsSignals({
            title: title || `${candidate.section.label} 뉴스`,
            lead: lead || `${candidate.section.label} 뉴스`,
            sourceLabel,
            sectionOrder: candidate.sectionOrder,
          }),
          listedDate: candidate.listedDate,
          publishedAt,
        });
      } catch {
        continue;
      }
    }
  });

  await Promise.all(workers);

  const uniqueItems = new Map<string, ReaderNewsSearchItem>();
  results.forEach((item) => {
    const normalizedUrl = normalizeReaderArticleUrl(item.url);
    if (!normalizedUrl || uniqueItems.has(normalizedUrl)) {
      return;
    }

    uniqueItems.set(normalizedUrl, item);
  });

  return Array.from(uniqueItems.values())
    .sort((left, right) => {
      const signalGap = (right.signals?.length ?? 0) - (left.signals?.length ?? 0);

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

      const listedDateGap = (right.listedDate ?? '').localeCompare(left.listedDate ?? '');
      if (listedDateGap !== 0) {
        return listedDateGap;
      }

      const publishedAtGap = (right.publishedAt ?? '').localeCompare(left.publishedAt ?? '');
      if (publishedAtGap !== 0) {
        return publishedAtGap;
      }

      return left.title.localeCompare(right.title, 'ko');
    })
    .slice(0, limit);
}

export async function registerReaderRoutes(app: FastifyInstance) {
  app.get('/api/reader/extract', async (request) => {
    const query = extractArticleQuerySchema.parse(request.query ?? {});
    const targetUrl = new URL(query.url);
    const html = await fetchHtml(targetUrl.toString());
    const title = extractTitle(html);
    const sourceLabel = extractSiteName(html, targetUrl);
    const articleHtml = pickBestArticleHtml(html);
    const rawBody = pickBestArticleBody(html);
    const body = normalizeParagraphs(rawBody);
    const contentBlocks = extractReaderContentBlocks(articleHtml, targetUrl);
    const htmlBody = buildArticleHtml(contentBlocks);

    if (!body || body.length < 120) {
      throw new Error('원문 본문을 충분히 추출하지 못했습니다.');
    }

    const lead = body.split('\n')[0]?.trim() ?? '';

    return {
      ok: true,
      item: {
        title,
        sourceLabel,
        url: targetUrl.toString(),
        lead,
        body,
        htmlBody,
        publishedAt: extractPublishedAt(html),
      },
    };
  });

  app.get('/api/reader/library', async (request, reply) => {
    const ownerKey = resolveReaderLibraryOwnerKey(request.headers);

    if (!ownerKey) {
      return reply.code(400).send({
        message: '서버 서가 조회에는 access token 또는 client id가 필요합니다.',
      });
    }

    const records = await readReaderLibraryRecords();
    const items = records
      .filter((record) => record.ownerKey === ownerKey)
      .sort((left, right) => right.updatedAt.localeCompare(left.updatedAt))
      .map(toReaderLibraryResponseItem);

    return {
      ok: true,
      items,
    };
  });

  app.post('/api/reader/library', async (request, reply) => {
    const ownerKey = resolveReaderLibraryOwnerKey(request.headers);

    if (!ownerKey) {
      return reply.code(400).send({
        message: '서버 저장에는 access token 또는 client id가 필요합니다.',
      });
    }

    const payload = saveReaderLibraryBodySchema.parse(request.body ?? {});
    const records = await readReaderLibraryRecords();
    const now = new Date().toISOString();
    const savedRecord = upsertReaderLibraryRecord(records, ownerKey, payload.item, now);
    await writeReaderLibraryRecords(records);

    return {
      ok: true,
      item: savedRecord,
    };
  });

  app.get('/api/reader/news', async (request, reply) => {
    const query = searchReaderNewsQuerySchema.parse(request.query ?? {});
    const ownerKey = resolveReaderLibraryOwnerKey(request.headers);

    if (query.dateFrom > query.dateTo) {
      return reply.code(400).send({
        message: '뉴스 검색 날짜 범위를 다시 확인해 주세요.',
      });
    }

    if (!ownerKey) {
      return reply.code(400).send({
        message: '뉴스 수집에는 access token 또는 client id가 필요합니다.',
      });
    }

    const items = await collectNaverNewsArticles(query);
    const records = await readReaderLibraryRecords();
    const now = new Date().toISOString();
    const savedItems = items.map((item) =>
      upsertReaderLibraryRecord(records, ownerKey, {
        id: item.id,
        title: item.title,
        sourceLabel: item.sourceLabel,
        url: item.url,
        lead: item.lead,
        body: item.body,
        htmlBody: item.htmlBody,
        tags: item.tags,
        signals: item.signals,
        listedDate: item.listedDate,
        publishedAt: item.publishedAt,
      }, now),
    );
    await writeReaderLibraryRecords(records);

    return {
      ok: true,
      items: savedItems,
    };
  });
}
