<script lang="ts">
  import { tick, untrack } from "svelte";
  import { listMessages } from "$lib/api/endpoints";
  import { type ViewerItem, viewerItemsFrom } from "$lib/api/media";
  import type { LiveEvent, MessageView } from "$lib/api/types";
  import ActionMessage from "$lib/components/ActionMessage.svelte";
  import MediaViewer from "$lib/components/MediaViewer.svelte";
  import MessageBubble from "$lib/components/MessageBubble.svelte";
  import MessageVersions from "$lib/components/MessageVersions.svelte";
  import PinnedBar from "$lib/components/PinnedBar.svelte";
  import TrackChat from "$lib/components/TrackChat.svelte";
  import Icon from "$lib/components/ui/Icon.svelte";
  import Spinner from "$lib/components/ui/Spinner.svelte";
  import { formatDay } from "$lib/format/datetime";
  import { accounts } from "$lib/stores/accounts.svelte";
  import { events } from "$lib/stores/events.svelte";
  import { peers } from "$lib/stores/peers.svelte";
  import { toasts } from "$lib/stores/toasts.svelte";
  import { ui } from "$lib/stores/ui.svelte";

  interface Props {
    chatId: number;
  }

  let { chatId }: Props = $props();

  const skeletonBubbles = [42, 66, 28, 54, 38, 72, 46];

  const PAGE = 60;
  const SCROLL_THRESHOLD = 160;
  const STICK_OFFSET = 9;
  const IDLE_DELAY = 1500;

  let messages = $state<MessageView[]>([]);
  let loading = $state(true);
  let loadingOlder = $state(false);
  let loadingNewer = $state(false);
  let hasMore = $state(true);
  let hasNewer = $state(false);
  let container = $state<HTMLDivElement | null>(null);
  let suppressAppear = $state(false);
  let scrolling = $state(false);
  let stuckDay = $state<string | null>(null);
  let idleTimer: ReturnType<typeof setTimeout> | null = null;

  let mediaOpen = $state(false);
  let mediaItems = $state<ViewerItem[]>([]);
  let mediaIndex = $state(0);
  let versionsOpen = $state(false);
  let versionsMessageId = $state<number | null>(null);
  let highlightId = $state<number | null>(null);
  let highlightTimer: ReturnType<typeof setTimeout> | null = null;

  const ownId = $derived(accounts.selected?.tg_user_id ?? null);
  const isGroupChat = $derived(chatId < 0);

  interface Row {
    dayKey: string;
    daySeparator: string | null;
    firstInGroup: boolean;
    lastInGroup: boolean;
    message: MessageView;
    own: boolean;
  }

  function dayKey(iso: string): string {
    return new Date(iso).toDateString();
  }

  const rows = $derived.by<Row[]>(() => {
    const out: Row[] = [];
    for (let i = 0; i < messages.length; i++) {
      const message = messages[i];
      const prev = messages[i - 1];
      const next = messages[i + 1];
      const day = dayKey(message.date);
      const samePrevDay = prev ? dayKey(prev.date) === day : false;
      const sameNextDay = next ? dayKey(next.date) === day : false;
      out.push({
        message,
        dayKey: day,
        own: ownId !== null && message.sender_id === ownId,
        firstInGroup:
          !prev || prev.sender_id !== message.sender_id || !samePrevDay,
        lastInGroup:
          !next || next.sender_id !== message.sender_id || !sameNextDay,
        daySeparator: samePrevDay ? null : formatDay(message.date),
      });
    }
    return out;
  });

  function ensurePeers(items: MessageView[]) {
    const ids = new Set<number>();
    for (const message of items) {
      if (message.sender_id !== null) {
        ids.add(message.sender_id);
      }
      if (message.reply?.sender_id != null) {
        ids.add(message.reply.sender_id);
      }
      if (message.forward?.from_id != null) {
        ids.add(message.forward.from_id);
      }
      if (message.service?.member_ids) {
        for (const id of message.service.member_ids) {
          ids.add(id);
        }
      }
    }
    if (ownId !== null) {
      ids.add(ownId);
    }
    peers.ensure(ids);
  }

  function jumpToMessage(messageId: number) {
    const target = container?.querySelector<HTMLElement>(
      `[data-message-id="${messageId}"]`
    );
    if (!target) {
      toasts.error("Message not loaded");
      return;
    }
    target.scrollIntoView({ behavior: "smooth", block: "center" });
    highlightId = messageId;
    if (highlightTimer) {
      clearTimeout(highlightTimer);
    }
    highlightTimer = setTimeout(() => {
      highlightId = null;
    }, 1600);
  }

  function scrollToBottom() {
    if (container) {
      container.scrollTop = container.scrollHeight;
    }
  }

  function updateStuck() {
    if (!container) {
      return;
    }
    const limit = container.getBoundingClientRect().top + STICK_OFFSET + 1;
    const seps = container.querySelectorAll<HTMLElement>(".day-separator");
    let day: string | null = null;
    for (const sep of seps) {
      if (sep.getBoundingClientRect().top <= limit) {
        day = sep.dataset.day ?? null;
      }
    }
    stuckDay = day;
  }

  async function loadInitial() {
    loading = true;
    hasMore = true;
    hasNewer = false;
    try {
      const page = await listMessages(chatId, {
        limit: PAGE,
        offset: 0,
        include_deleted: true,
      });
      messages = [...page].reverse();
      hasMore = page.length > 0;
      ensurePeers(messages);
      await tick();
      scrollToBottom();
      updateStuck();
    } catch {
      toasts.error("Failed to load messages");
    } finally {
      loading = false;
    }
  }

  async function loadOlder() {
    if (
      loadingOlder ||
      !hasMore ||
      container === null ||
      messages.length === 0
    ) {
      return;
    }
    loadingOlder = true;
    const el = container;
    const prevHeight = el.scrollHeight;
    const prevTop = el.scrollTop;
    try {
      const page = await listMessages(chatId, {
        limit: PAGE,
        before_id: messages[0].message_id,
        include_deleted: true,
      });
      const known = new Set(messages.map((m) => m.message_id));
      const fresh = page.filter((m) => !known.has(m.message_id));
      hasMore = fresh.length > 0;
      if (fresh.length > 0) {
        suppressAppear = true;
        messages = [...[...fresh].reverse(), ...messages];
        ensurePeers(fresh);
        await tick();
        el.scrollTop = prevTop + (el.scrollHeight - prevHeight);
      }
    } finally {
      loadingOlder = false;
      suppressAppear = false;
    }
  }

  async function loadNewer() {
    if (loadingNewer || !hasNewer || messages.length === 0) {
      return;
    }
    loadingNewer = true;
    try {
      const page = await listMessages(chatId, {
        limit: PAGE,
        after_id: messages.at(-1)?.message_id,
        include_deleted: true,
      });
      const known = new Set(messages.map((m) => m.message_id));
      const fresh = page.filter((m) => !known.has(m.message_id));
      hasNewer = fresh.length > 0;
      if (fresh.length > 0) {
        suppressAppear = true;
        messages = [...messages, ...fresh];
        ensurePeers(fresh);
        await tick();
      }
    } finally {
      loadingNewer = false;
      suppressAppear = false;
    }
  }

  async function loadAround(messageId: number) {
    suppressAppear = true;
    try {
      const [older, newer] = await Promise.all([
        listMessages(chatId, {
          limit: PAGE,
          before_id: messageId + 1,
          include_deleted: true,
        }),
        listMessages(chatId, {
          limit: PAGE,
          after_id: messageId,
          include_deleted: true,
        }),
      ]);
      messages = [...[...older].reverse(), ...newer];
      hasMore = older.length > 0;
      hasNewer = newer.length > 0;
      ensurePeers(messages);
      await tick();
    } finally {
      suppressAppear = false;
    }
  }

  async function goToLatest() {
    await loadInitial();
  }

  function onScroll() {
    scrolling = true;
    if (idleTimer) {
      clearTimeout(idleTimer);
    }
    idleTimer = setTimeout(() => {
      scrolling = false;
    }, IDLE_DELAY);
    updateStuck();
    if (container && container.scrollTop < SCROLL_THRESHOLD) {
      loadOlder();
    }
    if (hasNewer && isNearBottom()) {
      loadNewer();
    }
  }

  function openMedia(message: MessageView, index: number) {
    mediaItems = viewerItemsFrom(message.message_id, message.media);
    mediaIndex = index;
    mediaOpen = true;
  }

  function openVersions(messageId: number) {
    versionsMessageId = messageId;
    versionsOpen = true;
  }

  function isNearBottom(): boolean {
    if (!container) {
      return true;
    }
    return (
      container.scrollHeight - container.scrollTop - container.clientHeight <
      SCROLL_THRESHOLD
    );
  }

  function replaceMessage(message: MessageView) {
    messages = messages.map((m) =>
      m.message_id === message.message_id ? message : m
    );
  }

  async function appendMessage(message: MessageView) {
    if (messages.some((m) => m.message_id === message.message_id)) {
      replaceMessage(message);
      return;
    }
    if (hasNewer) {
      return;
    }
    const stick = isNearBottom();
    messages = [...messages, message];
    ensurePeers([message]);
    if (stick) {
      await tick();
      scrollToBottom();
    }
  }

  function applyDelete(ids: number[]) {
    const now = new Date().toISOString();
    messages = messages.map((m) =>
      ids.includes(m.message_id) && m.deleted_at === null
        ? { ...m, deleted_at: now }
        : m
    );
  }

  function applyReceipt(readUpTo: number) {
    if (ownId === null) {
      return;
    }
    messages = messages.map((m) =>
      m.sender_id === ownId && m.message_id <= readUpTo && !m.read
        ? { ...m, read: true }
        : m
    );
  }

  function applyLiveEvent(event: LiveEvent) {
    if (event.type === "message" && event.message.chat_id === chatId) {
      appendMessage(event.message);
    } else if (
      (event.type === "edit" || event.type === "reaction") &&
      event.message.chat_id === chatId
    ) {
      replaceMessage(event.message);
    } else if (
      event.type === "delete" &&
      (event.chat_id === null || event.chat_id === chatId)
    ) {
      applyDelete(event.message_ids);
    } else if (event.type === "receipt" && event.chat_id === chatId) {
      applyReceipt(event.read_up_to);
    }
  }

  async function resyncTail() {
    if (messages.length === 0 || hasNewer) {
      return;
    }
    const page = await listMessages(chatId, {
      limit: PAGE,
      offset: 0,
      include_deleted: true,
    });
    const fresh = [...page].reverse();
    const known = new Map(messages.map((m) => [m.message_id, m]));
    const maxId = messages.reduce((acc, m) => Math.max(acc, m.message_id), 0);
    const stick = isNearBottom();
    const appended: MessageView[] = [];
    for (const message of fresh) {
      if (known.has(message.message_id)) {
        known.set(message.message_id, message);
      } else if (message.message_id > maxId) {
        appended.push(message);
      }
    }
    messages = [
      ...messages.map((m) => known.get(m.message_id) ?? m),
      ...appended,
    ];
    ensurePeers(fresh);
    if (appended.length > 0 && stick) {
      await tick();
      scrollToBottom();
    }
  }

  async function jumpToTarget(messageId: number) {
    if (!messages.some((m) => m.message_id === messageId)) {
      await loadAround(messageId);
    }
    await tick();
    jumpToMessage(messageId);
  }

  $effect(() => {
    const target = ui.jumpTarget;
    if (target === null || target.chatId !== chatId || loading) {
      return;
    }
    ui.clearJump();
    jumpToTarget(target.messageId);
  });

  $effect(() => {
    const unsub = events.subscribe(applyLiveEvent);
    const unsubReconnect = events.onReconnect(resyncTail);
    return () => {
      unsub();
      unsubReconnect();
    };
  });

  $effect(() => {
    if (accounts.selectedId === null) {
      return;
    }
    untrack(() => loadInitial());
  });
