import {
  ArrowLeftOutlined,
  ArrowRightOutlined,
  BookOutlined,
  CheckOutlined,
  DownOutlined,
  DownloadOutlined,
  FontSizeOutlined,
  HomeOutlined,
  LinkOutlined,
  ReadOutlined,
  ReloadOutlined,
  SearchOutlined,
  SettingOutlined,
  SwapOutlined,
} from '@ant-design/icons';
import { AutoComplete, Button, Input, Select, Slider, Tag } from 'antd';
import { startTransition, useEffect, useMemo, useRef, useState } from 'react';
import { isPreviewRuntime } from '../../../../app/main/previewRuntime';
import { buildPlayAppPath } from '../../../../app/main/routes';
import { getRegisteredAccessToken } from '../../../../app/main/tokenAccess';
import {
  extractReaderArticle,
  listReaderLibraryArticles,
  searchReaderNews,
  saveReaderLibraryArticle,
  setEReaderShareTokenOverride,
  type EReaderLibraryArticle,
  type EReaderNewsArticle,
  type EReaderNewsSearchParams,
} from './eReaderApi';
import './EReaderAppView.css';

type EReaderAppViewProps = {
  onBack: () => void;
  launchContext?: 'direct' | 'embedded';
  shareToken?: string | null;
};

type ReaderTheme = 'mist' | 'ocean' | 'night' | 'sepia' | 'forest' | 'graphite' | 'rose' | 'dawn';
type ReaderView = 'home' | 'news' | 'library' | 'import' | 'settings' | 'reader';
type ReaderNewsTopic = NonNullable<NonNullable<EReaderNewsSearchParams['topics']>[number]>;

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

type ReaderArticle = ReaderArticleSeed & {
  pageTokens: string[];
};

type ReaderPageBlock = {
  tag: 'blockquote' | 'figure' | 'h2' | 'h3' | 'p' | 'ul';
  markup: string;
  text: string;
  splittable: boolean;
};

type SwipeTrackingState = {
  pointerId: number;
  startX: number;
  startY: number;
  deltaX: number;
  deltaY: number;
};

type PendingPageTurn = {
  articleTitle: string;
  nextPage: string;
  nextPageIndex: number;
  nextPageLabel: string;
  nextProgressPercent: number;
  sourceLabel: string;
};

type ReaderPageCardData = {
  articleTitle: string;
  page: string;
  pageLabel: string;
  progressPercent: number;
  listedDate?: string;
  publishedAt?: string;
  sourceLabel: string;
};

type ReaderPageSlot = 'primary' | 'secondary';
type ReaderInstallState = 'standalone' | 'installable' | 'browser-help';
type ReaderGestureMode = 'tap-swipe' | 'touch-scroll';
type ReaderLibraryNavigationMode = 'scroll' | 'touch-swipe';
type ReaderNewsFilters = {
  keyword: string;
  topics: ReaderNewsTopic[];
  sources: string[];
  dateFrom: string;
  dateTo: string;
  limit: number;
};

type ReaderLibrarySort = 'updated-desc' | 'created-desc';

type DeferredInstallPromptEvent = Event & {
  prompt: () => Promise<void>;
  userChoice: Promise<{ outcome: 'accepted' | 'dismissed'; platform: string }>;
};

const { TextArea } = Input;

const READER_STORAGE_KEY = 'play-app.e-reader.saved-articles';
const READER_SETTINGS_STORAGE_KEY = 'play-app.e-reader.settings';
const READER_READ_HISTORY_STORAGE_KEY = 'play-app.e-reader.read-history';
const E_READER_IMMERSIVE_BODY_CLASS = 'play-app-e-reader-immersive';
const E_READER_MANIFEST_PATH = '/e-reader.webmanifest';
const PAGE_TURN_DISTANCE_PX = 52;
const PAGE_TURN_VERTICAL_TOLERANCE_PX = 74;
const PAGE_SCROLL_TURN_DISTANCE_PX = 72;
const PAGE_SCROLL_HORIZONTAL_TOLERANCE_PX = 82;
const BACK_SWIPE_EDGE_PX = 44;
const BACK_SWIPE_DISTANCE_PX = 112;
const BACK_SWIPE_VERTICAL_TOLERANCE_PX = 72;
const DEFAULT_SOURCE_URL = 'https://example.com/article';
const READER_NEWS_LIMIT_OPTIONS = [12, 24, 36].map((count) => ({
  label: `${count}건`,
  value: count,
}));
const READER_LIBRARY_SORT_OPTIONS: ReadonlyArray<{ value: ReaderLibrarySort; label: string }> = [
  { value: 'updated-desc', label: '업데이트순' },
  { value: 'created-desc', label: '최근등록순' },
];
const FALLBACK_BODY = [
  '인터넷 기사나 웹 콘텐츠를 전자책처럼 보려면 읽기 흐름을 먼저 정리해야 한다.',
  '복잡한 광고나 사이드바를 없애고 제목, 출처, 본문만 남기면 긴 글도 훨씬 집중해서 읽을 수 있다.',
  '이 리더는 URL 기준 자동 원문 가져오기와 페이지 분할을 함께 제공해 긴 글을 더 차분하게 읽게 돕는다.',
].join('\n\n');

const READER_THEME_OPTIONS: ReadonlyArray<{ value: ReaderTheme; label: string }> = [
  { value: 'mist', label: 'Mist' },
  { value: 'ocean', label: 'Ocean' },
  { value: 'night', label: 'Night' },
  { value: 'sepia', label: 'Sepia' },
  { value: 'forest', label: 'Forest' },
  { value: 'graphite', label: 'Graphite' },
  { value: 'rose', label: 'Rose' },
  { value: 'dawn', label: 'Dawn' },
];

const READER_NEWS_TOPIC_OPTIONS: ReadonlyArray<{ value: ReaderNewsTopic; label: string }> = [
  { value: 'politics', label: '정치' },
  { value: 'economy', label: '경제' },
  { value: 'society', label: '사회' },
  { value: 'culture', label: '생활/문화' },
  { value: 'world', label: '세계' },
  { value: 'it', label: 'IT/과학' },
];

