/* eslint-disable @typescript-eslint/member-ordering */
import { CommonModule } from '@angular/common';
import {
  AfterViewChecked,
  ChangeDetectionStrategy,
  Component,
  ElementRef,
  OnDestroy,
  ViewChild,
  computed,
  effect,
  inject,
  input,
  output,
  signal
} from '@angular/core';
import { Store } from '@ngrx/store';
import { Attachment } from '../../../../../attachment';
import { getMessageTimestamp } from '../../../../domain/rules/message.rules';
import {
  STICKY_BOTTOM_THRESHOLD,
  isStuckToBottom,
  resolveAutoScrollBehavior
} from '../../../../domain/rules/auto-scroll.rules';
import { Message, User } from '../../../../../../shared-kernel';
import {
  ChatMessageDeleteEvent,
  ChatMessageEditEvent,
  ChatMessageEmbedRemoveEvent,
  ChatMessageImageContextMenuEvent,
  ChatMessageImageGalleryEvent,
  ChatMessageImageLightboxEvent,
  ChatMessageReactionEvent,
  ChatMessageReplyEvent
} from '../../models/chat-messages.model';
import { selectAllUsers } from '../../../../../../store/users/users.selectors';
import { APP_TRANSLATE_IMPORTS } from '../../../../../../core/i18n';
import { ThemeNodeDirective } from '../../../../../theme';
import { ChatMessageItemComponent } from '../message-item/chat-message-item.component';

interface PrismGlobal {
  highlightElement(element: Element): void;
}

declare global {
  interface Window {
    Prism?: PrismGlobal;
  }
}

@Component({
  selector: 'app-chat-message-list',
  standalone: true,
  imports: [
    CommonModule,
    ChatMessageItemComponent,
    ThemeNodeDirective,
    ...APP_TRANSLATE_IMPORTS
  ],
  templateUrl: './chat-message-list.component.html',
  changeDetection: ChangeDetectionStrategy.OnPush,
  host: {
    style: 'display: contents;'
  }
})
export class ChatMessageListComponent implements AfterViewChecked, OnDestroy {
  @ViewChild('messagesContainer') messagesContainer?: ElementRef<HTMLDivElement>;
  @ViewChild('messagesContent') messagesContent?: ElementRef<HTMLDivElement>;

  private readonly store = inject(Store);
  private readonly allUsers = this.store.selectSignal(selectAllUsers);
  private readonly dateSeparatorFormatter = new Intl.DateTimeFormat('en-GB', {
    day: 'numeric',
    month: 'long',
    year: 'numeric'
  });

  readonly allMessages = input.required<Message[]>();
  readonly channelMessages = input.required<Message[]>();
  readonly loading = input(false);
  readonly syncing = input(false);
  readonly currentUserId = input<string | null>(null);
  readonly isAdmin = input(false);
  readonly bottomPadding = input(120);
  readonly conversationKey = input.required<string>();
  readonly userLookupOverrides = input<User[]>([]);

  readonly replyRequested = output<ChatMessageReplyEvent>();
  readonly deleteRequested = output<ChatMessageDeleteEvent>();
  readonly editSaved = output<ChatMessageEditEvent>();
  readonly reactionAdded = output<ChatMessageReactionEvent>();
  readonly reactionToggled = output<ChatMessageReactionEvent>();
  readonly downloadRequested = output<Attachment>();
  readonly imageOpened = output<ChatMessageImageLightboxEvent>();
  readonly imageGalleryOpened = output<ChatMessageImageGalleryEvent>();
  readonly imageContextMenuRequested = output<ChatMessageImageContextMenuEvent>();
  readonly embedRemoved = output<ChatMessageEmbedRemoveEvent>();
  /**
   * Emitted when the user scrolls up past the in-store window and the
   * component needs the parent to fetch an older page from the DB.
   */
  readonly loadOlderRequested = output<{ beforeTimestamp: number; limit: number }>();

  /** True while a DB-backed older-page fetch dispatched by the parent is in flight. */
  readonly loadingOlder = input(false);
  /** True once the parent has paginated all the way back to the start of DB history. */
  readonly conversationExhausted = input(false);

  private readonly PAGE_SIZE = 50;

  readonly displayLimit = signal(this.PAGE_SIZE);
  readonly loadingMore = signal(false);
  readonly showNewMessagesBar = signal(false);

