import type { ScrollBoxRenderable } from '@opentui/core';
import { useKeyboard, useTerminalDimensions } from '@opentui/react';
import { format } from 'date-fns';
import { useEffect, useMemo, useRef } from 'react';
import { fetchArticle } from '../../fetchers/article';
import { truncateUtf16 } from '../../utils/bounded-text';
import { MAX_CONTENT_SIZE, MAX_DESCRIPTION_SIZE } from '../../utils/constants';
import { containsHtml, hasRenderableContent, htmlToMarkdown } from '../../utils/html';
import { sanitizeTerminalText } from '../../utils/terminal';
import { markRead, toggleRead, toggleStar } from '../hooks/useDataLoader';
import { isTuiShutdownError, useTuiLifecycle } from '../lifecycle';
import { isSafeExternalUrl, parseDocument } from '../markdown/parse';
import { resolveAdjacentArticle } from '../models/article-list';
import { dispatchError, useAppDispatch, useAppState } from '../state/context';
import { generateHintLabels } from '../state/hint-labels';
import { truncateTerminalText } from '../text';
import { colors, icons } from '../theme';
import { MarkdownRenderer } from './MarkdownRenderer';

export function ArticleReader() {
  const state = useAppState();
  const dispatch = useAppDispatch();
  const lifecycle = useTuiLifecycle();
  const { width, height } = useTerminalDimensions();
  const article = state.selectedArticle;
  const scrollRef = useRef<ScrollBoxRenderable>(null);
  const selectedArticleIdRef = useRef<number | null>(article?.id ?? null);
  const contentFetchInFlightRef = useRef<{ articleId: number; link: string | undefined } | null>(null);
  const articleRef = useRef(article);
  selectedArticleIdRef.current = article?.id ?? null;
  articleRef.current = article;
  const articleId = article?.id;
  const articleLink = article?.link;
  const articleContent = article?.content;
  const articleContentFetchedAt = article?.contentFetchedAt;
  const hasUsableArticleContent = hasRenderableContent(articleContent);
  const articleUrl = article?.linkFixed ?? article?.link ?? '';
  const readerPaneWidth = useMemo(() => {
    const terminalWidth = Math.max(0, Math.floor(width));
    if (state.layoutMode !== 'wide') return terminalWidth;
    const feedWidth = Math.max(0, state.feedPanelWidth ?? 0);
    const articlePaneWidth = Math.max(0, state.articlePanelWidth ?? 0);
    return Math.max(0, terminalWidth - feedWidth - articlePaneWidth);
  }, [width, state.layoutMode, state.feedPanelWidth, state.articlePanelWidth]);
  const metadataRuleWidth = Math.max(0, Math.min(readerPaneWidth - 6, 60));
  const markdownWidth = Math.max(0, readerPaneWidth - 6);

  // Fetch each selected article at most once while it is in flight. Read/star
  // updates replace the article object, so depending on object identity here
  // would otherwise issue duplicate content requests.
  useEffect(() => {
    const articleToFetch = articleRef.current;
    if (!articleToFetch || hasUsableArticleContent || articleContentFetchedAt || articleId === undefined) return;
    if (contentFetchInFlightRef.current?.articleId === articleId) return;

    let cancelled = false;
    // Capture the link generation as well as the ID. The same Article can be
    // refreshed with a new canonical link while an older extraction resolves.
    const request = { articleId, link: articleLink };
    const requestController = new AbortController();
    const abortRequest = () => requestController.abort(lifecycle.signal.reason);
    if (lifecycle.signal.aborted) abortRequest();
    else lifecycle.signal.addEventListener('abort', abortRequest, { once: true });
    contentFetchInFlightRef.current = request;

    void lifecycle
      .run(() =>
        fetchArticle(articleToFetch, { skipBroken: false, flagBroken: true, signal: requestController.signal }),
      )
      .then((result) => {
        if (
          lifecycle.isAcceptingWork() &&
          !cancelled &&
          (result.persisted || result.current) &&
          contentFetchInFlightRef.current === request &&
          selectedArticleIdRef.current === articleId &&
          articleRef.current?.link === request.link
        ) {
          dispatch({
            type: 'UPDATE_ARTICLE',
            articleId,
            changes: {
              // A CAS loser can return a newer row written by another process.
              // Keep its fetch generation together: retaining the stale link
              // would make a subsequent retry target the wrong URL.
              link: result.article.link,
              content: result.article.content,
              contentFetchedAt: result.article.contentFetchedAt,
              isBroken: result.article.isBroken,
              brokenReason: result.article.brokenReason,
              linkFixed: result.article.linkFixed,
              updatedAt: result.article.updatedAt,
            },
          });
        }
      })
      .catch((err) => {
        if (
          !isTuiShutdownError(err) &&
          lifecycle.isAcceptingWork() &&
          !cancelled &&
          selectedArticleIdRef.current === articleId
        ) {
          dispatchError(dispatch, err);
        }
      })
      .finally(() => {
        lifecycle.signal.removeEventListener('abort', abortRequest);
        if (contentFetchInFlightRef.current === request) contentFetchInFlightRef.current = null;
      })
      .catch(() => {
        // The success/error handlers above own fetch failures. This final guard
        // prevents an unexpected renderer/dispatch failure from becoming a
        // detached Promise rejection.
      });

    return () => {
      cancelled = true;
      requestController.abort();
      lifecycle.signal.removeEventListener('abort', abortRequest);
      if (contentFetchInFlightRef.current === request) contentFetchInFlightRef.current = null;
    };
  }, [articleId, articleLink, articleContentFetchedAt, dispatch, hasUsableArticleContent, lifecycle]);

  // Reset scroll to the top whenever a different article is opened, so the
  // reader doesn't inherit the previous article's scroll position.
  // biome-ignore lint/correctness/useExhaustiveDependencies: scrollRef is stable; article?.id is the intended trigger so switching articles resets scroll.
  useEffect(() => {
    scrollRef.current?.scrollTo({ x: 0, y: 0 });
  }, [article?.id]);

  // Build the markdown text once per content change. Keyed on the raw content
  // strings rather than the article object identity so star/read toggles
  // (which produce a new article object) don't re-run cheerio + turndown.
  const contentTextForParse = useMemo(() => {
    let text = hasRenderableContent(article?.content)
      ? (truncateUtf16(article.content, MAX_CONTENT_SIZE) ?? '')
      : truncateUtf16(article?.description ?? undefined, MAX_DESCRIPTION_SIZE) || '';
    text = sanitizeTerminalText(text, { preserveNewlines: true });
    if (containsHtml(text)) text = htmlToMarkdown(text);
    return text;
  }, [article?.content, article?.description]);

  // Parse the document a single time and share both halves: the renderer reuses
  // `blocks`, and hint mode reuses `links`, so the markdown isn't parsed twice.
  const parsed = useMemo(() => parseDocument(contentTextForParse), [contentTextForParse]);
  const parsedLinks = parsed.links;

  // Hint targets are snapshotted at ENTER_HINT_MODE. If a background content
  // fetch lands while hint mode is open, the link set changes underneath the
  // overlay and label `i` would point at the wrong URL. Exit hint mode whenever
  // the link set changes (skipping the initial render so we don't close a
  // freshly-opened overlay).
  const prevLinksRef = useRef(parsedLinks);
  useEffect(() => {
    if (prevLinksRef.current !== parsedLinks) {
      prevLinksRef.current = parsedLinks;
      if (state.overlay === 'hint') {
        dispatch({ type: 'EXIT_HINT_MODE' });
      }
    }
  }, [parsedLinks, state.overlay, dispatch]);

  // Map linkIndex -> hint label for the renderer to overlay.
  const hintLabels = useMemo(() => {
    if (state.overlay !== 'hint' || state.hintTargets.length === 0) return undefined;
    const map = new Map<number, string>();
    for (const [targetIndex, target] of state.hintTargets.entries()) {
      const linkIndex = parsedLinks[targetIndex]?.linkIndex;
      if (linkIndex !== undefined) map.set(linkIndex, target.label);
    }
    return map;
  }, [parsedLinks, state.overlay, state.hintTargets]);

  useKeyboard((key) => {
    if (state.activePanel !== 'reader') return;

    // Hint mode owns all keystrokes while active.
    if (state.overlay === 'hint') {
      if (key.name === 'escape') {
        dispatch({ type: 'EXIT_HINT_MODE' });
        return;
      }
      if (key.name && /^[a-z]$/.test(key.name)) {
        const next = state.hintBuffer + key.name;
        const exact = state.hintTargets.find((t) => t.label === next);
        if (exact) {
          if (isSafeExternalUrl(exact.url)) {
            import('open').then((mod) => mod.default(exact.url)).catch(() => {});
          }
          dispatch({ type: 'EXIT_HINT_MODE' });
          return;
        }
        const hasPrefix = state.hintTargets.some((t) => t.label.startsWith(next));
        if (hasPrefix) {
          dispatch({ type: 'APPEND_HINT_KEY', key: key.name });
          return;
        }
        // Typo / no match — bail out so the user can retry.
        dispatch({ type: 'EXIT_HINT_MODE' });
        return;
      }
      // Non-letter keys are swallowed silently.
      return;
    }

    if (state.overlay !== 'none') return;

    // u -> open link via hint mode
    if (key.name === 'u') {
      if (parsedLinks.length === 0) return;
      const labels = generateHintLabels(parsedLinks.length);
      const targets = parsedLinks.map((l, i) => ({ url: l.url, label: labels[i] ?? '' }));
      dispatch({ type: 'ENTER_HINT_MODE', targets });
      return;
    }

    // space / pagedown -> page down ; b / pageup -> page up.
    // Keep ~3 lines of overlap so context isn't lost between pages.
    if (key.name === 'space' || key.name === 'pagedown') {
      const pageSize = Math.max(1, height - 8);
      scrollRef.current?.scrollBy({ x: 0, y: pageSize });
      return;
    }
    if (key.name === 'b' || key.name === 'pageup') {
      const pageSize = Math.max(1, height - 8);
      scrollRef.current?.scrollBy({ x: 0, y: -pageSize });
      return;
    }

    // h / Left / Escape -> back
    if (key.name === 'h' || key.name === 'left' || key.name === 'escape') {
      dispatch({ type: 'SET_PANEL', panel: 'articles' });
      return;
    }

    // s -> toggle star
    if (key.name === 's' && article) {
      void lifecycle
        .run(() => toggleStar(article.id, dispatch, () => lifecycle.isAcceptingWork()))
        .catch((err) => {
          if (!isTuiShutdownError(err) && lifecycle.isAcceptingWork()) dispatchError(dispatch, err);
        });
      return;
    }

    // r -> toggle read
    if (key.name === 'r' && article) {
      void lifecycle
        .run(() => toggleRead(article.id, dispatch, () => lifecycle.isAcceptingWork()))
        .catch((err) => {
          if (!isTuiShutdownError(err) && lifecycle.isAcceptingWork()) dispatchError(dispatch, err);
        });
      return;
    }

    // o -> open in browser
    if (key.name === 'o' && article && isSafeExternalUrl(articleUrl)) {
      import('open').then((mod) => mod.default(articleUrl)).catch(() => {});
      return;
    }

    // n -> next article ; p -> prev article. Resolve relative to the current
    // article's real position so navigation stays correct even when the open
    // article was removed from the list (unread-only / starred filters).
    if (key.name === 'n' || key.name === 'p') {
      const direction = key.name === 'n' ? 'next' : 'prev';
      const target = resolveAdjacentArticle(state.articles, article?.id, state.articleListIndex, direction);
      if (target) {
        dispatch({ type: 'SET_ARTICLE_LIST_INDEX', index: target.index });
        dispatch({ type: 'SET_SELECTED_ARTICLE', article: target.article });
        if (!target.article.isRead) {
          void lifecycle
            .run(() => markRead(target.article.id, dispatch, () => lifecycle.isAcceptingWork()))
            .catch((err) => {
              if (!isTuiShutdownError(err) && lifecycle.isAcceptingWork()) dispatchError(dispatch, err);
            });
        }
      }
      return;
    }
  });

  if (!article) {
    return (
      <box
        border
        borderStyle="rounded"
        borderColor={state.activePanel === 'reader' ? colors.borderActive : colors.border}
        backgroundColor={colors.bgPanel}
        height="100%"
        justifyContent="center"
        alignItems="center"
      >
        <text fg={colors.textSecondary}>Select an article to read</text>
        <text>
          <span fg={colors.textMuted}>Press </span>
          <span fg={colors.accentBlue}>
            <strong>?</strong>
          </span>
          <span fg={colors.textMuted}> for keyboard shortcuts</span>
        </text>
      </box>
    );
  }

  const contentText = contentTextForParse;
  const articleTitle = sanitizeTerminalText(article.title ?? 'Untitled');
  const feedTitle = sanitizeTerminalText(article.feed?.title ?? '');
  const author = article.author ? sanitizeTerminalText(article.author) : '';
  const displayedArticleUrl = sanitizeTerminalText(articleUrl);

  const publishDate = article.publishedAt ? format(article.publishedAt, "MMM d, yyyy 'at' HH:mm") : 'Unknown date';

  const readStatus = article.isRead ? 'Read' : 'Unread';
  const starStatus = article.isStarred ? `${icons.starred} Starred` : '';
  const readerTitle = truncateTerminalText(articleTitle || 'Article', Math.max(0, Math.min(50, readerPaneWidth - 4)));

  return (
    <box
      flexDirection="column"
      border
      borderStyle="rounded"
      borderColor={state.activePanel === 'reader' ? colors.borderActive : colors.border}
      title={readerTitle ? ` ${readerTitle} ` : ''}
      titleAlignment="left"
      backgroundColor={colors.bgPanel}
      height="100%"
    >
      <scrollbox ref={scrollRef} focused={state.activePanel === 'reader'} height="100%">
        {/* Article metadata header */}
        <box flexDirection="column" paddingLeft={2} paddingRight={2} paddingTop={1}>
          <text wrapMode="char">
            <span fg={colors.textPrimary}>
              <strong>{articleTitle}</strong>
            </span>
          </text>
          <text fg={colors.accentBlue}>{'═'.repeat(metadataRuleWidth)}</text>
          <text wrapMode="char">
            <span fg={colors.accentPurple}>{feedTitle}</span>
            {author ? (
              <span fg={colors.textSecondary}>
                {' · '}
                {author}
              </span>
            ) : null}
            <span fg={colors.textSecondary}>
              {' · '}
              {publishDate}
            </span>
          </text>
          <text>
            <span fg={article.isRead ? colors.textMuted : colors.accentGreen}>
              {article.isRead ? icons.read : icons.unread} {readStatus}
            </span>
            {starStatus ? (
              <span fg={colors.accentYellow}>
                {' · '}
                {starStatus}
              </span>
            ) : null}
          </text>
          <text fg={colors.textMuted} wrapMode="char">
            {displayedArticleUrl}
          </text>
          <text fg={colors.textMuted}>{'─'.repeat(metadataRuleWidth)}</text>
        </box>

        {/* Article content */}
        <box paddingLeft={2} paddingRight={2} paddingBottom={2}>
          {contentText ? (
            <MarkdownRenderer
              content={contentText}
              blocks={parsed.blocks}
              width={markdownWidth}
              hintLabels={hintLabels}
            />
          ) : (
            <text fg={colors.textSecondary} wrapMode="char">
              No content available. Press 'o' to open in browser.
            </text>
          )}
        </box>
      </scrollbox>
    </box>
  );
}
