<script setup lang="ts">
import type {
  ChatArtifactLoader,
  ChatDocumentLoader,
  ChatImageLoader,
  ChatTimelineItem as ChatTimelineItemType,
  ChatTimelineMessageItem,
  ChatTimelineToolApprovalPayload,
} from './types';

import { computed, nextTick, onBeforeUnmount, ref, watch } from 'vue';

import ChatAssistantAvatar from './ChatAssistantAvatar.vue';
import ChatTimelineItem from './ChatTimelineItem.vue';
import ChatTimelineTurn from './ChatTimelineTurn.vue';

const props = defineProps<{
  approvalLoading?: boolean;
  artifactLoader?: ChatArtifactLoader;
  assistantAvatar?: string;
  copyable?: (item: ChatTimelineMessageItem) => boolean;
  copyAction?: (item: ChatTimelineMessageItem) => boolean | Promise<boolean>;
  documentLoader?: ChatDocumentLoader;
  emptyText?: string;
  emptyTitle?: string;
  imageLoader?: ChatImageLoader;
  items: ChatTimelineItemType[];
  regenerable?: (item: ChatTimelineMessageItem) => boolean;
  regenerateDisabled?: boolean;
  variantLoading?: (item: ChatTimelineMessageItem) => boolean;
}>();

const emit = defineEmits<{
  approve: [payload: ChatTimelineToolApprovalPayload];
  copyMessage: [item: ChatTimelineMessageItem];
  regenerateMessage: [item: ChatTimelineMessageItem];
  reject: [payload: ChatTimelineToolApprovalPayload];
  selectNextVariant: [item: ChatTimelineMessageItem];
  selectPreviousVariant: [item: ChatTimelineMessageItem];
}>();

const containerRef = ref<HTMLElement>();
const isPinnedToBottom = ref(true);
const suppressNextAutoScroll = ref(false);
let preservedAnchor: undefined | { element: HTMLElement; relativeTop: number };

const bottomThreshold = 24;
let scrollFrame = 0;
const assistantActionAnchorByRound = computed(() => {
  const latestAssistantByRound = new Map<string, string>();
  for (const item of props.items) {
    if (item.type === 'message' && item.role === 'assistant' && item.roundId) {
      latestAssistantByRound.set(item.roundId, item.id);
    }
  }
  return latestAssistantByRound;
});

const assistantActionAnchorIds = computed(
  () => new Set(assistantActionAnchorByRound.value.values()),
);

type TimelineDisplayEntry =
  | { id: string; item: ChatTimelineItemType; type: 'item' }
  | {
      id: string;
      items: ChatTimelineItemType[];
      roundId: string;
      type: 'turn';
    };

const displayEntries = computed<TimelineDisplayEntry[]>(() => {
  const entries: TimelineDisplayEntry[] = [];
  const turns = new Map<
    string,
    Extract<TimelineDisplayEntry, { type: 'turn' }>
  >();
  for (const item of props.items) {
    if (!item.roundId || (item.type === 'message' && item.role === 'user')) {
      entries.push({ id: item.id, item, type: 'item' });
      continue;
    }
    const existing = turns.get(item.roundId);
    if (existing) {
      existing.items.push(item);
      continue;
    }
    const turn: Extract<TimelineDisplayEntry, { type: 'turn' }> = {
      id: `turn-${item.roundId}`,
      items: [item],
      roundId: item.roundId,
      type: 'turn',
    };
    turns.set(item.roundId, turn);
    entries.push(turn);
  }
  return entries;
});

function isNearBottom(container: HTMLElement) {
  return (
    container.scrollHeight - container.scrollTop - container.clientHeight <=
    bottomThreshold
  );
}

function updatePinnedState() {
  const container = containerRef.value;
  if (!container) {
    return;
  }
  if (suppressNextAutoScroll.value && preservedAnchor) {
    return;
  }
  isPinnedToBottom.value = isNearBottom(container);
}