  readonly messages = computed(() => {
    const all = this.channelMessages();
    const limit = this.displayLimit();

    if (all.length <= limit)
      return all;

    return all.slice(all.length - limit);
  });

  readonly initialLoading = computed(() => this.loading() && this.messages().length === 0);
  readonly refreshLoading = computed(() => this.loading() && this.messages().length > 0);

  readonly hasMoreMessages = computed(() => this.channelMessages().length > this.displayLimit());

  readonly dateSeparatorLabels = computed(() => {
    const labels = new Map<number, string>();

    let previousDayKey: string | null = null;

    this.messages().forEach((message, index) => {
      const timestamp = this.getMessageDateTimestamp(message);
      const currentDayKey = this.getMessageDayKey(timestamp);

      if (currentDayKey !== previousDayKey) {
        labels.set(index, this.dateSeparatorFormatter.format(new Date(timestamp)));
        previousDayKey = currentDayKey;
      }
    });

    return labels;
  });

  readonly userLookup = computed<ReadonlyMap<string, User>>(() => {
    const lookup = new Map<string, User>();

    for (const user of this.allUsers()) {
      lookup.set(user.id, user);

      if (user.oderId && user.oderId !== user.id) {
        lookup.set(user.oderId, user);
      }
    }

    for (const user of this.userLookupOverrides()) {
      lookup.set(user.id, user);

      if (user.oderId && user.oderId !== user.id) {
        lookup.set(user.oderId, user);
      }
    }

    return lookup;
  });

  /**
   * O(1) index of messages by id, built once per `allMessages()` change.
   * Used by `findRepliedMessage` so each rendered row doing a reply lookup
   * costs a Map.get instead of an Array.find over the full message list.
   */
  private readonly messagesById = computed<ReadonlyMap<string, Message>>(() => {
    const index = new Map<string, Message>();

    for (const message of this.allMessages()) {
      index.set(message.id, message);
    }

    return index;
  });

  private contentResizeObserver: ResizeObserver | null = null;
  private observedContent: HTMLElement | null = null;
  private localSendScrollPending = false;
  private localSendScrollTimer: ReturnType<typeof setTimeout> | null = null;
  private isAutoScrolling = false;
  private lastMessageCount = 0;
  private initialScrollPending = true;
  private prismHighlightScheduled = false;
  /**
   * True while the list should keep auto-pinning to the newest message. Set
   * when the conversation opens and whenever the user is scrolled near the
   * bottom; cleared once the user scrolls up to read history. The
   * `ResizeObserver` only re-pins while this is true, so late-loading images,
   * embeds, and plugin content can never knock an at-bottom view off the
   * latest message.
   */
  private stickToBottom = true;
  /**
   * Timestamp (ms) until which a freshly opened conversation is still
   * settling. Inside this window new messages jump instantly instead of
   * animating, so a channel switch always lands at the bottom.
   */
  private settleUntil = 0;
  private static readonly INITIAL_SETTLE_MS = 1500;
  /**
   * Set when an older-page DB fetch is in flight. While true, the
   * `onMessagesChanged` effect treats incoming message-count growth as a
   * prepend (older history arriving) and preserves the user's scroll
   * position instead of running sticky-bottom / new-messages-indicator
   * logic.
   */
  private pendingOlderFetchScrollHeight: number | null = null;

  private readonly onConversationChanged = effect(() => {
    void this.conversationKey();
    this.resetScrollingState();
  });

  /**
   * Clears the in-flight older-fetch flag when the parent reports the
   * load has finished (regardless of how many rows were returned, even
   * zero). Without this, `loadingMore` would stick on if the DB had no
   * rows older than the cursor.
   */
  private readonly onLoadingOlderChanged = effect(() => {
    const inFlight = this.loadingOlder();

    if (!inFlight && this.pendingOlderFetchScrollHeight !== null) {
      // If onMessagesChanged already consumed the pending state because
      // rows arrived, this is a no-op; otherwise we clear it now.
      queueMicrotask(() => {
        if (this.pendingOlderFetchScrollHeight !== null) {
          this.pendingOlderFetchScrollHeight = null;
          this.loadingMore.set(false);
        }
      });
    }
  });

