import app from 'flarum/forum/app';
import Page, { type IPageAttrs } from 'flarum/common/components/Page';
import DeckPageStructure from './DeckPageStructure';
import DeckSidebar from './DeckSidebar';
import DeckPageHero from './DeckPageHero';
import Button from 'flarum/common/components/Button';
import Dropdown from 'flarum/common/components/Dropdown';
import Icon from 'flarum/common/components/Icon';
import classList from 'flarum/common/utils/classList';
import extractText from 'flarum/common/utils/extractText';
import ItemList from 'flarum/common/utils/ItemList';
import type Mithril from 'mithril';
import type Sortable from 'sortablejs';
import DeckRow from './DeckRow';
import DeckRowSplit from './DeckRowSplit';
import DeckState from '../states/DeckState';
import { canUseDeck } from '../utils/deckLayout';
import type DeckColumnState from '../states/DeckColumnState';

export interface IDeckPageAttrs extends IPageAttrs {}

/** Below this height two rows of columns are too cramped, so they share one strip. */
const SHORT_SCREEN = '(max-height: 640px)';

/** Remembered per browser, like the hero: it's about this screen, not the member. */
const FULLSCREEN_KEY = 'flarum-deck.fullscreen';
/** On the app element, where the forum's header and nav are hidden from. */
const FULLSCREEN_CLASS = 'App--deckFullscreen';

/** Kept between visits so the deck is exactly as the member left it. */
let deck: { userId: string; state: DeckState; scrollLeft: Record<string, number> } | null = null;

export default class DeckPage<CustomAttrs extends IDeckPageAttrs = IDeckPageAttrs> extends Page<CustomAttrs> {
  protected deck!: DeckState;
  protected observer: IntersectionObserver | null = null;
  protected sortable: typeof Sortable | null = null;
  protected chipSortable: Sortable | null = null;
  protected media: MediaQueryList[] = [];
  /** Rows, by key, whose columns don't fit across the screen. */
  /** The column filling the screen in the phone layout. */
  protected activeIndex = 0;
  /** Once the hero is gone the toolbar names the page instead. */
  protected heroDismissed = DeckPageHero.isHidden();
  /** Just the deck: the forum's header, nav and the hero out of the way. */
  protected fullscreen = DeckPage.storedFullscreen();

  static storedFullscreen(): boolean {
    try {
      return localStorage.getItem(FULLSCREEN_KEY) === 'true';
    } catch {
      return false;
    }
  }

  isFullscreen(): boolean {
    return this.fullscreen;
  }

  setFullscreen(on: boolean): void {
    this.fullscreen = on;
    document.getElementById('app')?.classList.toggle(FULLSCREEN_CLASS, on);

    try {
      if (on) localStorage.setItem(FULLSCREEN_KEY, 'true');
      else localStorage.removeItem(FULLSCREEN_KEY);
    } catch {
      // Private browsing and the like: it lasts until the page reloads.
    }

    m.redraw();
  }

  /** Escape leaves full screen, once nothing above the page (a modal) wants it. */
  onKeyDown = (e: KeyboardEvent): void => {
    if (e.key === 'Escape' && this.fullscreen && !app.modal.isModalOpen()) this.setFullscreen(false);
  };

  oninit(vnode: Mithril.Vnode<CustomAttrs, this>) {
    super.oninit(vnode);

    if (!canUseDeck()) {
      m.route.set(app.route('index'));
      return;
    }

    this.bodyClass = 'App--deck';

    // The tag list would turn the horizontal nav into a very long strip.
    app.current.set('noTagsList', true);

    const userId = app.session.user!.id()!;

    if (deck?.userId !== userId) {
      deck = { userId, state: new DeckState(), scrollLeft: {} };
    } else {
      deck.state.sync();
    }

    this.deck = deck.state;

    const title = extractText(app.translator.trans('flarum-deck.forum.page.title'));
    app.setTitle(title);
    app.setTitleCount(0);
    app.history.push('deck', title);
  }