function scrollToBottom() {
  if (scrollFrame) {
    cancelAnimationFrame(scrollFrame);
  }
  scrollFrame = requestAnimationFrame(() => {
    const container = containerRef.value;
    if (!container) {
      return;
    }
    container.scrollTop = container.scrollHeight;
    updatePinnedState();
  });
}

function handleTimelineScroll() {
  updatePinnedState();
}

function handleLayoutToggle(roundId: string) {
  const container = containerRef.value;
  if (!container) {
    return;
  }
  if (suppressNextAutoScroll.value && preservedAnchor) {
    return;
  }
  if (scrollFrame) {
    cancelAnimationFrame(scrollFrame);
    scrollFrame = 0;
  }
  const turn = [
    ...container.querySelectorAll<HTMLElement>('[data-round-id]'),
  ].find((element) => element.dataset.roundId === roundId);
  const header = turn?.querySelector<HTMLElement>(
    '.chat-timeline-turn__header',
  );
  const contentAnchor = turn?.querySelector<HTMLElement>(
    '[data-chat-turn-final], [data-chat-turn-live-anchor]',
  );
  const containerRect = container.getBoundingClientRect();
  const headerRect = header?.getBoundingClientRect();
  const anchor =
    headerRect &&
    headerRect.bottom >= containerRect.top &&
    headerRect.top <= containerRect.bottom
      ? header
      : (contentAnchor ?? header ?? turn);
  if (anchor) {
    preservedAnchor = {
      element: anchor,
      relativeTop: anchor.getBoundingClientRect().top - containerRect.top,
    };
  }
  suppressNextAutoScroll.value = true;
}

async function handleLayoutChanged() {
  await nextTick();
  const container = containerRef.value;
  const anchor = preservedAnchor;
  if (container && anchor?.element.isConnected) {
    const relativeTop =
      anchor.element.getBoundingClientRect().top -
      container.getBoundingClientRect().top;
    container.scrollTop += relativeTop - anchor.relativeTop;
  }
  preservedAnchor = undefined;
  suppressNextAutoScroll.value = false;
  updatePinnedState();
}

function handleLegacyLayoutToggle() {
  const container = containerRef.value;
  if (!container) {
    return;
  }
  const scrollTop = container.scrollTop;
  suppressNextAutoScroll.value = true;
  void nextTick(() => {
    if (containerRef.value) {
      containerRef.value.scrollTop = scrollTop;
    }
    suppressNextAutoScroll.value = false;
    updatePinnedState();
  });
}

function canCopyMessage(item: ChatTimelineItemType) {
  return item.type === 'message' && (props.copyable?.(item) ?? false);
}

function canRegenerateMessage(item: ChatTimelineItemType) {
  return item.type === 'message' && (props.regenerable?.(item) ?? false);
}

function isAssistantActionAnchor(item: ChatTimelineItemType) {
  return (
    item.type === 'message' &&
    item.role === 'assistant' &&
    Boolean(item.roundId) &&
    assistantActionAnchorIds.value.has(item.id)
  );
}

function isVariantLoading(item: ChatTimelineItemType) {
  return item.type === 'message' && (props.variantLoading?.(item) ?? false);
}

onBeforeUnmount(() => {
  if (scrollFrame) {
    cancelAnimationFrame(scrollFrame);
  }
});

watch(
  () => props.items,
  async () => {
    if (suppressNextAutoScroll.value) {
      return;
    }
    if (isPinnedToBottom.value) {
      scrollToBottom();
    }
  },
  { deep: true, immediate: true },
);
</script>