  private readonly onMessagesChanged = effect(() => {
    const currentCount = this.channelMessages().length;
    const element = this.messagesContainer?.nativeElement;

    if (!element) {
      this.lastMessageCount = currentCount;
      return;
    }

    if (this.initialScrollPending) {
      this.lastMessageCount = currentCount;
      return;
    }

    // Handle older-history backfill: messages were prepended, not appended.
    // Reveal the new rows by widening the display window, and preserve the
    // user's visual scroll position across the height change. We skip the
    // sticky-bottom / new-messages-indicator logic entirely for this path.
    if (this.pendingOlderFetchScrollHeight !== null && currentCount > this.lastMessageCount) {
      const previousScrollHeight = this.pendingOlderFetchScrollHeight;
      const previousScrollTop = element.scrollTop;
      const newlyLoaded = currentCount - this.lastMessageCount;

      this.pendingOlderFetchScrollHeight = null;
      this.displayLimit.update((limit) => limit + newlyLoaded);

      requestAnimationFrame(() => {
        requestAnimationFrame(() => {
          const container = this.messagesContainer?.nativeElement;

          if (container) {
            const newScrollHeight = container.scrollHeight;

            container.scrollTop = previousScrollTop + (newScrollHeight - previousScrollHeight);
          }

          this.loadingMore.set(false);
        });
      });

      this.lastMessageCount = currentCount;
      return;
    }

    const distanceFromBottom = element.scrollHeight - element.scrollTop - element.clientHeight;
    const newMessages = currentCount > this.lastMessageCount;
    const forceLocalSendScroll = this.shouldForceLocalSendScroll();
    const behavior = resolveAutoScrollBehavior({
      newMessages,
      forceLocalSend: forceLocalSendScroll,
      distanceFromBottom,
      withinInitialGrace: this.withinInitialGrace(),
      stickyThreshold: STICKY_BOTTOM_THRESHOLD
    });

    if (behavior === 'none') {
      if (newMessages) {
        queueMicrotask(() => this.showNewMessagesBar.set(true));
      }
    } else {
      this.stickToBottom = true;
      this.showNewMessagesBar.set(false);

      if (behavior === 'instant') {
        if (forceLocalSendScroll) {
          this.clearLocalSendScrollPending();
        }

        this.scheduleScrollToBottomAfterRender(true);
      } else {
        this.scheduleScrollToBottomSmooth();
      }
    }

    this.lastMessageCount = currentCount;
  });

  ngAfterViewChecked(): void {
    const element = this.messagesContainer?.nativeElement;

    if (!element)
      return;

    if (this.initialScrollPending) {
      if (this.messages().length > 0) {
        this.initialScrollPending = false;
        this.stickToBottom = true;
        this.settleUntil = Date.now() + ChatMessageListComponent.INITIAL_SETTLE_MS;
        this.isAutoScrolling = true;
        element.scrollTop = element.scrollHeight;
        requestAnimationFrame(() => {
          this.isAutoScrolling = false;
        });

        this.clearLocalSendScrollPending();
        this.startContentResizeWatch();
        this.showNewMessagesBar.set(false);
        this.lastMessageCount = this.messages().length;
        this.scheduleCodeHighlight();
      } else if (!this.loading()) {
        this.initialScrollPending = false;
        this.lastMessageCount = 0;
      }

      return;
    }

    this.startContentResizeWatch();
    this.scheduleCodeHighlight();
  }

  ngOnDestroy(): void {
    this.stopContentResizeWatch();
    this.clearLocalSendScrollPending();
  }

  findRepliedMessage(messageId?: string | null): Message | undefined {
    if (!messageId)
      return undefined;

    return this.messagesById().get(messageId);
  }

  onScroll(): void {
    const element = this.messagesContainer?.nativeElement;

    if (!element || this.isAutoScrolling)
      return;

    const distanceFromBottom = element.scrollHeight - element.scrollTop - element.clientHeight;

    this.stickToBottom = isStuckToBottom(distanceFromBottom);

    if (this.stickToBottom) {
      this.showNewMessagesBar.set(false);
    }

    if (element.scrollTop < 150 && !this.loadingMore()) {
      const canFetchOlderFromDb =
        !this.hasMoreMessages() && !this.conversationExhausted() && !this.loadingOlder() && this.channelMessages().length > 0;

      if (this.hasMoreMessages() || canFetchOlderFromDb) {
        this.loadMore();
      }
    }
  }