</script>

<div class="message-pane">
  <PinnedBar {chatId} onjump={jumpToMessage} />
  <div
    bind:this={container}
    class="message-list custom-scroll"
    onscroll={onScroll}
  >
    {#if loading && messages.length === 0}
      <div class="messages-container">
        {#each skeletonBubbles as width, index (index)}
          <div class="bubble-skeleton skeleton" style:width="{width}%"></div>
        {/each}
      </div>
    {:else if rows.length === 0}
      <TrackChat {chatId} />
    {:else}
      <div class="messages-container">
        {#if loadingOlder}
          <div class="loading-older"><Spinner /></div>
        {/if}
        {#each rows as row (row.message.message_id)}
          {#if row.daySeparator}
            <div
              class="day-separator"
              class:idle={!scrolling && row.dayKey === stuckDay}
              data-day={row.dayKey}
            >
              <span>{row.daySeparator}</span>
            </div>
          {/if}
          {#if row.message.service}
            <ActionMessage message={row.message} onjump={jumpToMessage} />
          {:else}
            <MessageBubble
              message={row.message}
              own={row.own}
              {isGroupChat}
              firstInGroup={row.firstInGroup}
              lastInGroup={row.lastInGroup}
              highlighted={highlightId === row.message.message_id}
              animate={!suppressAppear}
              onjump={jumpToMessage}
              onmedia={(index) => openMedia(row.message, index)}
              onversions={() => openVersions(row.message.message_id)}
            />
          {/if}
        {/each}
        {#if loadingNewer}
          <div class="loading-older"><Spinner /></div>
        {/if}
      </div>
    {/if}
  </div>
  {#if hasNewer}
    <button
      type="button"
      class="to-latest"
      onclick={goToLatest}
      aria-label="К последним сообщениям"
    >
      <Icon name="arrow-down" />
    </button>
  {/if}
</div>

<MediaViewer
  bind:open={mediaOpen}
  bind:index={mediaIndex}
  {chatId}
  items={mediaItems}
/>
<MessageVersions
  bind:open={versionsOpen}
  {chatId}
  messageId={versionsMessageId}
/>

<style lang="scss">
  .message-pane {
    position: relative;
    display: flex;
    flex-direction: column;
    height: 100%;
  }

  .to-latest {
    position: absolute;
    right: 1.25rem;
    bottom: 1.25rem;
    z-index: var(--z-sticky-date);

    display: flex;
    align-items: center;
    justify-content: center;

    width: 3rem;
    height: 3rem;
    border: 0;
    border-radius: 50%;

    color: var(--color-text-secondary);
    cursor: pointer;
    background-color: var(--color-background);
    box-shadow: 0 2px 8px rgb(0 0 0 / 16%);

    &:hover {
      color: var(--color-text);
    }
  }

  .message-list {
    overflow-y: auto;
    flex: 1;
    min-height: 0;
  }

  .messages-container {
    display: flex;
    flex-direction: column;
    justify-content: flex-end;

    min-height: 100%;
    padding: 1rem max(1rem, calc((100% - var(--messages-container-width)) / 2));
  }

  .loading-older {
    display: flex;
    justify-content: center;
    padding: 0.5rem 0;
  }

  .bubble-skeleton {
    align-self: flex-start;

    height: 2.25rem;
    margin-bottom: 0.5rem;
    border-radius: var(--border-radius-messages);

    &:last-child {
      margin-bottom: 0;
    }
  }

  .day-separator {
    pointer-events: none;

    position: sticky;
    top: 0.5625rem;
    z-index: var(--z-sticky-date);

    display: flex;
    justify-content: center;
    margin: 0.75rem 0;

    span {
      padding: 0.25rem 0.75rem;
      border-radius: 1rem;

      font-size: 0.8125rem;
      font-weight: var(--font-weight-medium);
      color: var(--color-white);

      background-color: var(--color-default-shadow);
      backdrop-filter: blur(8px);
      transition: opacity 0.3s ease;
    }

    &.idle span {
      opacity: 0;
    }
  }
</style>