  oncreate(vnode: Mithril.VnodeDOM<CustomAttrs, this>) {
    super.oncreate(vnode);

    if (!this.deck) return;

    // Columns just off screen count as visible, so swiping or scrolling to a
    // neighbour shows it already up to date.
    this.observer = new IntersectionObserver(
      (entries) => {
        entries.forEach((entry) => {
          const column = this.columnFor(entry.target as HTMLElement);

          if (!column) return;

          column.visible = entry.isIntersecting;

          if (column.visible && column.stale) column.check(this.deck.isLive());
        });
      },
      { rootMargin: '0px 100% 0px 100%' }
    );

    this.media = [window.matchMedia(this.phoneQuery()), window.matchMedia(SHORT_SCREEN)];
    this.media.forEach((query) => query.addEventListener('change', this.onLayoutChange));

    document.getElementById('app')?.classList.toggle(FULLSCREEN_CLASS, this.fullscreen);
    window.addEventListener('keydown', this.onKeyDown);

    import('../utils/loadSortable').then(({ default: Sortable }) => {
      this.sortable = Sortable;
      m.redraw();
    });

    this.observeColumns();
    this.deck.start();
  }

  onupdate(vnode: Mithril.VnodeDOM<CustomAttrs, this>) {
    super.onupdate(vnode);

    this.observeColumns();
    this.setUpChipSortable();
  }

  onremove(vnode: Mithril.VnodeDOM<CustomAttrs, this>) {
    super.onremove(vnode);

    if (!this.deck) return;

    this.observer?.disconnect();
    this.chipSortable?.destroy();
    this.media.forEach((query) => query.removeEventListener('change', this.onLayoutChange));
    window.removeEventListener('keydown', this.onKeyDown);
    // Leaving the page brings the forum's header and nav back; the setting stays.
    document.getElementById('app')?.classList.remove(FULLSCREEN_CLASS);
    this.deck.stop();
  }

  view() {
    if (!this.deck) return <div />;

    const columns = this.deck.columns();
    const phone = this.isPhone();
    const flat = phone || this.isShort();
    // The way between columns on phones; side by side, rows scroll themselves.
    const showTabs = columns.length > 1 && phone;

    return (
      // Page--vertical puts core's side nav above the content, as on the Tags
      // and Messages pages, so the forum's navigation stays to hand without
      // costing the deck any width.
      <DeckPageStructure
        className={classList('DeckPage Page--vertical', { 'DeckPage--flat': flat, 'DeckPage--phone': phone })}
        hero={() => <DeckPageHero ondismiss={() => (this.heroDismissed = true) && m.redraw()} />}
        sidebar={() => <DeckSidebar />}
        deck={() => [
          <div className="DeckPage-toolbar">
            {this.heroDismissed ? (
              <h1 className="DeckPage-title">
                <Icon name="fas fa-table-columns" />
                {app.translator.trans('flarum-deck.forum.page.title')}
              </h1>
            ) : (
              <span />
            )}
            <div className="DeckPage-actions">{this.actionItems().toArray()}</div>
          </div>,

          // Always rendered, so a change to its text is announced.
          <div className="visually-hidden" role="status" aria-live="polite">
            {this.deck.announcement}
          </div>,

          showTabs && (
            <nav className="DeckPage-tabs" aria-label={extractText(app.translator.trans('flarum-deck.forum.page.navigator_label'))}>
              {this.tabs(columns, phone)}
            </nav>
          ),

          columns.length ? (
            <div className="Deck">{flat ? this.row('flat', null, columns, phone) : this.rows()}</div>
          ) : (
            <div className="DeckPage-empty">
              <p>{app.translator.trans('flarum-deck.forum.page.empty_text')}</p>
              {this.addButton()}
            </div>
          ),
        ]}
      />
    );
  }

  /** Both rows, with a divider to share the height once each has a column. */
  protected rows(): Mithril.Children[] {
    const [top, bottom] = this.deck.rows();
    const split = this.deck.rowSplit();

    if (!top.length || !bottom.length) {
      return [this.row('row0', 0, top, false), this.row('row1', 1, bottom, false)];
    }

    return [this.row('row0', 0, top, false, split), <DeckRowSplit key="split" deck={this.deck} />, this.row('row1', 1, bottom, false, 1 - split)];
  }