  loadMore(): void {
    if (this.loadingMore())
      return;

    // Case 1: there are still in-store messages above the rendered window.
    // Just widen the display window and preserve scroll position.
    if (this.hasMoreMessages()) {
      this.loadingMore.set(true);

      const element = this.messagesContainer?.nativeElement;
      const previousScrollHeight = element?.scrollHeight ?? 0;

      this.displayLimit.update((limit) => limit + this.PAGE_SIZE);

      requestAnimationFrame(() => {
        requestAnimationFrame(() => {
          if (element) {
            const newScrollHeight = element.scrollHeight;

            element.scrollTop += newScrollHeight - previousScrollHeight;
          }

          this.loadingMore.set(false);
        });
      });

      return;
    }

    // Case 2: in-store window is exhausted. Ask the parent to fetch the
    // next older page from the DB. The parent dispatches loadOlderMessages
    // and the resulting store update is handled by onMessagesChanged via
    // pendingOlderFetchScrollHeight (prepend-aware scroll preservation).
    if (this.loadingOlder() || this.conversationExhausted())
      return;

    const all = this.channelMessages();

    if (all.length === 0)
      return;

    const oldest = all[0];
    const element = this.messagesContainer?.nativeElement;

    this.loadingMore.set(true);
    this.pendingOlderFetchScrollHeight = element?.scrollHeight ?? 0;
    this.loadOlderRequested.emit({
      beforeTimestamp: oldest.timestamp,
      limit: this.PAGE_SIZE
    });
  }

  readLatest(): void {
    this.stickToBottom = true;
    this.scrollToBottomSmooth();
    this.showNewMessagesBar.set(false);
  }

  scrollToBottomAfterLocalSend(): void {
    this.stickToBottom = true;
    this.localSendScrollPending = true;
    this.showNewMessagesBar.set(false);
    this.scheduleScrollToBottomAfterRender(true);
    this.armLocalSendScrollTimeout();
  }

  scrollToMessage(messageId: string): void {
    const container = this.messagesContainer?.nativeElement;

    if (!container)
      return;

    const element = container.querySelector(`[data-message-id="${messageId}"]`) as HTMLElement | null;

    if (element) {
      element.scrollIntoView({
        behavior: 'smooth',
        block: 'center'
      });

      element.classList.add('bg-primary/10');
      setTimeout(() => element.classList.remove('bg-primary/10'), 2000);
    }
  }

  handleReplyRequested(message: ChatMessageReplyEvent): void {
    this.replyRequested.emit(message);
  }

  handleDeleteRequested(message: ChatMessageDeleteEvent): void {
    this.deleteRequested.emit(message);
  }

  handleEditSaved(event: ChatMessageEditEvent): void {
    this.editSaved.emit(event);
  }

  handleReactionAdded(event: ChatMessageReactionEvent): void {
    this.reactionAdded.emit(event);
  }

  handleReactionToggled(event: ChatMessageReactionEvent): void {
    this.reactionToggled.emit(event);
  }

  handleReferenceRequested(messageId: string): void {
    this.scrollToMessage(messageId);
  }

  handleDownloadRequested(attachment: Attachment): void {
    this.downloadRequested.emit(attachment);
  }

  handleImageOpened(event: ChatMessageImageLightboxEvent): void {
    this.imageOpened.emit(event);
  }

  handleImageGalleryOpened(attachments: ChatMessageImageGalleryEvent): void {
    this.imageGalleryOpened.emit(attachments);
  }

  handleImageContextMenuRequested(event: ChatMessageImageContextMenuEvent): void {
    this.imageContextMenuRequested.emit(event);
  }

  handleEmbedRemoved(event: ChatMessageEmbedRemoveEvent): void {
    this.embedRemoved.emit(event);
  }

  private resetScrollingState(): void {
    this.initialScrollPending = true;
    this.stickToBottom = true;
    this.settleUntil = Date.now() + ChatMessageListComponent.INITIAL_SETTLE_MS;
    this.stopContentResizeWatch();
    this.clearLocalSendScrollPending();
    this.showNewMessagesBar.set(false);
    this.lastMessageCount = 0;
    this.displayLimit.set(this.PAGE_SIZE);
    this.pendingOlderFetchScrollHeight = null;
    this.loadingMore.set(false);
  }

  private withinInitialGrace(): boolean {
    return Date.now() < this.settleUntil;
  }