<template>
  <div
    ref="containerRef"
    class="chat-timeline"
    @scroll.passive="handleTimelineScroll"
  >
    <div v-if="items.length === 0" class="chat-timeline__empty">
      <div
        class="chat-timeline__empty-icon"
        :class="{ 'has-avatar': assistantAvatar }"
        aria-hidden="true"
      >
        <ChatAssistantAvatar v-if="assistantAvatar" :src="assistantAvatar" />
      </div>
      <div v-if="emptyTitle" class="chat-timeline__empty-title">
        {{ emptyTitle }}
      </div>
      <div class="chat-timeline__empty-text">
        {{ emptyText || '开始对话' }}
      </div>
    </div>
    <template v-else>
      <template v-for="entry in displayEntries" :key="entry.id">
        <ChatTimelineTurn
          v-if="entry.type === 'turn'"
          :action-anchor-id="assistantActionAnchorByRound.get(entry.roundId)"
          :artifact-loader="artifactLoader"
          :approval-loading="approvalLoading"
          :assistant-avatar="assistantAvatar"
          :copy-action="copyAction"
          :copyable="copyable"
          :document-loader="documentLoader"
          :image-loader="imageLoader"
          :items="entry.items"
          :regenerable="regenerable"
          :regenerate-disabled="regenerateDisabled"
          :round-id="entry.roundId"
          :variant-loading="variantLoading"
          @approve="emit('approve', $event)"
          @copy-message="emit('copyMessage', $event)"
          @layout-changed="handleLayoutChanged"
          @layout-toggle="handleLayoutToggle"
          @regenerate-message="emit('regenerateMessage', $event)"
          @reject="emit('reject', $event)"
          @select-next-variant="emit('selectNextVariant', $event)"
          @select-previous-variant="emit('selectPreviousVariant', $event)"
        >
          <template #custom-item="{ item }">
            <slot name="custom-item" :item="item"></slot>
          </template>
        </ChatTimelineTurn>
        <slot
          v-else-if="entry.item.type === 'custom'"
          name="custom-item"
          :item="entry.item"
        ></slot>
        <ChatTimelineItem
          v-else
          :assistant-actions-visible="isAssistantActionAnchor(entry.item)"
          :artifact-loader="artifactLoader"
          :assistant-avatar="assistantAvatar"
          :item="entry.item"
          :document-loader="documentLoader"
          :image-loader="imageLoader"
          :approval-loading="approvalLoading"
          :copy-action="copyAction"
          :copyable="canCopyMessage(entry.item)"
          :regenerable="canRegenerateMessage(entry.item)"
          :regenerate-disabled="regenerateDisabled"
          :variant-loading="isVariantLoading(entry.item)"
          @approve="emit('approve', $event)"
          @copy-message="emit('copyMessage', $event)"
          @regenerate-message="emit('regenerateMessage', $event)"
          @reject="emit('reject', $event)"
          @select-next-variant="emit('selectNextVariant', $event)"
          @select-previous-variant="emit('selectPreviousVariant', $event)"
          @thinking-toggle="handleLegacyLayoutToggle"
        />
      </template>
    </template>
  </div>
</template>

<style scoped>
.chat-timeline {
  display: flex;
  flex: 1;
  flex-direction: column;
  gap: var(--space-3);
  min-height: 0;
  padding: 16px;
  overflow: auto;
}

.chat-timeline__empty-icon {
  width: 72px;
  height: 72px;
  margin: 0 auto;
  background: var(--el-color-primary-light-9);
  border: 1px solid var(--el-color-primary-light-7);
  border-radius: 8px;
}

.chat-timeline__empty-icon.has-avatar {
  background: transparent;
  border: 0;
  border-radius: var(--radius-panel);
}

.chat-timeline__empty {
  display: flex;
  flex: 1;
  flex-direction: column;
  gap: var(--space-2);
  align-items: center;
  justify-content: center;
  min-height: 180px;
  color: var(--el-text-color-secondary);
}

.chat-timeline__empty-text {
  font-size: 13px;
  line-height: 20px;
}

.chat-timeline__empty-title {
  font-size: 16px;
  font-weight: 600;
  line-height: 24px;
  color: hsl(var(--text-strong));
}
</style>