  protected row(key: string, row: number | null, columns: DeckColumnState[], phone: boolean, grow?: number): Mithril.Children {
    return (
      <DeckRow
        key={key}
        deck={this.deck}
        grow={grow}
        row={row}
        columns={columns}
        // On phones columns fill the screen and swipe; they're reordered with the chips instead.
        sortable={phone ? null : this.sortable}
        scrollLeft={deck!.scrollLeft[key] ?? 0}
        onscrolled={(left: number) => (deck!.scrollLeft[key] = left)}
        ondrag={(dragging: boolean) => this.element.querySelector('.Deck')?.classList.toggle('Deck--dragging', dragging)}
        onactive={
          phone
            ? (index: number) => {
                this.activeIndex = index;
                m.redraw();
              }
            : undefined
        }
      />
    );
  }

  actionItems(): ItemList<Mithril.Children> {
    const items = new ItemList<Mithril.Children>();

    // The side nav is out of sight in full screen, and starting a discussion
    // is the one thing on it the deck can't do without.
    if (this.fullscreen) items.add('newDiscussion', this.newDiscussionButton(), 110);

    items.add('addColumn', this.addButton(), 100);

    items.add(
      'fullscreen',
      <Button
        className="Button Button--icon DeckPage-fullscreen"
        icon={this.fullscreen ? 'fas fa-compress' : 'fas fa-expand'}
        aria-pressed={this.fullscreen ? 'true' : 'false'}
        aria-label={extractText(app.translator.trans(`flarum-deck.forum.page.${this.fullscreen ? 'exit_fullscreen_button' : 'fullscreen_button'}`))}
        title={extractText(app.translator.trans(`flarum-deck.forum.page.${this.fullscreen ? 'exit_fullscreen_button' : 'fullscreen_button'}`))}
        onclick={() => this.setFullscreen(!this.fullscreen)}
      />,
      95
    );

    // Rarely wanted and can't be undone, so kept a step away rather than as a
    // button that could be tapped by mistake.
    items.add(
      'options',
      <Dropdown
        className="DeckPage-options"
        buttonClassName="Button Button--icon"
        menuClassName="Dropdown-menu--right"
        icon="fas fa-ellipsis"
        accessibleToggleLabel={extractText(app.translator.trans('flarum-deck.forum.page.options_label'))}
      >
        {this.optionItems().toArray()}
      </Dropdown>,
      90
    );

    return items;
  }

  /** The deck-wide actions in the toolbar's menu. */
  optionItems(): ItemList<Mithril.Children> {
    const items = new ItemList<Mithril.Children>();

    items.add(
      'reset',
      <Button
        icon="fas fa-rotate-left"
        disabled={!this.deck.isCustomised()}
        onclick={() => {
          if (confirm(extractText(app.translator.trans('flarum-deck.forum.page.reset_confirmation')))) {
            this.deck.reset();
          }
        }}
      >
        {app.translator.trans('flarum-deck.forum.page.reset_button')}
      </Button>,
      100
    );

    return items;
  }

  /** As the side nav's, which the deck keeps for the same reason. See DeckSidebar. */
  protected newDiscussionButton(): Mithril.Children {
    const canStart = app.forum.attribute<boolean>('canStartDiscussion');
    const label = app.translator.trans(`core.forum.index.${canStart ? 'start_discussion_button' : 'cannot_start_discussion_button'}`);

    return (
      <Button
        className="Button Button--primary DeckPage-action DeckPage-newDiscussion"
        icon="fas fa-edit"
        disabled={!canStart}
        aria-label={extractText(label)}
        onclick={() =>
          app.composer.load(() => import('flarum/forum/components/DiscussionComposer'), { user: app.session.user }).then(() => app.composer.show())
        }
      >
        <span className="DeckPage-actionLabel">{label}</span>
      </Button>
    );
  }