  /**
   * Keeps the list pinned to the latest message while the user is at the
   * bottom, re-pinning whenever the rendered content changes height. A
   * `ResizeObserver` on the message content fires for any cause of growth -
   * images decoding, link/media embeds, plugin-rendered content, font swaps,
   * reflow - with no arbitrary time limit, so opening a conversation reliably
   * lands on the newest message even when content loads asynchronously.
   */
  private startContentResizeWatch(): void {
    if (typeof ResizeObserver === 'undefined')
      return;

    const content = this.messagesContent?.nativeElement;

    if (!content)
      return;

    if (this.contentResizeObserver && this.observedContent === content)
      return;

    this.stopContentResizeWatch();
    this.observedContent = content;

    this.contentResizeObserver = new ResizeObserver(() => {
      if (!this.stickToBottom)
        return;

      if (this.initialScrollPending || this.pendingOlderFetchScrollHeight !== null)
        return;

      requestAnimationFrame(() => this.scrollToBottomInstant());
    });

    this.contentResizeObserver.observe(content);
  }

  private stopContentResizeWatch(): void {
    if (this.contentResizeObserver) {
      this.contentResizeObserver.disconnect();
      this.contentResizeObserver = null;
    }

    this.observedContent = null;
  }

  private armLocalSendScrollTimeout(): void {
    if (this.localSendScrollTimer) {
      clearTimeout(this.localSendScrollTimer);
    }

    this.localSendScrollTimer = setTimeout(() => {
      this.localSendScrollPending = false;
      this.localSendScrollTimer = null;
    }, 1000);
  }

  private clearLocalSendScrollPending(): void {
    this.localSendScrollPending = false;

    if (this.localSendScrollTimer) {
      clearTimeout(this.localSendScrollTimer);
      this.localSendScrollTimer = null;
    }
  }

  private shouldForceLocalSendScroll(): boolean {
    if (!this.localSendScrollPending)
      return false;

    const latestMessage = this.channelMessages().at(-1);

    return !!latestMessage && latestMessage.senderId === this.currentUserId();
  }

  private getMessageDateTimestamp(message: Message): number {
    return message.timestamp || getMessageTimestamp(message);
  }

  private getMessageDayKey(timestamp: number): string {
    const date = new Date(timestamp);

    return `${date.getFullYear()}-${date.getMonth()}-${date.getDate()}`;
  }

  private scrollToBottomSmooth(): void {
    const element = this.messagesContainer?.nativeElement;

    if (!element)
      return;

    try {
      element.scrollTo({
        top: element.scrollHeight,
        behavior: 'smooth'
      });
    } catch {
      element.scrollTop = element.scrollHeight;
    }
  }

  private scrollToBottomInstant(): void {
    const element = this.messagesContainer?.nativeElement;

    if (!element)
      return;

    this.isAutoScrolling = true;
    element.scrollTop = element.scrollHeight;
    requestAnimationFrame(() => {
      this.isAutoScrolling = false;
    });
  }

  private scheduleScrollToBottomAfterRender(watchForLayoutChanges = false): void {
    requestAnimationFrame(() => {
      requestAnimationFrame(() => {
        this.scrollToBottomInstant();

        if (watchForLayoutChanges) {
          this.startContentResizeWatch();
        }
      });
    });
  }

  private scheduleScrollToBottomSmooth(): void {
    requestAnimationFrame(() => {
      requestAnimationFrame(() => this.scrollToBottomSmooth());
    });
  }

  private scheduleCodeHighlight(): void {
    if (this.prismHighlightScheduled)
      return;

    this.prismHighlightScheduled = true;
    requestAnimationFrame(() => {
      this.prismHighlightScheduled = false;
      this.highlightRenderedCodeBlocks();
    });
  }

  private highlightRenderedCodeBlocks(): void {
    const container = this.messagesContainer?.nativeElement;
    const prism = window.Prism;

    if (!container || !prism?.highlightElement)
      return;

    const blocks = container.querySelectorAll<HTMLElement>('pre > code[class*="language-"]');

    for (const block of blocks) {
      const signature = this.getCodeBlockSignature(block);

      if (block.dataset['prismSignature'] === signature)
        continue;

      try {
        prism.highlightElement(block);
      } finally {
        block.dataset['prismSignature'] = signature;
      }
    }
  }

  private getCodeBlockSignature(block: HTMLElement): string {
    const value = `${block.className}:${block.textContent ?? ''}`;

    let hash = 0;

    for (let index = 0; index < value.length; index++) {
      hash = ((hash << 5) - hash + value.charCodeAt(index)) | 0;
    }

    return String(hash);
  }
}