const READER_ARTICLE_SEEDS: ReaderArticleSeed[] = [
  {
    id: 'sample-okky-crawler-risk',
    title: 'OKKY 인기글 발췌: AI가 위험한 진짜 이유, 크롤러가 남긴 운영 비용',
    sourceLabel: 'OKKY 인기글',
    url: 'https://okky.kr/articles/1531363',
    lead: 'OKKY에서 반응이 컸던 글을 바탕으로, AI 학습용 크롤러가 운영자에게 남기는 실제 비용과 피로를 짧게 정리했다.',
    tags: ['AI', '크롤러', '운영비용'],
    body: [
      '이 글은 AI 자체보다, AI 학습을 명분으로 돌아다니는 크롤러가 실제 서비스 운영자에게 주는 비용이 더 직접적인 위협일 수 있다는 문제의식에서 출발한다. 한 번 돌기 시작한 봇은 정상 사용자보다 훨씬 거칠게 링크를 타고, 로그와 트래픽을 빠르게 불려 버린다.',
      '작성자는 웹서버 로그와 AWS 사용량을 확인하면서, 짧은 기간에도 실제 콘텐츠 용량을 훨씬 넘는 요청이 발생한 흔적을 봤다고 설명한다. 핵심은 이 부하가 제품 가치와 무관한 크롤링에서 왔다는 점이다.',
      '흥미로운 부분은 단순히 특정 회사 봇만 막는 방식이 근본 해법이 아니었다는 대목이다. 링크 구조의 edge case와 인코딩 문제를 타는 저품질 크롤러가 섞여 있었고, 결국 서비스 설정과 링크 구조를 함께 봐야 했다는 정리로 이어진다.',
      '커뮤니티 반응도 컸다. AI 논의가 흔히 모델 성능이나 생산성에 몰리는 것과 달리, 이 글은 운영 비용과 저작권, 공개 저장소의 개방성 문제를 현실적인 톤으로 끌어냈기 때문이다.',
      '읽기 모드에서는 이런 글이 특히 잘 맞는다. 로그 예시와 운영 관찰, 결론이 단계적으로 이어지기 때문에 광고나 주변 UI 없이 본문만 보는 쪽이 맥락을 따라가기 쉽다.',
    ].join('\n\n'),
    htmlBody: [
      '<h2>AI 시대의 리스크가 모델 성능만은 아니라는 기록</h2>',
      '<p>이 글은 AI 자체보다, AI 학습을 명분으로 돌아다니는 크롤러가 실제 서비스 운영자에게 주는 비용이 더 직접적인 위협일 수 있다는 문제의식에서 출발한다. 한 번 돌기 시작한 봇은 정상 사용자보다 훨씬 거칠게 링크를 타고, 로그와 트래픽을 빠르게 불려 버린다.</p>',
      '<figure class="e-reader-html__figure"><img src="https://images.unsplash.com/photo-1518770660439-4636190af475?auto=format&amp;fit=crop&amp;w=1400&amp;q=80" alt="서버 장비와 모니터링 화면" loading="lazy" referrerpolicy="no-referrer" /><figcaption>사진 1. 커뮤니티 글의 핵심은 AI 모델보다 먼저 서버 비용과 로그 폭증이 운영자에게 체감된다는 점이다.</figcaption></figure>',
      '<p>작성자는 웹서버 로그와 사용량 지표를 확인하면서, 실제 콘텐츠 규모를 넘는 요청이 짧은 기간에 누적된 흔적을 봤다고 설명한다. 읽는 입장에서는 기술적 원인보다도, 왜 운영자가 피로를 느끼는지가 먼저 잡히는 글이다.</p>',
      '<p>흥미로운 부분은 특정 봇 차단만으로 끝나지 않는다는 점이다. 링크 구조의 edge case와 URI 인코딩 문제가 섞이면 저품질 크롤러가 증폭되고, 결국 서비스 설정과 링크 구조를 함께 다뤄야 한다는 결론으로 이어진다.</p>',
      '<figure class="e-reader-html__figure"><img src="https://images.unsplash.com/photo-1558494949-ef010cbdcc31?auto=format&amp;fit=crop&amp;w=1400&amp;q=80" alt="데이터센터 복도와 서버 랙" loading="lazy" referrerpolicy="no-referrer" /><figcaption>사진 2. 인프라 관점의 AI 담론은 숫자와 체감 비용이 함께 보일 때 더 강하게 전달된다.</figcaption></figure>',
      '<blockquote>커뮤니티 반응이 컸던 이유는 AI 논의를 생산성보다 운영 현실로 끌어내렸기 때문이다.</blockquote>',
    ].join(''),
  },
  {
    id: 'sample-okky-prism-memory',
    title: "OKKY 발췌: AI가 자꾸 까먹어서, 직접 '외장 뇌'를 붙인다는 발상",
    sourceLabel: 'OKKY 인기글',
    url: 'https://okky.kr/articles/1549361',
    lead: '대화 맥락이 휘발되는 문제를 로컬 저장과 장기 기억 개념으로 풀어 보려는 1인 개발자의 시도를 요약했다.',
    tags: ['AI', '장기기억', '로컬저장'],
    body: [
      '이 글의 출발점은 많은 사용자가 공감하는 장면이다. ChatGPT나 Claude와 길게 대화하다 보면, 방금 합의한 맥락이 어느 순간 흐려지고 모델이 딴소리를 하기 시작한다는 경험이다.',
      '작성자는 이 문제를 단순 프롬프트 기교가 아니라 기억 장치의 부재로 본다. 그래서 대화와 자료를 로컬 디스크에 보존하고, 필요할 때 다시 불러오는 외장 기억 구조를 제품 개념으로 제시한다.',
      '흥미로운 점은 기능보다 비유가 강하다는 것이다. 로컬 브리지, 비전 스트림, 슬랙 연동 같은 설명을 통해 AI를 채팅창 안 도구가 아니라 확장된 작업 파트너로 상상하게 만든다.',
      '커뮤니티에서 이 글이 주목받은 이유도 비슷하다. 화려한 모델 비교보다, AI와 오래 일할수록 생기는 컨텍스트 드리프트와 기록 주권 문제를 더 직설적으로 건드렸기 때문이다.',
      '읽기 앱용 발췌본으로 보면 한 번에 훑기 좋다. 문제 정의, 구조적 해결책, 철학까지 단계가 분명해서 페이지 단위 분할에도 잘 맞는다.',
    ].join('\n\n'),
    htmlBody: [
      '<h2>채팅창 밖에 기억을 두겠다는 아이디어</h2>',
      '<p>이 글의 출발점은 많은 사용자가 공감하는 장면이다. ChatGPT나 Claude와 길게 대화하다 보면, 방금 합의한 맥락이 어느 순간 흐려지고 모델이 다른 대답을 꺼내는 경험이다.</p>',
      '<p>작성자는 이 문제를 단순 프롬프트 기술이 아니라 기억 장치의 부재로 해석한다. 그래서 대화와 자료를 로컬 디스크에 보존하고, 필요할 때 다시 불러오는 외장 기억 구조를 제품 개념으로 제시한다.</p>',
      '<figure class="e-reader-html__figure"><img src="https://images.unsplash.com/photo-1515879218367-8466d910aaa4?auto=format&amp;fit=crop&amp;w=1400&amp;q=80" alt="개발자가 여러 화면에서 작업하는 장면" loading="lazy" referrerpolicy="no-referrer" /><figcaption>사진 1. 이 발췌본의 핵심 장면은 AI를 채팅창이 아니라 작업 환경 전체와 연결된 존재로 보는 시선이다.</figcaption></figure>',
      '<p>커뮤니티 반응은 제품 구현보다 철학에 더 가까웠다. 기억이 휘발되지 않기를 바라는 사용자 경험, 그리고 기록을 클라우드 바깥으로 되찾고 싶다는 감각이 동시에 읽혔기 때문이다.</p>',
      '<blockquote>핵심은 더 똑똑한 답변이 아니라, 맥락이 사라지지 않는 작업 루프를 만들겠다는 선언에 있다.</blockquote>',
    ].join(''),
  },
  {
    id: 'sample-okky-ai-levels',
    title: 'OKKY 정리글 발췌: AI 활용으로 도달할 수 있는 개발 수준 10단계',
    sourceLabel: 'OKKY 인기글',
    url: 'https://okky.kr/articles/1555082?topic=ai',
    lead: '단순 코드 생성에서 끝나지 않고, SQL·아키텍처·자동화·업무 시스템화까지 확장해 보는 관점을 짧게 묶었다.',
    tags: ['AI', '자동화', '개발성장'],
    body: [
      '이 글은 AI를 얼마나 잘 쓰느냐를 단순 프롬프트 숙련도보다, 개발 프로세스를 어디까지 시스템화할 수 있느냐의 문제로 본다. 그래서 포스트맨 대체 도구 만들기처럼 작은 자동화부터 시작해 점차 범위를 넓혀 간다.',
      '중간 단계에서는 SQL 분석, 외부 API 정합성, 코드 구조 고도화, DB 설계 같은 일상적인 개발 문제에 AI를 붙여 보는 흐름이 제시된다. 즉 결과물보다 루프를 정리하는 쪽에 더 무게가 있다.',
      '후반부는 더 공격적이다. RAG, 벡터 DB, 업무 자동화, 에이전트, 하네스 엔지니어링까지 나아가며 결국 조직 지식과 일정, 규칙을 응답 시스템으로 묶는 그림을 상상한다.',
      '그래서 이 글은 기술 튜토리얼이라기보다 체크리스트에 가깝다. 지금 내가 AI를 어디에 붙이고 있고, 아직 손대지 않은 병목은 무엇인지 역으로 생각하게 만든다.',
      '서재 샘플로는 단계별 시야를 넓히는 글 역할을 한다. 제품 글과 뉴스형 글 사이에 두면 읽기 흐름도 자연스럽다.',
    ].join('\n\n'),
    htmlBody: [
      '<h2>AI 활용을 능숙함이 아니라 시스템화로 보는 시선</h2>',
      '<p>이 글은 AI를 얼마나 잘 쓰느냐를 단순 프롬프트 숙련도보다, 개발 프로세스를 어디까지 시스템화할 수 있느냐의 문제로 본다. 그래서 작은 자동화부터 시작해 점차 범위를 넓혀 가자는 제안이 중심에 놓여 있다.</p>',
      '<figure class="e-reader-html__figure"><img src="https://images.unsplash.com/photo-1451187580459-43490279c0fa?auto=format&amp;fit=crop&amp;w=1400&amp;q=80" alt="네트워크와 데이터 흐름을 표현한 추상 이미지" loading="lazy" referrerpolicy="no-referrer" /><figcaption>사진 1. 글이 말하는 성장 곡선은 기능 하나보다 연결된 업무 흐름 전체를 보는 시각에 가깝다.</figcaption></figure>',
      '<p>중간 단계에서는 SQL 분석, 외부 API 정합성, 코드 구조 고도화, DB 설계 같은 실전 업무가 반복해서 등장한다. 화려한 데모보다 현업에 바로 닿는 주제를 앞세우는 점이 이 글의 강점이다.</p>',
      '<p>후반부는 RAG, 에이전트, 업무 응답 봇까지 나아가며 결국 지식과 의사결정을 축적 가능한 시스템으로 묶는 방향을 가리킨다.</p>',
      '<blockquote>결국 이 발췌본은 AI를 도구가 아니라 팀의 작업 방식을 재조립하는 계기로 보자는 제안이다.</blockquote>',
    ].join(''),
  },
  {
    id: 'sample-geeknews-ai-growth',
    title: 'GeekNews 인기글 발췌: AI와 함께 일하며 복리처럼 성장하는 법',
    sourceLabel: 'GeekNews 인기글',
    url: 'https://news.hada.io/topic?id=29606',
    lead: 'AI 협업의 핵심을 한 번의 정답보다, 다음 세션까지 남는 컨텍스트와 검증 루프로 해석한 글을 읽기용으로 요약했다.',
    tags: ['AI', '협업', '컨텍스트'],
    body: [
      '이 글은 AI를 잘 쓰는 방법을 모델 선택 요령보다 작업 축적 방식으로 설명한다. 코드, 문서, 분석, 결정이 다음 세션의 컨텍스트로 남을 때 생산성이 단발성 향상이 아니라 복리처럼 쌓인다는 주장이다.',
      '그래서 중요한 것은 한 번의 프롬프트가 아니라, 설정 파일과 가이드, 검증 루틴을 코드처럼 관리하는 습관이 된다. 사람이 반복해서 같은 실수를 교정하지 않게 만드는 운영 방식에 더 가깝다.',
      '읽는 사람 입장에서는 화려한 성공담보다 재현 가능한 루프를 설명한다는 점이 좋다. 컨텍스트 제공, 취향 설정, 검증 자동화, 위임 확대, 피드백 루프처럼 개념 축이 분명해 긴 글이어도 따라가기 쉽다.',
      '서재 샘플에서는 실무 톤의 긴 글 역할을 맡는다. 앞선 OKKY 글들이 현장 감각을 건드린다면, 이 글은 그 감각을 운영 원칙으로 정리해 준다.',
    ].join('\n\n'),
  },
  {
    id: 'sample-geeknews-ai-spec',
    title: 'GeekNews 읽을거리 발췌: AI 에이전트를 위한 좋은 스펙 작성법',
    sourceLabel: 'GeekNews 읽을거리',
    url: 'https://news.hada.io/topic?id=25949',
    lead: '잘 만든 스펙이 AI 에이전트의 성능을 얼마나 좌우하는지, 명령어·테스트·프로젝트 구조 관점으로 묶은 글이다.',
    tags: ['AI', '에이전트', '스펙'],
    body: [
      '이 글은 에이전트가 자꾸 엇나가는 이유를 모델 성능 부족보다 스펙의 품질 문제로 본다. 문서가 메모처럼 흩어져 있으면 AI는 무엇을 실행하고 무엇을 검증해야 하는지 일관되게 파악하기 어렵다.',
      '그래서 좋은 스펙은 전문적인 PRD나 SRS처럼 구조화되어야 한다는 제안이 나온다. 실행 명령, 테스트 방법, 프로젝트 구조, 제약조건, 인간이 개입해야 하는 경계가 앞쪽에 또렷하게 놓여야 한다는 정리다.',
      '특히 인상적인 부분은 스펙을 금지 목록이 아니라 코치와 심판의 혼합물로 보자는 관점이다. 에이전트가 언제 바로 진행하고, 언제 멈춰 묻고, 언제 아예 중단해야 하는지 단계적으로 알려줘야 한다.',
      '개발 서재에 넣기 좋은 이유도 분명하다. 추상적인 AI 담론이 아니라 지금 저장소 문서에 무엇을 써야 하는지 바로 떠올리게 만드는 글이기 때문이다.',
    ].join('\n\n'),
    htmlBody: [
      '<h2>AI 에이전트는 좋은 스펙을 만나야 덜 흔들린다</h2>',
      '<p>이 글은 에이전트가 자꾸 엇나가는 이유를 모델 성능 부족보다 스펙의 품질 문제로 본다. 문서가 메모처럼 흩어져 있으면 AI는 무엇을 실행하고 무엇을 검증해야 하는지 일관되게 파악하기 어렵다.</p>',
      '<p>그래서 좋은 스펙은 PRD나 SRS처럼 구조화되어야 한다는 제안이 나온다. 실행 명령, 테스트 방법, 프로젝트 구조, 제약조건, 인간이 개입해야 하는 경계가 앞쪽에 또렷하게 놓여야 한다.</p>',
      '<figure class="e-reader-html__figure"><img src="https://images.unsplash.com/photo-1454165804606-c3d57bc86b40?auto=format&amp;fit=crop&amp;w=1400&amp;q=80" alt="회의 테이블 위 문서와 노트북" loading="lazy" referrerpolicy="no-referrer" /><figcaption>사진 1. 좋은 스펙은 아이디어 메모가 아니라 실행 순서와 판단 기준이 정리된 운영 문서에 가깝다.</figcaption></figure>',
      '<p>특히 인상적인 부분은 스펙을 금지 목록이 아니라 코치와 심판의 혼합물로 보자는 관점이다. 에이전트가 언제 바로 진행하고, 언제 멈춰 묻고, 언제 아예 중단해야 하는지 단계적으로 알려줘야 한다.</p>',
      '<figure class="e-reader-html__figure"><img src="https://images.unsplash.com/photo-1516321497487-e288fb19713f?auto=format&amp;fit=crop&amp;w=1400&amp;q=80" alt="화이트보드 앞에서 계획을 점검하는 팀" loading="lazy" referrerpolicy="no-referrer" /><figcaption>사진 2. 테스트와 제약조건을 문서에 미리 심어 두면 AI는 사람의 감독 없이도 덜 엇나간다.</figcaption></figure>',
      '<blockquote>좋은 스펙은 AI를 더 똑똑하게 만들기보다, 덜 헷갈리게 만든다.</blockquote>',
    ].join(''),
  },
  {
    id: 'sample-geeknews-llm-building',
    title: 'GeekNews 긴 글 발췌: 1년 동안 LLM과 함께 구축하며 배운 점',
    sourceLabel: 'GeekNews 긴 글',
    url: 'https://news.hada.io/topic?id=15268',
    lead: 'LLM이 데모를 넘어 제품으로 들어올 때 무엇이 어려워지는지, 데이터와 평가 중심으로 압축한 발췌본이다.',
    tags: ['LLM', '데이터', '평가'],
    body: [
      '이 글은 지난 1년 사이 LLM이 충분히 좋아졌지만, 실제 제품으로 붙이는 일은 여전히 별개의 문제라는 점을 차분하게 정리한다. 모델 성능만 높아졌다고 운영 품질이 자동으로 확보되지는 않는다는 뜻이다.',
      '가장 자주 강조되는 축은 데이터와 평가다. 프로덕션에서 만나는 입력 분포를 제대로 보지 않으면 개발 중에 좋아 보이던 시스템이 실제 사용자 앞에서 쉽게 무너질 수 있다는 설명이 이어진다.',
      '또 하나는 비용과 구조다. 모든 문제에 가장 큰 모델을 붙이는 대신, 더 작은 도구와 명확한 파이프라인으로 같은 목적을 달성할 수 있는지 계속 검토해야 한다는 태도가 반복된다.',
      '그래서 이 글은 LLM 낙관론과 비관론 사이에서 드문 균형을 준다. 가능성은 인정하되, 결국 해자를 만드는 것은 데이터 품질과 평가 체계, 제품 UX라는 점을 잊지 않게 한다.',
    ].join('\n\n'),
    htmlBody: [
      '<h2>LLM 제품화는 데모 이후가 더 어렵다</h2>',
      '<p>이 글은 지난 1년 사이 LLM이 충분히 좋아졌지만, 실제 제품으로 붙이는 일은 여전히 별개의 문제라는 점을 차분하게 정리한다. 모델 성능 향상만으로 운영 품질이 자동으로 확보되지는 않는다.</p>',
      '<figure class="e-reader-html__figure"><img src="https://images.unsplash.com/photo-1518186285589-2f7649de83e0?auto=format&amp;fit=crop&amp;w=1400&amp;q=80" alt="대시보드와 데이터 화면을 바라보는 작업 환경" loading="lazy" referrerpolicy="no-referrer" /><figcaption>사진 1. 긴 글이 반복해서 강조하는 축은 모델 선택보다 데이터 분포와 평가 루프다.</figcaption></figure>',
      '<p>가장 자주 언급되는 문제는 개발-프로덕션 편향이다. 내부 테스트에서는 잘 되지만 실제 입력 분포를 만나면 무너지는 패턴을 막으려면, 사용 데이터를 지속적으로 들여다보는 습관이 필요하다.</p>',
      '<figure class="e-reader-html__figure"><img src="https://images.unsplash.com/photo-1526628953301-3e589a6a8b74?auto=format&amp;fit=crop&amp;w=1400&amp;q=80" alt="분석 화면과 노트북이 놓인 책상" loading="lazy" referrerpolicy="no-referrer" /><figcaption>사진 2. 결국 제품 해자는 더 큰 모델보다도 데이터 품질, 평가 설계, UX 조합에서 만들어진다.</figcaption></figure>',
      '<blockquote>LLM 시대에도 끝까지 남는 경쟁력은 입력 데이터와 평가 체계를 얼마나 잘 운영하느냐다.</blockquote>',
    ].join(''),
  },
];

function buildArticleId() {
  return `custom-${Date.now().toString(36)}`;
}

function normalizeArticleUrl(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, '');
  }
}

function normalizeLineBreaks(value: string) {
  return value.replace(/\r\n?/gu, '\n').trim();
}