  protected addButton(): Mithril.Children {
    const canAdd = this.deck.canAddColumn();

    return (
      <Button
        className="Button Button--primary DeckPage-action DeckPage-add"
        icon="fas fa-plus"
        disabled={!canAdd}
        aria-label={extractText(app.translator.trans('flarum-deck.forum.page.add_column_button'))}
        title={canAdd ? undefined : extractText(app.translator.trans('flarum-deck.forum.page.column_limit_reached_text'))}
        onclick={() => this.openAddColumn()}
      >
        <span className="DeckPage-actionLabel">{app.translator.trans('flarum-deck.forum.page.add_column_button')}</span>
      </Button>
    );
  }

  protected openAddColumn(): void {
    app.modal.show(() => import('./AddColumnModal'), {
      deck: this.deck,
      onadd: (config: { id: string }) => setTimeout(() => this.scrollToColumn(config.id), 50),
    });
  }

  /**
   * A chip per column with its pending count: on phones the way to switch (and,
   * dragged, to reorder) columns; elsewhere a way to jump to columns scrolled
   * out of sight.
   */
  protected tabs(columns: DeckColumnState[], phone: boolean): Mithril.Children {
    return columns.map((column, index) => {
      const active = phone && index === this.activeIndex;

      return (
        <Button
          className={classList('Button Button--rounded DeckPage-tab', { 'Button--primary': active })}
          data-column-id={column.config.id}
          aria-current={active ? 'true' : undefined}
          onclick={() => this.scrollToColumn(column.config.id)}
        >
          {column.title()}
          {column.newCount > 0 && <span className="Bubble Bubble--primary DeckPage-tabCount">{column.newCount}</span>}
        </Button>
      );
    });
  }

  /** On phones, chips are dragged to reorder (after a short press, so a tap still switches). */
  protected setUpChipSortable(): void {
    const nav = this.element.querySelector<HTMLElement>('.DeckPage-tabs');
    const wanted = this.sortable && nav && this.isPhone() ? nav : null;

    if (this.chipSortable && (!wanted || this.chipSortable.el !== wanted)) {
      this.chipSortable.destroy();
      this.chipSortable = null;
    }

    if (!wanted || this.chipSortable) return;

    this.chipSortable = this.sortable!.create(wanted, {
      draggable: '.DeckPage-tab',
      direction: 'horizontal',
      animation: 150,
      delay: 250,
      delayOnTouchOnly: true,
      ghostClass: 'DeckPage-tab--ghost',
      onEnd: (event) => {
        const { item, from, oldIndex = 0, newIndex = 0 } = event;

        from.insertBefore(item, from.children[oldIndex > newIndex ? oldIndex + 1 : oldIndex] ?? null);

        if (oldIndex !== newIndex) this.deck.moveFlat(item.dataset.columnId!, newIndex);
      },
    });
  }

  protected onLayoutChange = (): void => {
    m.redraw();
  };

  /** Core's phone breakpoint, read from its CSS variable so themes that move it are followed. */
  protected phoneQuery(): string {
    const max = getComputedStyle(document.documentElement).getPropertyValue('--screen-phone-max').trim() || '767.98px';

    return `(max-width: ${max})`;
  }

  protected isPhone(): boolean {
    return this.media[0]?.matches ?? window.matchMedia(this.phoneQuery()).matches;
  }

  protected isShort(): boolean {
    return this.media[1]?.matches ?? window.matchMedia(SHORT_SCREEN).matches;
  }

  protected scrollToColumn(id: string): void {
    const element = this.element.querySelector(`.DeckColumn[data-column-id="${id}"]`);

    element?.scrollIntoView({ behavior: 'smooth', inline: 'nearest', block: 'nearest' });
  }

  protected observeColumns(): void {
    this.element.querySelectorAll('.DeckColumn').forEach((element) => this.observer?.observe(element));
  }

  protected columnFor(element: HTMLElement): DeckColumnState | undefined {
    return this.deck.columns().find((column) => column.config.id === element.dataset.columnId);
  }
}