function normalizeTags(tags: string[] | undefined) {
  if (!tags?.length) {
    return [];
  }

  return Array.from(
    new Set(
      tags
        .map((tag) => tag.replace(/^#/u, '').trim())
        .filter(Boolean),
    ),
  );
}

function normalizePublishedAt(value: string | undefined) {
  if (!value?.trim()) {
    return undefined;
  }

  const trimmedValue = value.trim();

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

  const parsedDate = new Date(trimmedValue);

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

  return parsedDate.toISOString();
}

function formatPublishedAt(value: string | undefined) {
  if (!value?.trim()) {
    return '';
  }

  const trimmedValue = value.trim();
  const normalizedValue = normalizePublishedAt(trimmedValue);

  if (!normalizedValue) {
    return '';
  }

  const date = new Date(normalizedValue);

  if (Number.isNaN(date.getTime())) {
    return normalizedValue;
  }

  const hasExplicitTime = /(?:[t\s]\d{1,2}:\d{2}(?::\d{2})?(?:\.\d+)?)|(?:오전|오후)\s*\d{1,2}:\d{2}/iu.test(trimmedValue);

  return new Intl.DateTimeFormat(
    'ko-KR',
    hasExplicitTime
      ? {
          year: 'numeric',
          month: '2-digit',
          day: '2-digit',
          hour: '2-digit',
          minute: '2-digit',
          hour12: false,
        }
      : {
          year: 'numeric',
          month: '2-digit',
          day: '2-digit',
        },
  ).format(date);
}

function formatDateInputValue(date: Date) {
  const year = date.getFullYear();
  const month = `${date.getMonth() + 1}`.padStart(2, '0');
  const day = `${date.getDate()}`.padStart(2, '0');
  return `${year}-${month}-${day}`;
}

function normalizeListedDate(value: string | undefined) {
  const trimmedValue = value?.trim() ?? '';
  return /^\d{4}-\d{2}-\d{2}$/u.test(trimmedValue) ? trimmedValue : undefined;
}

function buildArticleTimelineDetails(article: Pick<ReaderArticleSeed, 'listedDate' | 'publishedAt'>) {
  const details: string[] = [];

  if (article.listedDate) {
    details.push(`목록 기준 ${formatPublishedAt(article.listedDate)}`);
  }

  if (article.publishedAt) {
    details.push(`원본 등록 ${formatPublishedAt(article.publishedAt)}`);
  }

  return details;
}

function createDefaultNewsFilters(): ReaderNewsFilters {
  const today = new Date();

  return {
    keyword: '',
    topics: [],
    sources: [],
    dateFrom: formatDateInputValue(today),
    dateTo: formatDateInputValue(today),
    limit: 24,
  };
}

function createDefaultNewsFiltersStatus() {
  return '선택한 날짜 범위의 네이버 목록을 기준으로 수집하고, 카드에는 목록 기준일과 원본 등록 시각을 함께 표시합니다.';
}

function buildNewsArticleSeed(item: EReaderNewsArticle) {
  return {
    id: item.id,
    title: item.title,
    sourceLabel: item.sourceLabel,
    url: item.url,
    lead: item.lead,
    body: item.body,
    htmlBody: item.htmlBody,
    tags: normalizeTags(item.tags),
    signals: Array.isArray(item.signals) ? item.signals.filter((signal) => typeof signal === 'string' && signal.trim()) : undefined,
    listedDate: normalizeListedDate(item.listedDate),
    publishedAt: normalizePublishedAt(item.publishedAt),
    createdAt: typeof item.createdAt === 'string' && item.createdAt.trim() ? item.createdAt : undefined,
    updatedAt: typeof item.updatedAt === 'string' && item.updatedAt.trim() ? item.updatedAt : undefined,
  } satisfies ReaderArticleSeed;
}

function resolveSortableTimestamp(value: string | undefined) {
  if (!value?.trim()) {
    return 0;
  }

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

function compareReaderArticlesBySort(left: ReaderArticle, right: ReaderArticle, sort: ReaderLibrarySort) {
  const sortKey = sort === 'created-desc' ? 'createdAt' : 'updatedAt';
  const primaryGap = resolveSortableTimestamp(right[sortKey]) - resolveSortableTimestamp(left[sortKey]);

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

  const publishedGap = resolveSortableTimestamp(right.publishedAt) - resolveSortableTimestamp(left.publishedAt);
  if (publishedGap !== 0) {
    return publishedGap;
  }

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

function resolveNewsSignalTone(signal: string) {
  if (signal === '속보') {
    return 'error';
  }

  if (signal === '핵심') {
    return 'gold';
  }

  return 'processing';
}

function inferArticleTags(seed: Pick<ReaderArticleSeed, 'sourceLabel' | 'title' | 'lead' | 'body'>) {
  const haystack = `${seed.sourceLabel} ${seed.title} ${seed.lead} ${seed.body}`.toLowerCase();
  const detectedTags = new Set<string>();

  if (haystack.includes('ai')) {
    detectedTags.add('AI');
  }
  if (haystack.includes('llm')) {
    detectedTags.add('LLM');
  }
  if (haystack.includes('okky')) {
    detectedTags.add('OKKY');
  }
  if (haystack.includes('geeknews')) {
    detectedTags.add('GeekNews');
  }
  if (haystack.includes('에이전트')) {
    detectedTags.add('에이전트');
  }
  if (haystack.includes('자동화')) {
    detectedTags.add('자동화');
  }
  if (haystack.includes('데이터')) {
    detectedTags.add('데이터');
  }
  if (haystack.includes('저장') || haystack.includes('clip')) {
    detectedTags.add('저장글');
  }

  return Array.from(detectedTags);
}

function splitIntoParagraphs(body: string) {
  return normalizeLineBreaks(body)
    .split(/\n{2,}/u)
    .map((paragraph) => paragraph.trim())
    .filter(Boolean);
}

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

function stripHtmlForMeasure(value: string) {
  return value
    .replace(/<img\b[^>]*>/giu, ' [image] ')
    .replace(/<[^>]+>/gu, ' ')
    .replace(/\s+/gu, ' ')
    .trim();
}

function buildReaderPageTitleMarkup(title: string) {
  const normalizedTitle = title.trim() || 'Untitled';
  return `<h1 class="e-reader__page-title">${escapeHtml(normalizedTitle)}</h1>`;
}

function splitHtmlIntoBlocks(htmlBody: string) {
  const blockMatches = htmlBody.match(/<figure\b[\s\S]*?<\/figure>|<(?:h1|h2|h3|p|blockquote|ul)\b[\s\S]*?<\/(?:h1|h2|h3|p|blockquote|ul)>/giu);
  return blockMatches?.map((block) => block.trim()).filter(Boolean) ?? [];
}

function buildTextBlock(tag: 'blockquote' | 'h2' | 'h3' | 'p', text: string): ReaderPageBlock {
  const normalizedText = text.trim();
  return {
    tag,
    markup: `<${tag}>${escapeHtml(normalizedText)}</${tag}>`,
    text: normalizedText,
    splittable: true,
  };
}

function buildReaderPageBlocks(htmlBody: string | undefined, body: string) {
  const htmlBlocks = htmlBody?.trim() ? splitHtmlIntoBlocks(htmlBody) : [];

  if (htmlBlocks.length) {
    return htmlBlocks.map((block) => {
      const tagMatch = block.match(/^<([a-z0-9]+)/iu);
      const tag = (tagMatch?.[1]?.toLowerCase() ?? 'p') as ReaderPageBlock['tag'];
      return {
        tag,
        markup: block,
        text: stripHtmlForMeasure(block),
        splittable: tag !== 'figure' && tag !== 'ul',
      } satisfies ReaderPageBlock;
    });
  }

  return splitIntoParagraphs(body).map((paragraph) => buildTextBlock('p', paragraph));
}

function buildPageTokens(body: string, pageSize: number) {
  const paragraphs = splitIntoParagraphs(body);

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

  const pages: string[] = [];
  let currentPage = '';

  paragraphs.forEach((paragraph) => {
    const nextValue = currentPage ? `${currentPage}\n\n${paragraph}` : paragraph;

    if (currentPage && nextValue.length > pageSize) {
      pages.push(currentPage);
      currentPage = paragraph;
      return;
    }

    if (paragraph.length > pageSize) {
      if (currentPage) {
        pages.push(currentPage);
        currentPage = '';
      }

      const sentences = paragraph.split(/(?<=[.!?।…])\s+/u).filter(Boolean);
      let sentenceBucket = '';

      sentences.forEach((sentence) => {
        const nextSentenceBucket = sentenceBucket ? `${sentenceBucket} ${sentence}` : sentence;

        if (sentenceBucket && nextSentenceBucket.length > pageSize) {
          pages.push(sentenceBucket);
          sentenceBucket = sentence;
          return;
        }

        sentenceBucket = nextSentenceBucket;
      });

      if (sentenceBucket) {
        currentPage = sentenceBucket;
      }

      return;
    }

    currentPage = nextValue;
  });

  if (currentPage) {
    pages.push(currentPage);
  }

  return pages.length ? pages : [FALLBACK_BODY];
}

function buildHtmlPageTokens(htmlBody: string | undefined, body: string, pageSize: number) {
  if (!htmlBody?.trim()) {
    return buildPageTokens(body, pageSize);
  }

  const blocks = splitHtmlIntoBlocks(htmlBody);

  if (!blocks.length) {
    return buildPageTokens(body, pageSize);
  }

  const pages: string[] = [];
  let currentPageBlocks: string[] = [];
  let currentWeight = 0;

  blocks.forEach((block) => {
    const blockWeight = /<figure\b/iu.test(block) ? Math.max(320, Math.round(pageSize * 0.58)) : Math.max(60, stripHtmlForMeasure(block).length);

    if (currentPageBlocks.length && currentWeight + blockWeight > pageSize) {
      pages.push(currentPageBlocks.join(''));
      currentPageBlocks = [block];
      currentWeight = blockWeight;
      return;
    }

    currentPageBlocks.push(block);
    currentWeight += blockWeight;
  });

  if (currentPageBlocks.length) {
    pages.push(currentPageBlocks.join(''));
  }

  return pages.length ? pages : buildPageTokens(body, pageSize);
}

function resolvePageSize(width: number, fontSize: number) {
  if (width <= 420) {
    return Math.max(340, Math.round(500 - fontSize * 10));
  }

  if (width <= 860) {
    return Math.max(540, Math.round(820 - fontSize * 12));
  }

  return Math.max(720, Math.round(1160 - fontSize * 16));
}

function buildReaderArticle(seed: ReaderArticleSeed, pageSize: number): ReaderArticle {
  return {
    ...seed,
    tags: normalizeTags(seed.tags),
    pageTokens: buildHtmlPageTokens(seed.htmlBody, seed.body, pageSize),
  };
}

function clampIndex(value: number, pageCount: number) {
  if (pageCount <= 0) {
    return 0;
  }

  return Math.min(Math.max(value, 0), pageCount - 1);
}

function normalizeSearchKeyword(value: string) {
  return value.replace(/#/gu, '').trim().toLowerCase();
}

function buildReadHistoryKeys(article: Pick<ReaderArticleSeed, 'id' | 'url'> | Pick<EReaderNewsArticle, 'id' | 'url'>) {
  const keys = new Set<string>();

  if (article.id.trim()) {
    keys.add(`id:${article.id}`);
  }

  const normalizedUrl = normalizeArticleUrl(article.url);
  if (normalizedUrl) {
    keys.add(`url:${normalizedUrl}`);
  }

  return Array.from(keys);
}

function readStoredReadHistory() {
  if (typeof window === 'undefined') {
    return [];
  }

  try {
    const raw = window.localStorage.getItem(READER_READ_HISTORY_STORAGE_KEY);

    if (!raw) {
      return [];
    }

    const parsed = JSON.parse(raw) as unknown;
    if (!Array.isArray(parsed)) {
      return [];
    }

    return Array.from(
      new Set(
        parsed
          .filter((item): item is string => typeof item === 'string')
          .map((item) => item.trim())
          .filter(Boolean),
      ),
    );
  } catch {
    return [];
  }
}

function writeStoredReadHistory(items: string[]) {
  if (typeof window === 'undefined') {
    return;
  }

  window.localStorage.setItem(READER_READ_HISTORY_STORAGE_KEY, JSON.stringify(Array.from(new Set(items))));
}

function chunkArticles<T>(items: T[], pageSize: number) {
  if (pageSize <= 0) {
    return [items];
  }

  const pages: T[][] = [];

  for (let index = 0; index < items.length; index += pageSize) {
    pages.push(items.slice(index, index + pageSize));
  }

  return pages.length ? pages : [[]];
}

function resolveLibraryColumns(width: number) {
  if (width <= 720) {
    return 2;
  }

  if (width <= 1040) {
    return 3;
  }

  return 4;
}

function resolveLibraryRows(width: number, height: number) {
  if (width <= 720) {
    return height <= 760 ? 2 : 3;
  }

  return height <= 780 ? 2 : 3;
}

function normalizeStoredArticleSeed(parsed: Partial<ReaderArticleSeed> | null | undefined) {
  if (!parsed?.title || !parsed?.body) {
    return null;
  }

  return {
    id: typeof parsed.id === 'string' && parsed.id ? parsed.id : buildArticleId(),
    title: parsed.title,
    sourceLabel: typeof parsed.sourceLabel === 'string' && parsed.sourceLabel ? parsed.sourceLabel : 'Saved Clip',
    url: typeof parsed.url === 'string' && parsed.url ? parsed.url : DEFAULT_SOURCE_URL,
    lead: typeof parsed.lead === 'string' && parsed.lead ? parsed.lead : '저장한 웹 클립',
    body: parsed.body,
    htmlBody: typeof parsed.htmlBody === 'string' && parsed.htmlBody ? parsed.htmlBody : undefined,
    tags: normalizeTags(Array.isArray(parsed.tags) ? parsed.tags.filter((tag): tag is string => typeof tag === 'string') : inferArticleTags({
      sourceLabel: typeof parsed.sourceLabel === 'string' ? parsed.sourceLabel : 'Saved Clip',
      title: parsed.title,
      lead: typeof parsed.lead === 'string' ? parsed.lead : '저장한 웹 클립',
      body: parsed.body,
    })),
    signals: Array.isArray(parsed.signals)
      ? parsed.signals.filter((signal): signal is string => typeof signal === 'string' && signal.trim())
      : undefined,
    listedDate: normalizeListedDate(typeof parsed.listedDate === 'string' ? parsed.listedDate : undefined),
    publishedAt: normalizePublishedAt(typeof parsed.publishedAt === 'string' ? parsed.publishedAt : undefined),
    createdAt: typeof parsed.createdAt === 'string' && parsed.createdAt.trim() ? parsed.createdAt : undefined,
    updatedAt: typeof parsed.updatedAt === 'string' && parsed.updatedAt.trim() ? parsed.updatedAt : undefined,
  } satisfies ReaderArticleSeed;
}

function mergeSavedArticleSeeds(...groups: ReaderArticleSeed[][]) {
  const seenIds = new Set<string>();
  const seenUrls = new Set<string>();
  const merged: ReaderArticleSeed[] = [];

  groups.forEach((items) => {
    items.forEach((item) => {
      const normalizedUrl = normalizeArticleUrl(item.url);

      if (!item.id || seenIds.has(item.id) || (normalizedUrl && seenUrls.has(normalizedUrl))) {
        return;
      }

      seenIds.add(item.id);
      if (normalizedUrl) {
        seenUrls.add(normalizedUrl);
      }
      merged.push(item);
    });
  });

  return merged;
}

function upsertSavedArticleSeed(items: ReaderArticleSeed[], article: ReaderArticleSeed) {
  const normalizedUrl = normalizeArticleUrl(article.url);
  const duplicatedArticle =
    items.find((item) => item.id === article.id)
    ?? items.find((item) => normalizedUrl && normalizeArticleUrl(item.url) === normalizedUrl);
  const timestamp = new Date().toISOString();
  const nextArticle = {
    ...article,
    createdAt: article.createdAt ?? duplicatedArticle?.createdAt ?? timestamp,
    updatedAt: timestamp,
  } satisfies ReaderArticleSeed;

  return [
    nextArticle,
    ...items.filter((item) => {
      if (item.id === nextArticle.id) {
        return false;
      }

      if (!normalizedUrl) {
        return true;
      }

      return normalizeArticleUrl(item.url) !== normalizedUrl;
    }),
  ];
}

function writeStoredArticles(items: ReaderArticleSeed[]) {
  if (typeof window === 'undefined') {
    return;
  }

  window.localStorage.setItem(READER_STORAGE_KEY, JSON.stringify(items));
}

function readStoredArticles() {
  if (typeof window === 'undefined') {
    return [];
  }

  try {
    const raw = window.localStorage.getItem(READER_STORAGE_KEY);

    if (!raw) {
      return [];
    }

    const parsed = JSON.parse(raw) as Partial<ReaderArticleSeed> | Partial<ReaderArticleSeed>[] | null;

    if (Array.isArray(parsed)) {
      return parsed
        .map((item) => normalizeStoredArticleSeed(item))
        .filter((item): item is ReaderArticleSeed => Boolean(item));
    }

    const single = normalizeStoredArticleSeed(parsed);
    return single ? [single] : [];
  } catch {
    return [];
  }
}

function readStoredSettings() {
  if (typeof window === 'undefined') {
    return null;
  }

  try {
    const raw = window.localStorage.getItem(READER_SETTINGS_STORAGE_KEY);

    if (!raw) {
      return null;
    }

    const parsed = JSON.parse(raw) as Partial<{
      fontSize: number;
      gestureMode: ReaderGestureMode;
      libraryNavigationMode: ReaderLibraryNavigationMode;
      theme: ReaderTheme;
    }> | null;

    return {
      fontSize: typeof parsed?.fontSize === 'number' ? parsed.fontSize : undefined,
      gestureMode: parsed?.gestureMode === 'touch-scroll' ? 'touch-scroll' : parsed?.gestureMode === 'tap-swipe' ? 'tap-swipe' : undefined,
      libraryNavigationMode:
        parsed?.libraryNavigationMode === 'touch-swipe'
          ? 'touch-swipe'
          : parsed?.libraryNavigationMode === 'scroll'
          ? 'scroll'
            : undefined,
      theme: READER_THEME_OPTIONS.some((option) => option.value === parsed?.theme) ? parsed?.theme : undefined,
    };
  } catch {
    return null;
  }
}

function renderPageParagraphs(page: string) {
  return splitIntoParagraphs(page).map((paragraph, index) => <p key={`${index}-${paragraph.slice(0, 24)}`}>{paragraph}</p>);
}

function renderPageBody(page: string) {
  if (/<(?:figure|h1|h2|h3|blockquote|ul|p)\b/iu.test(page)) {
    return <div className="e-reader__page-html" dangerouslySetInnerHTML={{ __html: page }} />;
  }

  return renderPageParagraphs(page);
}

function waitForAnimationFrame() {
  return new Promise<void>((resolve) => {
    if (typeof window === 'undefined') {
      resolve();
      return;
    }

    window.requestAnimationFrame(() => resolve());
  });
}

function isStandaloneDisplayMode() {
  if (typeof window === 'undefined') {
    return false;
  }

  return (
    window.matchMedia?.('(display-mode: standalone)').matches === true ||
    (window.navigator as Navigator & { standalone?: boolean }).standalone === true
  );
}

function getReaderLaunchPath() {
  return buildPlayAppPath('e-reader');
}

function getReaderLaunchUrl() {
  if (typeof window === 'undefined') {
    return getReaderLaunchPath();
  }

  return new URL(getReaderLaunchPath(), window.location.origin).toString();
}

function normalizeShareToken(value: string | null | undefined) {
  return value?.trim() ?? '';
}

function readShareTokenFromUrl() {
  if (typeof window === 'undefined') {
    return '';
  }

  return normalizeShareToken(new URLSearchParams(window.location.search).get('shareToken'));
}

function getInstallGuideMessage() {
  if (typeof navigator !== 'undefined' && /iPhone|iPad|iPod/i.test(navigator.userAgent)) {
    return 'Safari 공유 메뉴에서 홈 화면에 추가를 선택하면 E-Reader 전용 아이콘으로 저장됩니다.';
  }

  return '브라우저 메뉴의 홈 화면에 추가 또는 앱 설치를 사용하면 E-Reader를 바로 열 수 있습니다.';
}

async function createEReaderManifestObjectUrl(options?: {
  registeredToken?: string | null;
  shareToken?: string | null;
}) {
  if (typeof window === 'undefined') {
    return '';
  }

  const response = await window.fetch(E_READER_MANIFEST_PATH, {
    cache: 'no-store',
  });

  if (!response.ok) {
    throw new Error(`manifest fetch failed: ${response.status}`);
  }

  const manifest = (await response.json()) as Record<string, unknown>;
  const startUrl = new URL(
    typeof manifest.start_url === 'string' && manifest.start_url.trim() ? manifest.start_url : getReaderLaunchPath(),
    window.location.origin,
  );
  const registeredToken = options?.registeredToken?.trim() ?? '';
  const shareToken = normalizeShareToken(options?.shareToken);

  if (registeredToken) {
    startUrl.searchParams.set('registeredAccessToken', registeredToken);
  }

  if (shareToken) {
    startUrl.searchParams.set('shareToken', shareToken);
  }

  manifest.scope = startUrl.pathname;
  manifest.start_url = `${startUrl.pathname}${startUrl.search}${startUrl.hash}`;

  return window.URL.createObjectURL(
    new Blob([JSON.stringify(manifest, null, 2)], {
      type: 'application/manifest+json',
    }),
  );
}

function swapManifestForEReader(manifestHref = E_READER_MANIFEST_PATH) {
  if (typeof document === 'undefined') {
    return () => undefined;
  }

  const head = document.head;
  let manifestLink = document.querySelector<HTMLLinkElement>('link[rel="manifest"]');
  const createdManifestLink = !manifestLink;

  if (!manifestLink) {
    manifestLink = document.createElement('link');
    manifestLink.rel = 'manifest';
    head.appendChild(manifestLink);
  }

  const previousManifestHref = manifestLink.href;
  manifestLink.href = manifestHref;

  let appleTitleMeta = document.querySelector<HTMLMetaElement>('meta[name="apple-mobile-web-app-title"]');
  const createdAppleTitleMeta = !appleTitleMeta;
  const previousAppleTitle = appleTitleMeta?.content ?? null;

  if (!appleTitleMeta) {
    appleTitleMeta = document.createElement('meta');
    appleTitleMeta.name = 'apple-mobile-web-app-title';
    head.appendChild(appleTitleMeta);
  }

  appleTitleMeta.content = 'E-Reader';

  return () => {
    if (createdManifestLink) {
      manifestLink?.remove();
    } else if (manifestLink) {
      manifestLink.href = previousManifestHref;
    }

    if (createdAppleTitleMeta) {
      appleTitleMeta?.remove();
    } else if (appleTitleMeta) {
      appleTitleMeta.content = previousAppleTitle ?? '';
    }
  };
}

async function waitForPageAssets(root: HTMLElement | null) {
  await waitForAnimationFrame();
  await waitForAnimationFrame();

  if (!root) {
    return;
  }

  const images = Array.from(root.querySelectorAll('img'));
  if (!images.length) {
    return;
  }

  await Promise.all(
    images.map(
      (image) =>
        new Promise<void>((resolve) => {
          const finalize = () => {
            image.removeEventListener('load', finalize);
            image.removeEventListener('error', finalize);
            resolve();
          };

          if (image.complete) {
            if (typeof image.decode === 'function') {
              image.decode().catch(() => undefined).finally(resolve);
              return;
            }

            resolve();
            return;
          }

          image.addEventListener('load', finalize, { once: true });
          image.addEventListener('error', finalize, { once: true });
        }),
    ),
  );

  await waitForAnimationFrame();
}

function splitBlockForMeasurement(block: ReaderPageBlock) {
  if (!block.splittable || !block.text) {
    return [block];
  }

  const units = block.text.split(/(?<=[.!?।…])\s+/u).filter(Boolean);
  const fallbackUnits = units.length > 1 ? units : block.text.split(/\s+/u).filter(Boolean);

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

  const chunks: ReaderPageBlock[] = [];
  let currentChunk = '';

  fallbackUnits.forEach((unit) => {
    const nextChunk = currentChunk ? `${currentChunk} ${unit}` : unit;

    if (currentChunk && nextChunk.length > 260) {
      chunks.push(buildTextBlock(block.tag === 'blockquote' ? 'blockquote' : 'p', currentChunk));
      currentChunk = unit;
      return;
    }

    currentChunk = nextChunk;
  });

  if (currentChunk) {
    chunks.push(buildTextBlock(block.tag === 'blockquote' ? 'blockquote' : 'p', currentChunk));
  }

  return chunks.length ? chunks : [block];
}

function paginateBlocksByMeasurement(blocks: ReaderPageBlock[], pageBody: HTMLDivElement) {
  const pages: string[] = [];
  let currentBlocks: ReaderPageBlock[] = [];
  const titleMarkup = buildReaderPageTitleMarkup(pageBody.dataset.articleTitle ?? '');

  const buildPageMarkup = (candidateBlocks: ReaderPageBlock[], isFirstPage: boolean) =>
    `${isFirstPage ? titleMarkup : ''}${candidateBlocks.map((block) => block.markup).join('')}`;

  const applyBlocks = (candidateBlocks: ReaderPageBlock[]) => {
    pageBody.innerHTML = buildPageMarkup(candidateBlocks, pages.length === 0);
    return pageBody.scrollHeight <= pageBody.clientHeight + 1;
  };

  const pushCurrentPage = () => {
    if (!currentBlocks.length) {
      return;
    }

    pages.push(buildPageMarkup(currentBlocks, pages.length === 0));
    currentBlocks = [];
  };

  const pushTitleOnlyFirstPage = () => {
    if (pages.length > 0) {
      return;
    }

    pages.push(buildPageMarkup([], true));
  };

  blocks.forEach((block) => {
    const nextBlocks = [...currentBlocks, block];

    if (applyBlocks(nextBlocks)) {
      currentBlocks = nextBlocks;
      return;
    }

    if (currentBlocks.length) {
      pushCurrentPage();
    }

    if (applyBlocks([block])) {
      currentBlocks = [block];
      return;
    }

    if (pages.length === 0) {
      pushTitleOnlyFirstPage();

      if (applyBlocks([block])) {
        currentBlocks = [block];
        return;
      }
    }

    splitBlockForMeasurement(block).forEach((splitBlock) => {
      const splitCandidate = [...currentBlocks, splitBlock];

      if (!applyBlocks(splitCandidate) && currentBlocks.length) {
        pushCurrentPage();
      }

      if (!applyBlocks([splitBlock])) {
        pages.push(buildPageMarkup([splitBlock], pages.length === 0));
        currentBlocks = [];
        return;
      }

      currentBlocks = [splitBlock];
    });
  });

  pushCurrentPage();
  pageBody.innerHTML = '';
  return pages.length ? pages : [FALLBACK_BODY];
}

type ReaderPageCardProps = {
  articleTitle: string;
  fontSize: number;
  page: string;
  pageLabel: string;
  progressPercent: number;
  listedDate?: string;
  publishedAt?: string;
  sourceLabel: string;
};

function ReaderPageCard({
  articleTitle,
  fontSize,
  page,
  pageLabel,
  progressPercent,
  listedDate,
  publishedAt,
  sourceLabel,
}: ReaderPageCardProps) {
  const timelineDetails = buildArticleTimelineDetails({ listedDate, publishedAt });

  return (
    <div className="e-reader__page">
      <div className="e-reader__page-topline">
        <div className="e-reader__page-topline-meta">
          <span>{sourceLabel}</span>
          {timelineDetails.map((detail) => (
            <span key={`${sourceLabel}-${detail}`} className="e-reader__page-topline-detail">
              {detail}
            </span>
          ))}
        </div>
        <span>{pageLabel}</span>
      </div>
      <div className="e-reader__page-body" style={{ fontSize: `${fontSize}px` }}>
        {renderPageBody(page)}
      </div>
      <div className="e-reader__page-bottomline">
        <span>{articleTitle}</span>
        <span>{progressPercent}%</span>
      </div>
    </div>
  );
}

function buildReaderPageCardData(article: ReaderArticle | undefined, nextPageIndex: number, pageCount: number) {
  const safePageIndex = clampIndex(nextPageIndex, pageCount);

  return {
    articleTitle: article?.title ?? 'Untitled',
    page: article?.pageTokens[safePageIndex] ?? '',
    pageLabel: pageCount ? `${safePageIndex + 1} / ${pageCount}` : '0 / 0',
    progressPercent: pageCount > 0 ? Math.round(((safePageIndex + 1) / pageCount) * 100) : 0,
    listedDate: article?.listedDate,
    publishedAt: article?.publishedAt,
    sourceLabel: article?.sourceLabel ?? 'Reader',
  } satisfies ReaderPageCardData;
}

function sleep(ms: number) {
  return new Promise((resolve) => {
    window.setTimeout(resolve, ms);
  });
}

export function EReaderAppView({ onBack, launchContext = 'direct', shareToken }: EReaderAppViewProps) {
  const storedSettings = readStoredSettings();
  const initialStoredArticles = readStoredArticles();
  const initialReadHistory = readStoredReadHistory();
  const [viewportWidth, setViewportWidth] = useState(() => (typeof window === 'undefined' ? 1280 : window.innerWidth));
  const [viewportHeight, setViewportHeight] = useState(() => (typeof window === 'undefined' ? 900 : window.innerHeight));
  const [fontSize, setFontSize] = useState(() => storedSettings?.fontSize ?? 18);
  const [theme, setTheme] = useState<ReaderTheme>(() => storedSettings?.theme ?? 'mist');
  const [activeArticleId, setActiveArticleId] = useState(() => initialStoredArticles[0]?.id ?? READER_ARTICLE_SEEDS[0].id);
  const [currentView, setCurrentView] = useState<ReaderView>('home');
  const [viewHistory, setViewHistory] = useState<ReaderView[]>([]);
  const [pageIndex, setPageIndex] = useState(0);
  const [displayPageIndex, setDisplayPageIndex] = useState(0);
  const [draftTitle, setDraftTitle] = useState('오늘 읽을 웹 클립');
  const [draftSourceLabel, setDraftSourceLabel] = useState('Saved Clip');
  const [draftUrl, setDraftUrl] = useState(DEFAULT_SOURCE_URL);
  const [draftBody, setDraftBody] = useState(FALLBACK_BODY);
  const [draftHtmlBody, setDraftHtmlBody] = useState<string | undefined>(undefined);
  const [savedArticleSeeds, setSavedArticleSeeds] = useState<ReaderArticleSeed[]>(() => initialStoredArticles);
  const [readHistoryKeys, setReadHistoryKeys] = useState<string[]>(() => initialReadHistory);
  const [librarySearchTerm, setLibrarySearchTerm] = useState('');
  const [librarySort, setLibrarySort] = useState<ReaderLibrarySort>('updated-desc');
  const [isLibrarySortSheetOpen, setIsLibrarySortSheetOpen] = useState(false);
  const [newsFilters, setNewsFilters] = useState<ReaderNewsFilters>(() => createDefaultNewsFilters());
  const [newsResults, setNewsResults] = useState<EReaderNewsArticle[]>([]);
  const [isSearchingNews, setIsSearchingNews] = useState(false);
  const [newsStatus, setNewsStatus] = useState('');
  const [isNewsFiltersCollapsed, setIsNewsFiltersCollapsed] = useState(false);
  const [gestureMode, setGestureMode] = useState<ReaderGestureMode>(() => storedSettings?.gestureMode ?? 'tap-swipe');
  const [libraryNavigationMode, setLibraryNavigationMode] = useState<ReaderLibraryNavigationMode>(
    () => storedSettings?.libraryNavigationMode ?? 'scroll',
  );
  const [pendingPageTurn, setPendingPageTurn] = useState<PendingPageTurn | null>(null);
  const [visiblePageSlot, setVisiblePageSlot] = useState<ReaderPageSlot>('primary');
  const [primaryPageCard, setPrimaryPageCard] = useState<ReaderPageCardData>(() => buildReaderPageCardData(undefined, 0, 0));
  const [secondaryPageCard, setSecondaryPageCard] = useState<ReaderPageCardData>(() => buildReaderPageCardData(undefined, 0, 0));
  const [isPageContentHidden, setIsPageContentHidden] = useState(false);
  const [isImporting, setIsImporting] = useState(false);
  const [importStatus, setImportStatus] = useState('');
  const [librarySyncStatus, setLibrarySyncStatus] = useState('');
  const [isLibrarySyncing, setIsLibrarySyncing] = useState(false);
  const [librarySyncRequestId, setLibrarySyncRequestId] = useState(0);
  const [installState, setInstallState] = useState<ReaderInstallState>(() =>
    isStandaloneDisplayMode() ? 'standalone' : 'browser-help',
  );
  const [installStatus, setInstallStatus] = useState('');
  const swipeTrackingRef = useRef<SwipeTrackingState | null>(null);
  const backSwipeTrackingRef = useRef<SwipeTrackingState | null>(null);
  const backSwipeTriggeredRef = useRef(false);
  const deferredInstallPromptRef = useRef<DeferredInstallPromptEvent | null>(null);
  const librarySwipeTrackingRef = useRef<SwipeTrackingState | null>(null);
  const librarySwipeSuppressClickRef = useRef(false);
  const newsAutoSearchAttemptedRef = useRef(false);
  const primaryPageRef = useRef<HTMLDivElement | null>(null);
  const secondaryPageRef = useRef<HTMLDivElement | null>(null);
  const measurementBodyRef = useRef<HTMLDivElement | null>(null);
  const pageTurnRequestIdRef = useRef(0);
  const [measuredPagination, setMeasuredPagination] = useState<{ articleId: string; pageTokens: string[] } | null>(null);
  const [libraryPageIndex, setLibraryPageIndex] = useState(0);
  const [newsPageIndex, setNewsPageIndex] = useState(0);

  const pageSize = useMemo(() => resolvePageSize(viewportWidth, fontSize), [fontSize, viewportWidth]);
  const libraryColumns = useMemo(() => resolveLibraryColumns(viewportWidth), [viewportWidth]);
  const libraryRows = useMemo(() => resolveLibraryRows(viewportWidth, viewportHeight), [viewportHeight, viewportWidth]);
  const libraryPageSize = useMemo(() => libraryColumns * libraryRows, [libraryColumns, libraryRows]);
  const newsColumns = useMemo(() => (viewportWidth <= 720 ? 1 : 2), [viewportWidth]);
  const newsRows = useMemo(() => {
    if (viewportWidth <= 720) {
      return viewportHeight < 760 ? 2 : 3;
    }

    return viewportHeight < 860 ? 2 : 3;
  }, [viewportHeight, viewportWidth]);
  const newsPageSize = useMemo(() => newsColumns * newsRows, [newsColumns, newsRows]);

  const articles = useMemo(() => {
    const seeds = mergeSavedArticleSeeds(savedArticleSeeds, READER_ARTICLE_SEEDS);
    return seeds.map((seed) => buildReaderArticle(seed, pageSize));
  }, [pageSize, savedArticleSeeds]);

  const activeArticle = useMemo(
    () => articles.find((article) => article.id === activeArticleId) ?? articles[0],
    [activeArticleId, articles],
  );
  const activeArticleIndex = useMemo(
    () => articles.findIndex((article) => article.id === activeArticle?.id),
    [activeArticle?.id, articles],
  );
  const previousArticle = activeArticleIndex > 0 ? articles[activeArticleIndex - 1] : undefined;
  const nextArticle = activeArticleIndex >= 0 ? articles[activeArticleIndex + 1] : undefined;
  const libraryTags = useMemo(
    () => Array.from(new Set(articles.flatMap((article) => article.tags ?? []))).sort((left, right) => left.localeCompare(right, 'ko')),
    [articles],
  );
  const libraryTagSuggestions = useMemo(() => {
    const normalizedSearch = normalizeSearchKeyword(librarySearchTerm);
    const candidateTags = normalizedSearch
      ? libraryTags.filter((tag) => tag.toLowerCase().includes(normalizedSearch))
      : libraryTags.slice(0, 8);

    return candidateTags.map((tag) => ({
      label: `#${tag}`,
      value: `#${tag}`,
    }));
  }, [librarySearchTerm, libraryTags]);
  const filteredArticles = useMemo(() => {
    const normalizedSearch = normalizeSearchKeyword(librarySearchTerm);

    return articles
      .filter((article) => {
        if (!normalizedSearch) {
          return true;
        }

        const searchableText = [article.title, article.sourceLabel, article.lead, ...(article.tags ?? [])].join(' ').toLowerCase();
        return searchableText.includes(normalizedSearch);
      })
      .sort((left, right) => compareReaderArticlesBySort(left, right, librarySort));
  }, [articles, librarySearchTerm, librarySort]);
  const libraryPages = useMemo(
    () => chunkArticles(filteredArticles, libraryPageSize),
    [filteredArticles, libraryPageSize],
  );
  const newsPages = useMemo(
    () => chunkArticles(newsResults, newsPageSize),
    [newsPageSize, newsResults],
  );
  const safeLibraryPageIndex = Math.min(Math.max(libraryPageIndex, 0), Math.max(libraryPages.length - 1, 0));
  const visibleLibraryArticles = libraryPages[safeLibraryPageIndex] ?? [];
  const safeNewsPageIndex = Math.min(Math.max(newsPageIndex, 0), Math.max(newsPages.length - 1, 0));
  const visibleNewsResults = newsPages[safeNewsPageIndex] ?? [];
  const librarySortLabel = READER_LIBRARY_SORT_OPTIONS.find((option) => option.value === librarySort)?.label ?? '업데이트순';
  const libraryCountLabel = librarySearchTerm.trim() ? `검색 ${filteredArticles.length}` : `전체 ${filteredArticles.length}`;
  const effectivePageTokens =
    measuredPagination?.articleId === activeArticle?.id ? measuredPagination.pageTokens : (activeArticle?.pageTokens ?? []);
  const paginatedActiveArticle = useMemo(
    () => (activeArticle ? { ...activeArticle, pageTokens: effectivePageTokens } : activeArticle),
    [activeArticle, effectivePageTokens],
  );
  const isMeasuredPaginationReady = currentView !== 'reader' || measuredPagination?.articleId === activeArticle?.id;
  const pageCount = paginatedActiveArticle?.pageTokens.length ?? 0;
  const safePageIndex = clampIndex(pageIndex, pageCount);
  const safeDisplayPageIndex = clampIndex(displayPageIndex, pageCount);
  const hiddenPageSlot = visiblePageSlot === 'primary' ? 'secondary' : 'primary';
  const isEmbeddedLaunch = launchContext === 'embedded';
  const effectiveShareToken = normalizeShareToken(shareToken) || readShareTokenFromUrl();
  const hasViewHistory = viewHistory.length > 0;
  const canExitToParentApp = isEmbeddedLaunch && installState !== 'standalone';
  const isStandaloneHome = installState === 'standalone' && currentView === 'home';
  const canShowParentBackButton = canExitToParentApp;
  const canShowInternalBackButton = !isStandaloneHome && (!isEmbeddedLaunch || hasViewHistory);
  const newsSourceOptions = useMemo(() => {
    const sourceLabels = Array.from(
      new Set(
        [
          ...newsFilters.sources,
          ...newsResults.map((item) => item.sourceLabel),
          ...savedArticleSeeds.map((item) => item.sourceLabel),
        ]
          .map((value) => value.trim())
          .filter(Boolean),
      ),
    ).sort((left, right) => left.localeCompare(right, 'ko'));

    return sourceLabels.map((value) => ({ label: value, value }));
  }, [newsFilters.sources, newsResults, savedArticleSeeds]);

  useEffect(() => {
    if (typeof window === 'undefined') {
      return undefined;
    }

    const handleResize = () => {
      setViewportWidth(window.innerWidth);
      setViewportHeight(window.innerHeight);
    };

    window.addEventListener('resize', handleResize);
    return () => {
      window.removeEventListener('resize', handleResize);
    };
  }, []);

  useEffect(() => {
    if (typeof window === 'undefined') {
      return;
    }

    window.localStorage.setItem(
      READER_SETTINGS_STORAGE_KEY,
        JSON.stringify({
          fontSize,
          gestureMode,
          libraryNavigationMode,
          theme,
        }),
    );
  }, [fontSize, gestureMode, libraryNavigationMode, theme]);

  useEffect(() => {
    writeStoredArticles(savedArticleSeeds);
  }, [savedArticleSeeds]);

  useEffect(() => {
    writeStoredReadHistory(readHistoryKeys);
  }, [readHistoryKeys]);

  useEffect(() => {
    let isCancelled = false;

    void (async () => {
      setIsLibrarySyncing(true);
      try {
        setLibrarySyncStatus('서버 서가를 확인하는 중입니다.');
        let remoteItems: EReaderLibraryArticle[] | null = null;
        let syncError: unknown;

        for (let attempt = 0; attempt < 2; attempt += 1) {
          try {
            remoteItems = await listReaderLibraryArticles();
            syncError = undefined;
            break;
          } catch (error) {
            syncError = error;

            if (attempt === 0) {
              if (isCancelled) {
                return;
              }

              setLibrarySyncStatus('서버 연결을 다시 확인하는 중입니다.');
              await sleep(900);
              continue;
            }
          }
        }

        if (!remoteItems) {
          throw syncError instanceof Error ? syncError : new Error('서버 서가를 불러오지 못했습니다.');
        }

        if (isCancelled) {
          return;
        }

        const remoteSeeds = remoteItems.map((item) => ({
          id: item.id,
          title: item.title,
          sourceLabel: item.sourceLabel,
          url: item.url,
          lead: item.lead,
          body: item.body,
          htmlBody: item.htmlBody,
          tags: normalizeTags(item.tags),
          listedDate: normalizeListedDate(item.listedDate),
          publishedAt: normalizePublishedAt(item.publishedAt),
          createdAt: item.createdAt,
          updatedAt: item.updatedAt,
        }) satisfies ReaderArticleSeed);

        const localSeeds = readStoredArticles();
        const mergedSeeds = mergeSavedArticleSeeds(remoteSeeds, localSeeds);
        setSavedArticleSeeds(mergedSeeds);

        const remoteIds = new Set(remoteSeeds.map((item) => item.id));
        const unsyncedLocalSeeds = localSeeds.filter((item) => !remoteIds.has(item.id));

        if (!unsyncedLocalSeeds.length) {
          setLibrarySyncStatus(remoteSeeds.length ? '서버 저장글과 동기화되었습니다.' : '서버 저장글이 아직 없습니다.');
          return;
        }

        setLibrarySyncStatus('기존 로컬 저장글을 서버로 동기화하는 중입니다.');
        const uploadedItems = await Promise.all(
          unsyncedLocalSeeds.map((article) => saveReaderLibraryArticle(article as EReaderLibraryArticle)),
        );

        if (isCancelled) {
          return;
        }

        const uploadedSeeds = uploadedItems.map((item) => ({
          id: item.id,
          title: item.title,
          sourceLabel: item.sourceLabel,
          url: item.url,
          lead: item.lead,
          body: item.body,
          htmlBody: item.htmlBody,
          tags: normalizeTags(item.tags),
          listedDate: normalizeListedDate(item.listedDate),
          publishedAt: normalizePublishedAt(item.publishedAt),
          createdAt: item.createdAt,
          updatedAt: item.updatedAt,
        }) satisfies ReaderArticleSeed);

        setSavedArticleSeeds(mergeSavedArticleSeeds(uploadedSeeds, remoteSeeds, localSeeds));
        setLibrarySyncStatus('로컬 저장글까지 서버 서가와 동기화되었습니다.');
      } catch {
        if (!isCancelled) {
          setLibrarySyncStatus('서버 응답이 불안정해 이 기기 저장글만 표시합니다. 위 새로고침으로 다시 확인할 수 있습니다.');
        }
      } finally {
        if (!isCancelled) {
          setIsLibrarySyncing(false);
        }
      }
    })();

    return () => {
      isCancelled = true;
    };
  }, [librarySyncRequestId]);

  useEffect(() => {
    setEReaderShareTokenOverride(effectiveShareToken);

    return () => {
      setEReaderShareTokenOverride('');
    };
  }, [effectiveShareToken]);

  useEffect(() => {
    if (typeof document === 'undefined') {
      return undefined;
    }

    let isDisposed = false;
    let dynamicManifestHref = '';
    document.body.classList.add(E_READER_IMMERSIVE_BODY_CLASS);
    const previewRuntimeToken = isPreviewRuntime() ? getRegisteredAccessToken() : '';
    const restoreManifest = swapManifestForEReader();

    if (previewRuntimeToken || effectiveShareToken) {
      void (async () => {
        try {
          const manifestObjectUrl = await createEReaderManifestObjectUrl({
            registeredToken: previewRuntimeToken,
            shareToken: effectiveShareToken,
          });

          if (isDisposed) {
            window.URL.revokeObjectURL(manifestObjectUrl);
            return;
          }

          dynamicManifestHref = manifestObjectUrl;
          restoreManifest();
          swapManifestForEReader(manifestObjectUrl);
        } catch {
          // Fall back to the static manifest when the dynamic install URL cannot be prepared.
        }
      })();
    }

    return () => {
      isDisposed = true;
      if (dynamicManifestHref) {
        window.URL.revokeObjectURL(dynamicManifestHref);
      }
      restoreManifest();
      document.body.classList.remove(E_READER_IMMERSIVE_BODY_CLASS);
    };
  }, [effectiveShareToken]);

  useEffect(() => {
    if (typeof window === 'undefined') {
      return undefined;
    }

    const syncInstallState = () => {
      setInstallState((currentState) => {
        if (isStandaloneDisplayMode()) {
          return 'standalone';
        }

        return deferredInstallPromptRef.current ? 'installable' : currentState === 'standalone' ? 'browser-help' : currentState;
      });
    };

    const handleBeforeInstallPrompt = (event: Event) => {
      event.preventDefault();
      deferredInstallPromptRef.current = event as DeferredInstallPromptEvent;
      setInstallState(isStandaloneDisplayMode() ? 'standalone' : 'installable');
      setInstallStatus('');
    };

    const handleAppInstalled = () => {
      deferredInstallPromptRef.current = null;
      setInstallState('standalone');
      setInstallStatus('홈 화면 앱으로 추가되었습니다. 다음부터는 E-Reader로 바로 열립니다.');
    };

    syncInstallState();
    window.addEventListener('beforeinstallprompt', handleBeforeInstallPrompt);
    window.addEventListener('appinstalled', handleAppInstalled);
    window.addEventListener('resize', syncInstallState);

    return () => {
      window.removeEventListener('beforeinstallprompt', handleBeforeInstallPrompt);
      window.removeEventListener('appinstalled', handleAppInstalled);
      window.removeEventListener('resize', syncInstallState);
    };
  }, []);

  useEffect(() => {
    const nextPageCard = buildReaderPageCardData(paginatedActiveArticle, safeDisplayPageIndex, pageCount);

    setPageIndex((currentPageIndex) => clampIndex(currentPageIndex, pageCount));
    setDisplayPageIndex((currentPageIndex) => clampIndex(currentPageIndex, pageCount));
    setVisiblePageSlot('primary');
    setPrimaryPageCard(nextPageCard);
    setSecondaryPageCard(nextPageCard);
    setPendingPageTurn(null);
    setIsPageContentHidden(false);
  }, [pageCount, paginatedActiveArticle, safeDisplayPageIndex]);

  useEffect(() => {
    if (!activeArticle?.id) {
      setMeasuredPagination(null);
      return;
    }

    if (currentView !== 'reader') {
      return;
    }

    const measurementBody = measurementBodyRef.current;
    if (!measurementBody) {
      return;
    }

    measurementBody.dataset.articleTitle = activeArticle.title;
    const pageBlocks = buildReaderPageBlocks(activeArticle.htmlBody, activeArticle.body);
    const nextPageTokens = paginateBlocksByMeasurement(pageBlocks, measurementBody);

    setMeasuredPagination((current) => {
      if (
        current?.articleId === activeArticle.id &&
        current.pageTokens.length === nextPageTokens.length &&
        current.pageTokens.every((token, index) => token === nextPageTokens[index])
      ) {
        return current;
      }

      return {
        articleId: activeArticle.id,
        pageTokens: nextPageTokens,
      };
    });
  }, [activeArticle?.body, activeArticle?.htmlBody, activeArticle?.id, currentView, fontSize, viewportHeight, viewportWidth]);

  useEffect(() => {
    if (!pendingPageTurn) {
      return undefined;
    }

    const requestId = ++pageTurnRequestIdRef.current;
    const nextVisiblePageSlot = hiddenPageSlot;
    const nextPageRef = nextVisiblePageSlot === 'primary' ? primaryPageRef : secondaryPageRef;
    let isCancelled = false;

    void (async () => {
      await waitForPageAssets(nextPageRef.current);

      if (isCancelled || requestId !== pageTurnRequestIdRef.current) {
        return;
      }

      setIsPageContentHidden(true);
      await waitForAnimationFrame();

      if (isCancelled || requestId !== pageTurnRequestIdRef.current) {
        return;
      }

      setVisiblePageSlot(nextVisiblePageSlot);
      setDisplayPageIndex(pendingPageTurn.nextPageIndex);
      setPageIndex(pendingPageTurn.nextPageIndex);
      await waitForAnimationFrame();
      await waitForAnimationFrame();

      if (isCancelled || requestId !== pageTurnRequestIdRef.current) {
        return;
      }

      setIsPageContentHidden(false);
      setPendingPageTurn(null);
    })();

    return () => {
      isCancelled = true;
    };
  }, [hiddenPageSlot, pendingPageTurn]);

  useEffect(() => {
    if (activeArticle?.id) {
      return;
    }

    setActiveArticleId(savedArticleSeeds[0]?.id ?? READER_ARTICLE_SEEDS[0].id);
  }, [activeArticle?.id, savedArticleSeeds]);

  useEffect(() => {
    setLibraryPageIndex((currentIndex) => Math.min(Math.max(currentIndex, 0), Math.max(libraryPages.length - 1, 0)));
  }, [libraryPages.length]);

  useEffect(() => {
    setNewsPageIndex((currentIndex) => Math.min(Math.max(currentIndex, 0), Math.max(newsPages.length - 1, 0)));
  }, [newsPages.length]);

  useEffect(() => {
    setLibraryPageIndex(0);
  }, [libraryNavigationMode, librarySearchTerm, librarySort]);

  useEffect(() => {
    if (currentView !== 'library') {
      setIsLibrarySortSheetOpen(false);
    }
  }, [currentView]);

  useEffect(() => {
    setNewsPageIndex(0);
  }, [newsFilters.dateFrom, newsFilters.dateTo, newsFilters.keyword, newsFilters.limit, newsFilters.sources, newsFilters.topics]);

  useEffect(() => {
    if (!activeArticle) {
      return;
    }

    setDraftTitle(activeArticle.title);
    setDraftSourceLabel(activeArticle.sourceLabel);
    setDraftUrl(activeArticle.url);
    setDraftBody(activeArticle.body);
    setDraftHtmlBody(activeArticle.htmlBody);
  }, [activeArticle]);

  useEffect(() => {
    if (currentView !== 'news') {
      newsAutoSearchAttemptedRef.current = false;
      return;
    }

    if (newsAutoSearchAttemptedRef.current || isSearchingNews || newsResults.length > 0) {
      return;
    }

    newsAutoSearchAttemptedRef.current = true;
    void handleSearchNews({ silent: true });
  }, [currentView, isSearchingNews, newsResults.length]);

  const moveToView = (nextView: ReaderView) => {
    if (currentView === nextView) {
      return;
    }

    setViewHistory((history) => [...history, currentView]);
    setCurrentView(nextView);
  };

  const handleGoBack = () => {
    if (isStandaloneHome) {
      return;
    }

    const previousView = viewHistory[viewHistory.length - 1];

    if (!previousView) {
      onBack();
      return;
    }

    setViewHistory((history) => history.slice(0, -1));
    setCurrentView(previousView);
  };

  const handleExitToParentApp = () => {
    onBack();
  };

  const handleBackGesturePointerDownCapture: React.PointerEventHandler<HTMLElement> = (event) => {
    if (isStandaloneHome || event.pointerType === 'mouse' || event.clientX > BACK_SWIPE_EDGE_PX) {
      backSwipeTrackingRef.current = null;
      backSwipeTriggeredRef.current = false;
      return;
    }

    backSwipeTrackingRef.current = {
      pointerId: event.pointerId,
      startX: event.clientX,
      startY: event.clientY,
      deltaX: 0,
      deltaY: 0,
    };
    backSwipeTriggeredRef.current = false;
  };

  const handleBackGesturePointerMoveCapture: React.PointerEventHandler<HTMLElement> = (event) => {
    const tracking = backSwipeTrackingRef.current;

    if (!tracking || tracking.pointerId !== event.pointerId || backSwipeTriggeredRef.current) {
      return;
    }

    const deltaX = event.clientX - tracking.startX;
    const deltaY = event.clientY - tracking.startY;

    backSwipeTrackingRef.current = {
      ...tracking,
      deltaX,
      deltaY,
    };

    if (deltaX < BACK_SWIPE_DISTANCE_PX || Math.abs(deltaY) > BACK_SWIPE_VERTICAL_TOLERANCE_PX) {
      return;
    }

    backSwipeTriggeredRef.current = true;
    backSwipeTrackingRef.current = null;
    handleGoBack();
  };

  const finishBackGesturePointerTrackingCapture: React.PointerEventHandler<HTMLElement> = (event) => {
    const tracking = backSwipeTrackingRef.current;

    if (tracking?.pointerId === event.pointerId) {
      backSwipeTrackingRef.current = null;
    }

    backSwipeTriggeredRef.current = false;
  };

  const commitArticle = (articleSeed: ReaderArticleSeed) => {
    setSavedArticleSeeds((currentItems) => upsertSavedArticleSeed(currentItems, articleSeed));
    startTransition(() => {
      setActiveArticleId(articleSeed.id);
      setPageIndex(0);
      moveToView('reader');
    });
  };

  const syncArticleToServer = async (articleSeed: ReaderArticleSeed) => {
    const savedItem = await saveReaderLibraryArticle(articleSeed as EReaderLibraryArticle);
    const normalizedSavedArticle = {
      id: savedItem.id,
      title: savedItem.title,
      sourceLabel: savedItem.sourceLabel,
      url: savedItem.url,
      lead: savedItem.lead,
      body: savedItem.body,
      htmlBody: savedItem.htmlBody,
      tags: normalizeTags(savedItem.tags),
      listedDate: normalizeListedDate(savedItem.listedDate),
      publishedAt: normalizePublishedAt(savedItem.publishedAt),
      signals: Array.isArray(savedItem.signals) ? savedItem.signals.filter((signal) => typeof signal === 'string' && signal.trim()) : undefined,
      createdAt: savedItem.createdAt,
      updatedAt: savedItem.updatedAt,
    } satisfies ReaderArticleSeed;

    setSavedArticleSeeds((currentItems) => upsertSavedArticleSeed(currentItems, normalizedSavedArticle));
    setLibrarySyncStatus('서버 서가와 동기화되었습니다.');
    return normalizedSavedArticle;
  };

  const buildImportedArticleSeed = (
    item: {
      title: string;
      sourceLabel: string;
      url: string;
      lead: string;
      body: string;
      htmlBody?: string;
      publishedAt?: string;
    },
    fallbackDraft?: Partial<Pick<ReaderArticleSeed, 'title' | 'sourceLabel' | 'lead'>>,
  ) => {
    const normalizedUrl = normalizeArticleUrl(item.url.trim() || draftUrl.trim());
    const duplicatedArticle = savedArticleSeeds.find((savedItem) => normalizeArticleUrl(savedItem.url) === normalizedUrl);
    const nextCustomArticle = {
      id: duplicatedArticle?.id ?? buildArticleId(),
      title: item.title.trim() || fallbackDraft?.title?.trim() || draftTitle.trim() || '가져온 원문',
      sourceLabel: item.sourceLabel.trim() || fallbackDraft?.sourceLabel?.trim() || draftSourceLabel.trim() || 'Imported Article',
      url: normalizedUrl,
      lead: item.lead.trim() || item.body.split('\n')[0]?.trim() || fallbackDraft?.lead?.trim() || '가져온 원문',
      body: normalizeLineBreaks(item.body),
      htmlBody: item.htmlBody?.trim() || undefined,
      tags: inferArticleTags({
        sourceLabel: item.sourceLabel.trim() || fallbackDraft?.sourceLabel?.trim() || draftSourceLabel.trim() || 'Imported Article',
        title: item.title.trim() || fallbackDraft?.title?.trim() || draftTitle.trim() || '가져온 원문',
        lead: item.lead.trim() || item.body.split('\n')[0]?.trim() || fallbackDraft?.lead?.trim() || '가져온 원문',
        body: item.body,
      }),
      signals: duplicatedArticle?.signals,
      listedDate: normalizeListedDate(item.listedDate) ?? duplicatedArticle?.listedDate,
      publishedAt: normalizePublishedAt(item.publishedAt) ?? duplicatedArticle?.publishedAt,
      createdAt: duplicatedArticle?.createdAt,
      updatedAt: duplicatedArticle?.updatedAt,
    } satisfies ReaderArticleSeed;

    return { duplicatedArticle, nextCustomArticle };
  };

  const handleSearchNews = async (options?: { silent?: boolean }) => {
    if (!newsFilters.dateFrom || !newsFilters.dateTo) {
      setNewsStatus('뉴스 검색은 날짜 범위가 필수입니다.');
      return;
    }

    if (newsFilters.dateFrom > newsFilters.dateTo) {
      setNewsStatus('시작 날짜가 종료 날짜보다 늦을 수 없습니다.');
      return;
    }

    setIsSearchingNews(true);
    if (!options?.silent) {
      setNewsStatus('');
    }

    try {
      const items = await searchReaderNews(newsFilters);
      setNewsResults(items);
      if (items.length) {
        const savedNewsSeeds = items.map((item) => buildNewsArticleSeed(item));
        setSavedArticleSeeds((currentItems) => mergeSavedArticleSeeds(savedNewsSeeds, currentItems));
        setActiveArticleId((currentId) => currentId || savedNewsSeeds[0]?.id || currentId);
      }
      setNewsStatus(
        items.length
          ? `${newsFilters.dateFrom}${newsFilters.dateFrom === newsFilters.dateTo ? '' : ` ~ ${newsFilters.dateTo}`} 네이버 목록 기준으로 뉴스 ${items.length}건을 서가에 수집했습니다.`
          : `${newsFilters.dateFrom}${newsFilters.dateFrom === newsFilters.dateTo ? '' : ` ~ ${newsFilters.dateTo}`} 목록 기준에 맞는 네이버 뉴스가 없습니다.`,
      );
    } catch (error) {
      setNewsResults([]);
      setNewsStatus(error instanceof Error ? error.message : '뉴스 검색에 실패했습니다.');
    } finally {
      setIsSearchingNews(false);
    }
  };

  const handleOpenNewsArticle = (newsItem: EReaderNewsArticle) => {
    const nextArticle = buildNewsArticleSeed(newsItem);

    setDraftTitle(nextArticle.title);
    setDraftSourceLabel(nextArticle.sourceLabel);
    setDraftUrl(nextArticle.url);
    setDraftBody(nextArticle.body);
    setDraftHtmlBody(nextArticle.htmlBody);
    commitArticle(nextArticle);
    setNewsStatus('수집한 뉴스를 바로 펼쳤습니다.');
  };

  const markArticleAsRead = (article: Pick<ReaderArticleSeed, 'id' | 'url'> | Pick<EReaderNewsArticle, 'id' | 'url'>) => {
    const nextKeys = buildReadHistoryKeys(article);

    if (!nextKeys.length) {
      return;
    }

    setReadHistoryKeys((currentKeys) => {
      const mergedKeys = new Set(currentKeys);
      let hasChanged = false;

      nextKeys.forEach((key) => {
        if (!mergedKeys.has(key)) {
          mergedKeys.add(key);
          hasChanged = true;
        }
      });

      return hasChanged ? Array.from(mergedKeys) : currentKeys;
    });
  };

  const isReadArticle = (article: Pick<ReaderArticleSeed, 'id' | 'url'> | Pick<EReaderNewsArticle, 'id' | 'url'>) =>
    buildReadHistoryKeys(article).some((key) => readHistoryKeys.includes(key));

  const turnPage = (direction: 'next' | 'prev') => {
    if (!paginatedActiveArticle || pendingPageTurn || isPageContentHidden) {
      return;
    }

    const nextPageIndex =
      direction === 'next'
        ? clampIndex(safeDisplayPageIndex + 1, paginatedActiveArticle.pageTokens.length)
        : clampIndex(safeDisplayPageIndex - 1, paginatedActiveArticle.pageTokens.length);

    if (nextPageIndex === safeDisplayPageIndex) {
      return;
    }

    const nextPageTurn = {
      articleTitle: activeArticle.title,
      nextPage: paginatedActiveArticle.pageTokens[nextPageIndex] ?? '',
      nextPageIndex,
      nextPageLabel: `${nextPageIndex + 1} / ${pageCount}`,
      nextProgressPercent: pageCount > 0 ? Math.round(((nextPageIndex + 1) / pageCount) * 100) : 0,
      sourceLabel: paginatedActiveArticle.sourceLabel,
    } satisfies PendingPageTurn;

    const nextPageCard = {
      articleTitle: nextPageTurn.articleTitle,
      page: nextPageTurn.nextPage,
      pageLabel: nextPageTurn.nextPageLabel,
      progressPercent: nextPageTurn.nextProgressPercent,
      sourceLabel: nextPageTurn.sourceLabel,
    } satisfies ReaderPageCardData;

    if (hiddenPageSlot === 'primary') {
      setPrimaryPageCard(nextPageCard);
    } else {
      setSecondaryPageCard(nextPageCard);
    }

    setPendingPageTurn(nextPageTurn);
  };

  const moveToAdjacentArticle = (direction: 'next' | 'prev') => {
    if (pendingPageTurn || isPageContentHidden) {
      return false;
    }

    const targetArticle = direction === 'next' ? nextArticle : previousArticle;

    if (!targetArticle) {
      return false;
    }

    startTransition(() => {
      setActiveArticleId(targetArticle.id);
      setPageIndex(0);
      setDisplayPageIndex(0);
      setPendingPageTurn(null);
      setIsPageContentHidden(false);
    });

    return true;
  };

  const turnReaderContent = (direction: 'next' | 'prev') => {
    if (!paginatedActiveArticle) {
      return;
    }

    const atBoundary =
      direction === 'next'
        ? safeDisplayPageIndex >= Math.max(pageCount - 1, 0)
        : safeDisplayPageIndex <= 0;

    if (atBoundary) {
      moveToAdjacentArticle(direction);
      return;
    }

    turnPage(direction);
  };

  const handleSaveClip = async () => {
    const normalizedBody = normalizeLineBreaks(draftBody);
    const normalizedUrl = normalizeArticleUrl(draftUrl) || DEFAULT_SOURCE_URL;

    if (!draftTitle.trim() || !normalizedBody) {
      setImportStatus('제목과 본문을 먼저 채워 주세요.');
      return;
    }

    const duplicatedArticle = savedArticleSeeds.find((item) => normalizeArticleUrl(item.url) === normalizedUrl);

    const nextCustomArticle = {
      id: duplicatedArticle?.id ?? buildArticleId(),
      title: draftTitle.trim(),
      sourceLabel: draftSourceLabel.trim() || 'Saved Clip',
      url: normalizedUrl,
      lead: normalizedBody.split('\n')[0]?.trim() || '저장한 웹 클립',
      body: normalizedBody,
      htmlBody: draftHtmlBody?.trim() || undefined,
      tags: inferArticleTags({
        sourceLabel: draftSourceLabel.trim() || 'Saved Clip',
        title: draftTitle.trim(),
        lead: normalizedBody.split('\n')[0]?.trim() || '저장한 웹 클립',
        body: normalizedBody,
      }),
      listedDate: duplicatedArticle?.listedDate,
      publishedAt: duplicatedArticle?.publishedAt,
      createdAt: duplicatedArticle?.createdAt,
      updatedAt: duplicatedArticle?.updatedAt,
    } satisfies ReaderArticleSeed;

    setImportStatus(duplicatedArticle ? '같은 URL 저장글을 최신 내용으로 갱신했습니다.' : '서가에 저장했습니다.');
    commitArticle(nextCustomArticle);

    try {
      await syncArticleToServer(nextCustomArticle);
      setImportStatus(
        duplicatedArticle
          ? '같은 URL 저장글을 갱신했고 서버에도 반영했습니다.'
          : '서가에 저장했고 서버에도 보관했습니다.',
      );
    } catch (error) {
      setImportStatus(
        error instanceof Error
          ? `이 기기에는 저장했지만 서버 저장은 실패했습니다. ${error.message}`
          : '이 기기에는 저장했지만 서버 저장은 실패했습니다.',
      );
    }
  };

  const handleAutoImport = async () => {
    if (!draftUrl.trim()) {
      setImportStatus('원문 URL을 먼저 입력해 주세요.');
      return;
    }

    setIsImporting(true);
    setImportStatus('');

    try {
      const item = await extractReaderArticle(draftUrl);
      const { duplicatedArticle, nextCustomArticle } = buildImportedArticleSeed(item);

      setDraftTitle(nextCustomArticle.title);
      setDraftSourceLabel(nextCustomArticle.sourceLabel);
      setDraftUrl(nextCustomArticle.url);
      setDraftBody(nextCustomArticle.body);
      setDraftHtmlBody(nextCustomArticle.htmlBody);
      commitArticle(nextCustomArticle);
      setImportStatus(
        duplicatedArticle
          ? '같은 URL 원문이 이미 있어 기존 저장글을 최신 내용으로 갱신했습니다.'
          : '원문을 가져와 이 기기 서가에 저장했습니다.',
      );

      try {
        await syncArticleToServer(nextCustomArticle);
        setImportStatus(
          duplicatedArticle
            ? '같은 URL 원문 저장글을 갱신했고 서버에도 반영했습니다.'
            : '원문을 가져와 서가와 서버에 함께 저장했습니다.',
        );
      } catch (error) {
        setImportStatus(
          error instanceof Error
            ? `원문을 가져와 이 기기에는 저장했지만 서버 저장은 실패했습니다. ${error.message}`
            : '원문을 가져와 이 기기에는 저장했지만 서버 저장은 실패했습니다.',
        );
      }
    } catch (error) {
      setImportStatus(error instanceof Error ? error.message : '원문 자동 가져오기에 실패했습니다.');
    } finally {
      setIsImporting(false);
    }
  };

  const handleLoadSample = (articleId: string) => {
    setActiveArticleId(articleId);
    setPageIndex(0);
    setImportStatus('');
    moveToView('reader');
  };

  useEffect(() => {
    if (currentView !== 'reader' || !activeArticle) {
      return;
    }

    markArticleAsRead(activeArticle);
  }, [activeArticle, currentView]);

  const handleInstallReaderApp = async () => {
    if (isStandaloneDisplayMode()) {
      setInstallState('standalone');
      setInstallStatus('이미 홈 화면 앱으로 실행 중입니다.');
      return;
    }

    const deferredPrompt = deferredInstallPromptRef.current;

    if (!deferredPrompt) {
      setInstallState('browser-help');
      setInstallStatus(getInstallGuideMessage());
      return;
    }

    deferredPrompt.prompt();
    const choice = await deferredPrompt.userChoice.catch(() => null);
    deferredInstallPromptRef.current = null;

    if (choice?.outcome === 'accepted') {
      setInstallStatus('설치 요청을 보냈습니다. 설치가 끝나면 E-Reader 전용 아이콘으로 실행됩니다.');
      setInstallState('browser-help');
      return;
    }

    setInstallState('browser-help');
    setInstallStatus('설치를 닫았습니다. 필요하면 브라우저 메뉴에서 다시 홈 화면에 추가할 수 있습니다.');
  };

  const handlePointerDown: React.PointerEventHandler<HTMLDivElement> = (event) => {
    swipeTrackingRef.current = {
      pointerId: event.pointerId,
      startX: event.clientX,
      startY: event.clientY,
      deltaX: 0,
      deltaY: 0,
    };
    event.currentTarget.setPointerCapture(event.pointerId);
  };

  const handlePointerMove: React.PointerEventHandler<HTMLDivElement> = (event) => {
    const tracking = swipeTrackingRef.current;

    if (!tracking || tracking.pointerId !== event.pointerId) {
      return;
    }

    swipeTrackingRef.current = {
      ...tracking,
      deltaX: event.clientX - tracking.startX,
      deltaY: event.clientY - tracking.startY,
    };
  };

  const finishPointerTracking = (event: React.PointerEvent<HTMLDivElement>) => {
    const tracking = swipeTrackingRef.current;

    if (!tracking || tracking.pointerId !== event.pointerId) {
      return;
    }

    if (event.currentTarget.hasPointerCapture(event.pointerId)) {
      event.currentTarget.releasePointerCapture(event.pointerId);
    }

    swipeTrackingRef.current = null;

    if (gestureMode === 'touch-scroll') {
      if (Math.abs(tracking.deltaY) < PAGE_SCROLL_TURN_DISTANCE_PX || Math.abs(tracking.deltaX) > PAGE_SCROLL_HORIZONTAL_TOLERANCE_PX) {
        return;
      }

      if (tracking.deltaY < 0) {
        turnReaderContent('next');
        return;
      }

      turnReaderContent('prev');
      return;
    }

    if (Math.abs(tracking.deltaX) < PAGE_TURN_DISTANCE_PX || Math.abs(tracking.deltaY) > PAGE_TURN_VERTICAL_TOLERANCE_PX) {
      return;
    }

    if (tracking.deltaX < 0) {
      turnReaderContent('next');
      return;
    }

    turnReaderContent('prev');
  };

  const handleReaderTap: React.MouseEventHandler<HTMLDivElement> = (event) => {
    if (gestureMode === 'touch-scroll') {
      return;
    }

    const bounds = event.currentTarget.getBoundingClientRect();
    const offsetX = event.clientX - bounds.left;
    const ratio = offsetX / Math.max(bounds.width, 1);

    if (ratio >= 0.7) {
      turnReaderContent('next');
      return;
    }

    if (ratio <= 0.3) {
      turnReaderContent('prev');
    }
  };

  const turnLibraryPage = (direction: 'next' | 'prev') => {
    if (libraryNavigationMode !== 'touch-swipe' || libraryPages.length <= 1) {
      return;
    }

    setLibraryPageIndex((currentIndex) => {
      const nextIndex =
        direction === 'next'
          ? Math.min(currentIndex + 1, libraryPages.length - 1)
          : Math.max(currentIndex - 1, 0);
      return nextIndex;
    });
  };

  const handleLibraryPointerDown: React.PointerEventHandler<HTMLDivElement> = (event) => {
    if (libraryNavigationMode !== 'touch-swipe') {
      return;
    }

    librarySwipeTrackingRef.current = {
      pointerId: event.pointerId,
      startX: event.clientX,
      startY: event.clientY,
      deltaX: 0,
      deltaY: 0,
    };
    librarySwipeSuppressClickRef.current = false;
    event.currentTarget.setPointerCapture(event.pointerId);
  };

  const handleLibraryPointerMove: React.PointerEventHandler<HTMLDivElement> = (event) => {
    const tracking = librarySwipeTrackingRef.current;

    if (!tracking || tracking.pointerId !== event.pointerId) {
      return;
    }

    librarySwipeTrackingRef.current = {
      ...tracking,
      deltaX: event.clientX - tracking.startX,
      deltaY: event.clientY - tracking.startY,
    };
  };

  const finishLibraryPointerTracking = (event: React.PointerEvent<HTMLDivElement>) => {
    const tracking = librarySwipeTrackingRef.current;

    if (!tracking || tracking.pointerId !== event.pointerId) {
      return;
    }

    if (event.currentTarget.hasPointerCapture(event.pointerId)) {
      event.currentTarget.releasePointerCapture(event.pointerId);
    }

    librarySwipeTrackingRef.current = null;

    if (
      Math.abs(tracking.deltaX) < PAGE_TURN_DISTANCE_PX ||
      Math.abs(tracking.deltaY) > PAGE_TURN_VERTICAL_TOLERANCE_PX
    ) {
      return;
    }

    librarySwipeSuppressClickRef.current = true;

    if (tracking.deltaX < 0) {
      turnLibraryPage('next');
      return;
    }

    turnLibraryPage('prev');
  };

  const handleLibraryCardClick = (articleId: string) => {
    if (librarySwipeSuppressClickRef.current) {
      librarySwipeSuppressClickRef.current = false;
      return;
    }

    handleLoadSample(articleId);
  };

  const homePanel = (
    <section className="e-reader__panel e-reader__panel--home">
      <div className="e-reader__panel-header">
        <h2>E-Reader</h2>
        <div className="e-reader__panel-header-actions">
          <Button
            type="text"
            shape="circle"
            icon={<SettingOutlined />}
            className="e-reader__icon-button"
            aria-label="읽기 설정"
            onClick={() => moveToView('settings')}
          />
        </div>
      </div>
      <div className="e-reader__panel-body">
        <div className="e-reader__index-grid">
          <button type="button" className="e-reader__nav-card" onClick={() => moveToView('news')}>
            <span className="e-reader__book-icon">
              <SearchOutlined />
            </span>
            <strong>뉴스</strong>
            <p>날짜 조건으로 기사만 찾기</p>
          </button>
          <button type="button" className="e-reader__nav-card" onClick={() => moveToView('library')}>
            <span className="e-reader__book-icon">
              <BookOutlined />
            </span>
            <strong>서재</strong>
            <p>저장한 글 다시 읽기</p>
          </button>
          <button type="button" className="e-reader__nav-card" onClick={() => moveToView('import')}>
            <span className="e-reader__book-icon">
              <DownloadOutlined />
            </span>
            <strong>원문 가져오기</strong>
            <p>링크로 웹 글 저장</p>
          </button>
          <button
            type="button"
            className="e-reader__nav-card"
            onClick={() => {
              if (activeArticle) {
                moveToView('reader');
              }
            }}
            disabled={!activeArticle}
          >
            <span className="e-reader__book-icon">
              <ReadOutlined />
            </span>
            <strong>이어 읽기</strong>
            <p>{activeArticle ? activeArticle.title : '읽을 글이 없습니다.'}</p>
          </button>
        </div>
      </div>
    </section>
  );

  const newsPanel = (
    <section className="e-reader__panel e-reader__panel--news">
      <div className="e-reader__panel-header">
        <h2>뉴스</h2>
        <div className="e-reader__panel-header-actions">
          <Tag bordered={false} color="blue">
            {newsResults.length}건
          </Tag>
          <Button
            type="text"
            shape="circle"
            icon={<BookOutlined />}
            className="e-reader__icon-button"
            aria-label="서재"
            onClick={() => moveToView('library')}
          />
          <Button
            type="text"
            shape="circle"
            icon={<DownloadOutlined />}
            className="e-reader__icon-button"
            aria-label="원문 불러오기"
            onClick={() => moveToView('import')}
          />
          <Button
            type="text"
            shape="circle"
            icon={<SettingOutlined />}
            className="e-reader__icon-button"
            aria-label="읽기 설정"
            onClick={() => moveToView('settings')}
          />
        </div>
      </div>
      <div className="e-reader__panel-body e-reader__panel-body--locked">
        <div className="e-reader__news-stage">
          <div className="e-reader__news-filter-shell">
            <div className="e-reader__news-filter-topbar">
              <label className="e-reader__news-filter-date">
                <span>기준 날짜</span>
                <Input
                  type="date"
                  value={newsFilters.dateFrom}
                  onChange={(event) =>
                    setNewsFilters((current) => ({
                      ...current,
                      dateFrom: event.target.value,
                      dateTo: event.target.value,
                    }))
                  }
                />
              </label>
              <div className="e-reader__news-filter-topbar-actions">
                <Button type="primary" icon={<SearchOutlined />} loading={isSearchingNews} onClick={() => void handleSearchNews()}>
                  네이버 수집
                </Button>
                <Button type="text" onClick={() => setIsNewsFiltersCollapsed((current) => !current)} aria-expanded={!isNewsFiltersCollapsed}>
                  {isNewsFiltersCollapsed ? '조건 펼치기' : '조건 접기'}
                </Button>
              </div>
            </div>
            {isNewsFiltersCollapsed ? null : (
              <div className="e-reader__news-filter-grid">
                <label>
                  <span>키워드</span>
                  <Input
                    value={newsFilters.keyword}
                    onChange={(event) => setNewsFilters((current) => ({ ...current, keyword: event.target.value }))}
                    prefix={<SearchOutlined />}
                    placeholder="예: 반도체, 대선, OpenAI"
                    onPressEnter={() => void handleSearchNews()}
                  />
                </label>
                <label>
                  <span>분야</span>
                  <Select
                    mode="multiple"
                    value={newsFilters.topics}
                    options={READER_NEWS_TOPIC_OPTIONS}
                    onChange={(topics) => setNewsFilters((current) => ({ ...current, topics }))}
                    placeholder="전체 분야"
                    maxTagCount="responsive"
                    allowClear
                  />
                </label>
                <label>
                  <span>매체</span>
                  <Select
                    mode="tags"
                    value={newsFilters.sources}
                    options={newsSourceOptions}
                    onChange={(sources) => setNewsFilters((current) => ({ ...current, sources }))}
                    placeholder="예: 연합뉴스, Reuters"
                    tokenSeparators={[',']}
                    maxTagCount="responsive"
                  />
                </label>
                <label className="e-reader__news-filter-limit">
                  <span>수집량</span>
                  <Select
                    value={newsFilters.limit}
                    options={READER_NEWS_LIMIT_OPTIONS}
                    onChange={(limit) => setNewsFilters((current) => ({ ...current, limit }))}
                    aria-label="수집량"
                  />
                </label>
              </div>
            )}
          </div>
          <div className="e-reader__news-actions">
            <Button
              onClick={() => {
                setNewsFilters(createDefaultNewsFilters());
                setNewsStatus(createDefaultNewsFiltersStatus());
                setIsNewsFiltersCollapsed(false);
              }}
            >
              조건 초기화
            </Button>
            <Button icon={<ReadOutlined />} onClick={() => moveToView('reader')} disabled={!activeArticle}>
              이어 읽기
            </Button>
            <Button icon={<HomeOutlined />} onClick={() => void handleInstallReaderApp()}>
              홈 화면
            </Button>
          </div>
          <div className="e-reader__news-status-stack">
            <p className="e-reader__library-status">{createDefaultNewsFiltersStatus()}</p>
            {newsStatus ? <p className="e-reader__library-status">{newsStatus}</p> : null}
            {installStatus && !newsStatus ? <p className="e-reader__install-status">{installStatus}</p> : null}
          </div>
          {newsResults.length ? (
            <>
              <div
                className="e-reader__news-list e-reader__news-list--paged"
                style={{ ['--news-columns' as string]: String(newsColumns) }}
              >
                {visibleNewsResults.map((item) => (
                  <button
                    key={`${item.url}-${item.publishedAt ?? ''}`}
                    type="button"
                    className={`e-reader__news-card${isReadArticle(item) ? ' e-reader__news-card--read' : ''}`}
                    onClick={() => handleOpenNewsArticle(item)}
                  >
                    <div className="e-reader__card-topline">
                      <span className="e-reader__book-icon">
                        <ReadOutlined />
                      </span>
                      {item.signals?.length ? (
                        <div className="e-reader__news-signal-list">
                          {item.signals.map((signal) => (
                            <Tag key={`${item.id}-${signal}`} bordered={false} color={resolveNewsSignalTone(signal)}>
                              {signal}
                            </Tag>
                          ))}
                        </div>
                      ) : null}
                      {isReadArticle(item) ? (
                        <Tag bordered={false} color="default">
                          읽음
                        </Tag>
                      ) : null}
                    </div>
                    <strong>{item.title}</strong>
                    <div className="e-reader__news-meta">
                      <span>{item.sourceLabel}</span>
                      <div className="e-reader__timeline-meta">
                        {buildArticleTimelineDetails({
                          listedDate: normalizeListedDate(item.listedDate),
                          publishedAt: normalizePublishedAt(item.publishedAt),
                        }).map((detail) => (
                          <span key={`${item.id}-${detail}`}>{detail}</span>
                        ))}
                      </div>
                    </div>
                    <p>{item.lead}</p>
                  </button>
                ))}
              </div>
              {newsPages.length > 1 ? (
                <div className="e-reader__news-pager">
                  <Button onClick={() => setNewsPageIndex((currentIndex) => Math.max(currentIndex - 1, 0))} disabled={safeNewsPageIndex <= 0}>
                    이전 묶음
                  </Button>
                  <span className="e-reader__library-status">
                    {safeNewsPageIndex + 1} / {newsPages.length}
                  </span>
                  <Button
                    onClick={() => setNewsPageIndex((currentIndex) => Math.min(currentIndex + 1, newsPages.length - 1))}
                    disabled={safeNewsPageIndex >= newsPages.length - 1}
                  >
                    다음 묶음
                  </Button>
                </div>
              ) : null}
            </>
          ) : (
            <div className="e-reader__book-empty">
              <strong>뉴스 결과가 없습니다.</strong>
              <p>날짜와 멀티콤보 조건을 다시 조정해 주세요.</p>
            </div>
          )}
        </div>
      </div>
    </section>
  );

  const libraryPanel = (
    <section className="e-reader__panel e-reader__panel--library">
      <div className="e-reader__panel-header">
        <h2>서재</h2>
        <div className="e-reader__panel-header-actions">
          <Tag bordered={false} color="blue">
            {filteredArticles.length}/{articles.length}권
          </Tag>
          <Button
            type="text"
            shape="circle"
            icon={<ReloadOutlined />}
            className="e-reader__icon-button"
            aria-label="서재 동기화 다시 확인"
            loading={isLibrarySyncing}
            onClick={() => setLibrarySyncRequestId((currentValue) => currentValue + 1)}
          />
          <Button
            type="text"
            shape="circle"
            icon={<SettingOutlined />}
            className="e-reader__icon-button"
            aria-label="서재 설정"
            onClick={() => moveToView('settings')}
          />
        </div>
      </div>
      <div className={`e-reader__panel-body${libraryNavigationMode === 'touch-swipe' ? ' e-reader__panel-body--locked' : ''}`}>
        <div className="e-reader__library-toolbar">
          <AutoComplete
            className="e-reader__search-autocomplete"
            value={librarySearchTerm}
            options={libraryTagSuggestions}
            onChange={setLibrarySearchTerm}
            onSelect={setLibrarySearchTerm}
          >
            <Input
              prefix={<SearchOutlined />}
              placeholder="제목, 출처 검색 또는 #태그 추천"
              allowClear
            />
          </AutoComplete>
          <div className="e-reader__library-sort-row">
            <span className="e-reader__library-sort-count">{libraryCountLabel}</span>
            <button
              type="button"
              className="e-reader__library-sort-trigger"
              onClick={() => setIsLibrarySortSheetOpen(true)}
              aria-haspopup="dialog"
              aria-expanded={isLibrarySortSheetOpen}
              aria-label={`서재 정렬 변경, 현재 ${librarySortLabel}`}
            >
              <span>{librarySortLabel}</span>
              <DownOutlined />
            </button>
          </div>
        </div>
        {librarySyncStatus ? <p className="e-reader__library-status">{librarySyncStatus}</p> : null}
        {filteredArticles.length ? (
          libraryNavigationMode === 'touch-swipe' ? (
            <div className="e-reader__library-pager">
              <div
                className="e-reader__book-swipe-frame"
                onPointerDown={handleLibraryPointerDown}
                onPointerMove={handleLibraryPointerMove}
                onPointerUp={finishLibraryPointerTracking}
                onPointerCancel={finishLibraryPointerTracking}
              >
                <div
                  className="e-reader__book-list e-reader__book-list--paged"
                  style={{ ['--library-columns' as string]: String(libraryColumns) }}
                >
                  {visibleLibraryArticles.map((article) => (
                    <button
                      key={article.id}
                      type="button"
                      className={`e-reader__book-card${article.id === activeArticle?.id ? ' e-reader__book-card--active' : ''}${isReadArticle(article) ? ' e-reader__book-card--read' : ''}`}
                      onClick={() => handleLibraryCardClick(article.id)}
                    >
                      <div className="e-reader__card-topline">
                        <span className="e-reader__book-icon">
                          <BookOutlined />
                        </span>
                        {isReadArticle(article) ? (
                          <Tag bordered={false} color="default">
                            읽음
                          </Tag>
                        ) : null}
                      </div>
                      <strong>{article.title}</strong>
                      <span>{article.sourceLabel}</span>
                      <div className="e-reader__book-card-dates">
                        {buildArticleTimelineDetails(article).map((detail) => (
                          <span key={`${article.id}-${detail}`} className="e-reader__book-card-date">
                            {detail}
                          </span>
                        ))}
                      </div>
                      <p>{article.lead}</p>
                    </button>
                  ))}
                </div>
              </div>
            </div>
          ) : (
            <div className="e-reader__book-list">
              {filteredArticles.map((article) => (
                <button
                  key={article.id}
                  type="button"
                  className={`e-reader__book-card${article.id === activeArticle?.id ? ' e-reader__book-card--active' : ''}${isReadArticle(article) ? ' e-reader__book-card--read' : ''}`}
                  onClick={() => handleLibraryCardClick(article.id)}
                >
                  <div className="e-reader__card-topline">
                    <span className="e-reader__book-icon">
                      <BookOutlined />
                    </span>
                    {isReadArticle(article) ? (
                      <Tag bordered={false} color="default">
                        읽음
                      </Tag>
                    ) : null}
                  </div>
                  <strong>{article.title}</strong>
                  <span>{article.sourceLabel}</span>
                  <div className="e-reader__book-card-dates">
                    {buildArticleTimelineDetails(article).map((detail) => (
                      <span key={`${article.id}-${detail}`} className="e-reader__book-card-date">
                        {detail}
                      </span>
                    ))}
                  </div>
                  <p>{article.lead}</p>
                </button>
              ))}
            </div>
          )
        ) : (
          <div className="e-reader__book-empty">
            <strong>검색 결과가 없습니다.</strong>
            <p>추천된 태그를 고르거나 검색어를 지워 다시 확인해 주세요.</p>
          </div>
        )}
      </div>
      {isLibrarySortSheetOpen ? (
        <div className="e-reader__bottom-sheet" role="dialog" aria-modal="true" aria-label="서재 정렬 선택">
          <button
            type="button"
            className="e-reader__bottom-sheet-backdrop"
            aria-label="정렬 선택 닫기"
            onClick={() => setIsLibrarySortSheetOpen(false)}
          />
          <div className="e-reader__bottom-sheet-panel">
            <div className="e-reader__bottom-sheet-handle" aria-hidden="true" />
            <div className="e-reader__bottom-sheet-options">
              {READER_LIBRARY_SORT_OPTIONS.map((option) => {
                const isSelected = option.value === librarySort;
                return (
                  <button
                    key={option.value}
                    type="button"
                    className={`e-reader__bottom-sheet-option${isSelected ? ' e-reader__bottom-sheet-option--selected' : ''}`}
                    onClick={() => {
                      setLibrarySort(option.value);
                      setIsLibrarySortSheetOpen(false);
                    }}
                  >
                    <span>{option.label}</span>
                    {isSelected ? <CheckOutlined /> : null}
                  </button>
                );
              })}
            </div>
          </div>
        </div>
      ) : null}
    </section>
  );

  const importPanel = (
    <section className="e-reader__panel e-reader__panel--import">
      <div className="e-reader__panel-header">
        <h2>원문 자동 가져오기</h2>
        <ReadOutlined />
      </div>
      <div className="e-reader__panel-body">
        <div className="e-reader__import-fields">
          <label>
            <span>URL</span>
            <div className="e-reader__import-url-row">
              <Input
                value={draftUrl}
                onChange={(event) => setDraftUrl(event.target.value)}
                prefix={<LinkOutlined />}
                placeholder="https://..."
              />
              <Button type="primary" icon={<DownloadOutlined />} loading={isImporting} onClick={handleAutoImport}>
                원문 가져오기
              </Button>
            </div>
          </label>
          <label>
            <span>제목</span>
            <Input value={draftTitle} onChange={(event) => setDraftTitle(event.target.value)} placeholder="기사 제목" />
          </label>
          <div className="e-reader__import-grid">
            <label>
              <span>출처</span>
              <Input value={draftSourceLabel} onChange={(event) => setDraftSourceLabel(event.target.value)} placeholder="예: Reuters, Blog" />
            </label>
            <label>
              <span>다시 읽을 링크</span>
              <Input value={draftUrl} onChange={(event) => setDraftUrl(event.target.value)} prefix={<LinkOutlined />} placeholder="https://..." />
            </label>
          </div>
          <label className="e-reader__import-body-field">
            <span>본문</span>
            <TextArea
              className="e-reader__import-textarea"
              value={draftBody}
              onChange={(event) => setDraftBody(event.target.value)}
              autoSize={{ minRows: 12, maxRows: 24 }}
              placeholder="웹 기사 본문이나 읽고 싶은 인터넷 콘텐츠를 붙여 넣으세요."
            />
          </label>
        </div>
      </div>
      {librarySyncStatus ? <p className="e-reader__library-status">{librarySyncStatus}</p> : null}
      {importStatus ? <p className="e-reader__import-status">{importStatus}</p> : null}
      <div className="e-reader__import-actions">
        <Button type="primary" icon={<BookOutlined />} onClick={handleSaveClip}>
          내 서가에 저장
        </Button>
        <Button icon={<ReloadOutlined />} onClick={() => activeArticle && handleLoadSample(activeArticle.id)}>
          현재 글 다시 펼치기
        </Button>
      </div>
    </section>
  );

  const settingsPanel = (
    <section className="e-reader__panel e-reader__panel--settings">
      <div className="e-reader__panel-header">
        <h2>읽기 설정</h2>
        <FontSizeOutlined />
      </div>
      <div className="e-reader__panel-body">
        <div className="e-reader__theme-switcher" role="tablist" aria-label="리더 테마">
          {READER_THEME_OPTIONS.map(({ value, label }) => (
            <button
              key={value}
              type="button"
              className={`e-reader__theme-chip${theme === value ? ' e-reader__theme-chip--active' : ''}`}
              onClick={() => setTheme(value)}
            >
              {label}
            </button>
          ))}
        </div>
        <div className="e-reader__font-control">
          <span>글자 크기</span>
          <Slider min={15} max={23} value={fontSize} onChange={(value) => setFontSize(Array.isArray(value) ? value[0] ?? 18 : value)} />
        </div>
        <div className="e-reader__font-control">
          <span>페이지 이동 방식</span>
          <div className="e-reader__gesture-switcher" role="tablist" aria-label="페이지 이동 방식">
            {([
              ['tap-swipe', '좌우 탭'],
              ['touch-scroll', '터치 스크롤'],
            ] as const).map(([modeValue, label]) => (
              <button
                key={modeValue}
                type="button"
                className={`e-reader__theme-chip${gestureMode === modeValue ? ' e-reader__theme-chip--active' : ''}`}
                onClick={() => setGestureMode(modeValue)}
              >
                {label}
              </button>
            ))}
          </div>
        </div>
        <div className="e-reader__gesture-hint">
          <SwapOutlined />
          <span>
            {gestureMode === 'touch-scroll'
              ? '위로 밀면 다음 페이지, 아래로 밀면 이전 페이지로 이동합니다.'
              : '좌우 스와이프 또는 좌우 탭으로 페이지를 넘깁니다.'}
          </span>
        </div>
        <div className="e-reader__font-control">
          <span>서재 이동 방식</span>
          <div className="e-reader__gesture-switcher" role="tablist" aria-label="서재 이동 방식">
            {([
              ['scroll', '세로 스크롤'],
              ['touch-swipe', '좌우 터치'],
            ] as const).map(([modeValue, label]) => (
              <button
                key={modeValue}
                type="button"
                className={`e-reader__theme-chip${libraryNavigationMode === modeValue ? ' e-reader__theme-chip--active' : ''}`}
                onClick={() => setLibraryNavigationMode(modeValue)}
              >
                {label}
              </button>
            ))}
          </div>
        </div>
        <div className="e-reader__gesture-hint">
          <SwapOutlined />
          <span>
            {libraryNavigationMode === 'touch-swipe' ? `${safeLibraryPageIndex + 1} / ${libraryPages.length}` : '세로 스크롤'}
          </span>
        </div>
        <div className="e-reader__install-panel">
          <div className="e-reader__install-copy">
            <strong>{installState === 'standalone' ? '홈 화면 앱 실행 중' : 'E-Reader 홈 화면 추가'}</strong>
            <span>{installState === 'installable' ? '현재 브라우저에서 바로 설치할 수 있습니다.' : getInstallGuideMessage()}</span>
            <code>{getReaderLaunchUrl()}</code>
          </div>
          <Button type="primary" icon={<HomeOutlined />} onClick={() => void handleInstallReaderApp()}>
            {installState === 'standalone' ? '설치 완료' : '홈 화면에 추가'}
          </Button>
        </div>
        {installStatus ? <p className="e-reader__install-status">{installStatus}</p> : null}
      </div>
    </section>
  );

  const currentPanel =
    currentView === 'home'
      ? homePanel
      : currentView === 'library'
      ? libraryPanel
      : currentView === 'import'
        ? importPanel
        : currentView === 'settings'
          ? settingsPanel
          : newsPanel;

  return (
    <section
      className={`e-reader e-reader--${theme}`}
      data-testid="e-reader-app"
      onPointerCancelCapture={finishBackGesturePointerTrackingCapture}
      onPointerDownCapture={handleBackGesturePointerDownCapture}
      onPointerMoveCapture={handleBackGesturePointerMoveCapture}
      onPointerUpCapture={finishBackGesturePointerTrackingCapture}
    >
      <div className="e-reader__floating-bar">
        <div className="e-reader__floating-actions">
          {canShowParentBackButton ? (
            <Button
              type="text"
              shape="circle"
              icon={<ArrowLeftOutlined />}
              className="e-reader__icon-button"
              aria-label="부모 앱으로 돌아가기"
              onClick={handleExitToParentApp}
            />
          ) : null}
          {canShowInternalBackButton ? (
            <Button
              type="text"
              shape="circle"
              icon={<ArrowLeftOutlined />}
              className="e-reader__icon-button"
              aria-label="뒤로가기"
              onClick={handleGoBack}
            />
          ) : null}
          {currentView === 'reader' ? (
            <>
              <Button
                type="text"
                shape="circle"
                icon={<ArrowLeftOutlined />}
                className="e-reader__icon-button"
                aria-label="이전 글"
                onClick={() => moveToAdjacentArticle('prev')}
                disabled={!previousArticle}
              />
              <Button
                type="text"
                shape="circle"
                icon={<ArrowRightOutlined />}
                className="e-reader__icon-button"
                aria-label="다음 글"
                onClick={() => moveToAdjacentArticle('next')}
                disabled={!nextArticle}
              />
            </>
          ) : null}
        </div>
        {currentView === 'reader' ? (
          <div className="e-reader__floating-status">
            <Tag bordered={false}>
              {articles.length ? `${Math.max(activeArticleIndex + 1, 1)} / ${articles.length}` : '0 / 0'}
            </Tag>
            <Tag bordered={false} color="blue">
              {pageCount ? `${safePageIndex + 1} / ${pageCount}` : '0 / 0'}
            </Tag>
          </div>
        ) : null}
      </div>

      <main className="e-reader__reader-stage">
        {currentView === 'reader' ? (
          <section className="e-reader__reader-panel">
            <div
              className="e-reader__page-frame"
              onPointerDown={handlePointerDown}
              onPointerMove={handlePointerMove}
              onPointerUp={finishPointerTracking}
              onPointerCancel={finishPointerTracking}
              onClick={handleReaderTap}
            >
              <div className={`e-reader__page-stack${isPageContentHidden || !isMeasuredPaginationReady ? ' e-reader__page-stack--hidden' : ''}`}>
                <div
                  className={`e-reader__page-layer${visiblePageSlot === 'primary' ? ' e-reader__page-layer--visible' : ''}`}
                  ref={primaryPageRef}
                  aria-hidden={visiblePageSlot !== 'primary'}
                >
                  <ReaderPageCard
                    articleTitle={primaryPageCard.articleTitle}
                    fontSize={fontSize}
                    listedDate={primaryPageCard.listedDate}
                    page={primaryPageCard.page}
                    pageLabel={primaryPageCard.pageLabel}
                    progressPercent={primaryPageCard.progressPercent}
                    publishedAt={primaryPageCard.publishedAt}
                    sourceLabel={primaryPageCard.sourceLabel}
                  />
                </div>
                <div
                  className={`e-reader__page-layer${visiblePageSlot === 'secondary' ? ' e-reader__page-layer--visible' : ''}${pendingPageTurn ? ' e-reader__page-layer--preloading' : ''}`}
                  ref={secondaryPageRef}
                  aria-hidden={visiblePageSlot !== 'secondary'}
                >
                  <ReaderPageCard
                    articleTitle={secondaryPageCard.articleTitle}
                    fontSize={fontSize}
                    listedDate={secondaryPageCard.listedDate}
                    page={secondaryPageCard.page}
                    pageLabel={secondaryPageCard.pageLabel}
                    progressPercent={secondaryPageCard.progressPercent}
                    publishedAt={secondaryPageCard.publishedAt}
                    sourceLabel={secondaryPageCard.sourceLabel}
                  />
                </div>
                {pendingPageTurn ? (
                  <div className="e-reader__page-preload" aria-hidden="true" />
                ) : null}
                {isPageContentHidden || !isMeasuredPaginationReady ? <div className="e-reader__page-veil" aria-hidden="true" /> : null}
                <div className="e-reader__page-measure" aria-hidden="true">
                  <div className="e-reader__page">
                    <div className="e-reader__page-topline">
                      <div className="e-reader__page-topline-meta">
                        <span>{paginatedActiveArticle?.sourceLabel ?? 'Reader'}</span>
                        {buildArticleTimelineDetails({
                          listedDate: paginatedActiveArticle?.listedDate,
                          publishedAt: paginatedActiveArticle?.publishedAt,
                        }).map((detail) => (
                          <span key={`measure-${detail}`} className="e-reader__page-topline-detail">
                            {detail}
                          </span>
                        ))}
                      </div>
                      <span>{pageCount ? `${safeDisplayPageIndex + 1} / ${pageCount}` : '0 / 0'}</span>
                    </div>
                    <div className="e-reader__page-body e-reader__page-body--measure" ref={measurementBodyRef} style={{ fontSize: `${fontSize}px` }} />
                    <div className="e-reader__page-bottomline">
                      <span>{paginatedActiveArticle?.title ?? 'Untitled'}</span>
                      <span>{pageCount > 0 ? Math.round(((safeDisplayPageIndex + 1) / pageCount) * 100) : 0}%</span>
                    </div>
                  </div>
                </div>
              </div>
            </div>
          </section>
        ) : (
          <section className="e-reader__menu-stage">{currentPanel}</section>
        )}
      </main>
    </section>
  );
}
