// Copyright (C) 2023 The Android Open Source Project
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
//      http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.

import m from 'mithril';
import {assertIsInstance} from './assert';

// Check if a mithril component vnode has children
export function hasChildren<T>({children}: m.Vnode<T>): boolean {
  return (
    Array.isArray(children) &&
    children.length > 0 &&
    children.some((value) => value)
  );
}

export function childrenValid(children: m.Children): boolean {
  if (children === null || children === undefined) return false;
  if (Array.isArray(children)) {
    return children.length > 0 && children.some(childrenValid);
  }
  return true;
}

// A component which simply passes through it's children.
// Can be used for having something to attach lifecycle hooks to without having
// to add an extra HTML element to the DOM.
export const Passthrough = {
  view({children}: m.VnodeDOM) {
    return children;
  },
};

export interface GateAttrs {
  open: boolean;
}

// The gate component is a wrapper which can either be open or closed.
// - When open, children are rendered inside a div where display = contents.
// - When closed, children are rendered inside a div where display = none, and
//   children's view functions are not called.
//
// Use this component when we want to conditionally render certain children, but
// we want to retain their state, such as page and tab views.
export class Gate implements m.ClassComponent<GateAttrs> {
  private previousChildren: m.Children;
  private wasOpen?: boolean;

  view({attrs, children}: m.Vnode<GateAttrs>) {
    return m(
      '',
      {
        'data-gate-open': String(attrs.open),
        'style': {display: attrs.open ? 'contents' : 'none'},
      },
      this.renderChildren(attrs.open, children),
    );
  }

  private renderChildren(open: boolean, children: m.Children) {
    // If the gate is open, pass the latest children through, otherwise pass the
    // cached children through. When Mithril sees the same children as in the
    // previous render cycle, it doesn't re-render those children. This is a
    // performance optimization, as children that are not visible typically
    // don't need to be re-rendered.
    //
    // Note: Render the children once more after the gate has been closed, which
    // allows out-of-tree elements like popups to close properly, as the
    // display: none doesn't apply to them.
    if (open || this.wasOpen) {
      this.previousChildren = children;
    }
    this.wasOpen = open;
    return this.previousChildren;
  }
}

export interface GateDetectorAttrs {
  onVisibilityChanged: (visible: boolean, dom: Element) => void;
}

// A component that detects visibility changes from an ancestor Gate component.
// Place this inside a Gate's subtree to receive callbacks when the Gate opens
// or closes. Uses MutationObserver to watch for changes to the Gate's
// data-gate-open attribute.
//
// Usage:
//   view() {
//     return m(GateDetector, {
//       onVisibilityChanged: (visible, dom) => {
//         console.log('Gate is now visible:', visible);
//       }
//     }, m(...));
//   }
export class GateDetector implements m.ClassComponent<GateDetectorAttrs> {
  private observer?: MutationObserver;
  private gateElement?: HTMLElement;
  private dom?: Element;
  private wasVisible?: boolean;
  private callback?: (visible: boolean, dom: Element) => void;

  view({children}: m.Vnode<GateDetectorAttrs>): m.Children {
    return children;
  }

  oncreate(vnode: m.VnodeDOM<GateDetectorAttrs>) {
    this.callback = vnode.attrs.onVisibilityChanged;
    this.dom = vnode.dom;

    // Find closest Gate wrapper using data attribute
    const gateElem = this.dom.closest('[data-gate-open]') ?? undefined;
    this.gateElement = gateElem as HTMLElement | undefined;
    if (!this.gateElement) return;

    this.observer = new MutationObserver(this.checkVisibility.bind(this));
    this.observer.observe(this.gateElement, {
      attributes: true,
      attributeFilter: ['data-gate-open'],
    });

    // Fire initial state
    this.checkVisibility();
  }

  onupdate(vnode: m.VnodeDOM<GateDetectorAttrs>) {
    this.callback = vnode.attrs.onVisibilityChanged;
  }

  private checkVisibility() {
    if (!this.gateElement || !this.dom) return;
    const visible = this.gateElement.dataset.gateOpen === 'true';
    if (visible !== this.wasVisible) {
      this.wasVisible = visible;
      this.callback?.(visible, this.dom);
    }
  }

  onremove() {
    this.observer?.disconnect();
  }
}

export type MithrilEvent<T extends Event = Event> = T & {redraw: boolean};

// Check if a mithril children is empty (falsy or empty array). If
// it is any of these, mithril will not render anything. Useful for when we want
// to optionally avoid rendering a wrapper for some children for instance.
export function isEmptyVnodes(children: m.Children): boolean {
  if (!Boolean(children)) return true;
  if (Array.isArray(children)) {
    return children.length === 0 || children.every(isEmptyVnodes);
  }
  return false;
}

/**
 * Creates a React-style context for passing data through the component tree
 * without having to drill props manually through every level.
 *
 * Wrap content that needs access to context values in a Consumer vnode. The
 * Consumer takes a function that receives the current context value and returns
 * mithril vnodes.
 *
 * @template T The type of value stored in the context
 * @param initialValue Optional default value when no Provider is present
 * @returns An object with Provider and Consumer components
 *
 * @example
 * // Basic usage - create a context and use it
 * const ThemeContext = createContext('light');
 *
 * // Provider sets the context value for its children
 * m(ThemeContext.Provider, {value: 'dark'}, [
 *   m(Header),
 *   m(Content),
 * ]);
 *
 * // Consumer wraps content that needs the context value.
 * m(ThemeContext.Consumer, (theme) => {
 *   return m('.pf-button', {
 *     class: theme === 'dark' ? 'pf-dark' : 'pf-light',
 *   }, 'Click me');
 * });
 *
 * @example
 * // Using Consumer within a component
 * class Button implements m.ClassComponent {
 *   view() {
 *     return m(ThemeContext.Consumer, (theme) => {
 *       return m('.pf-button', {
 *         class: theme === 'dark' ? 'pf-dark' : 'pf-light',
 *       }, 'Click me');
 *     });
 *   }
 * }
 *
 * @example
 * // Using Consumer inline within a vnode tree
 * m('.page', [
 *   m('h1', 'My App'),
 *   m(ThemeContext.Consumer, (theme) =>
 *     m('.content', {class: theme}, 'Content')
 *   ),
 *   m(Footer),
 * ]);
 *
 * @example
 * // Context without a default value
 * interface User {
 *   name: string;
 *   id: number;
 * }
 * const UserContext = createContext<User>();
 *
 * // Consumer receives undefined when no Provider is present
 * m(UserContext.Consumer, (user) => {
 *   if (!user) return m('.pf-greeting', 'Not logged in');
 *   return m('.pf-greeting', `Welcome ${user.name}`);
 * });
 *
 * @example
 * // Nesting Providers creates scoped context values
 * m(ThemeContext.Provider, {value: 'light'}, [
 *   m(ThemeContext.Consumer, (theme) => m('div', theme)), // 'light'
 *   m(ThemeContext.Provider, {value: 'dark'}, [
 *     m(ThemeContext.Consumer, (theme) => m('div', theme)), // 'dark'
 *   ]),
 *   m(ThemeContext.Consumer, (theme) => m('div', theme)), // 'light' again
 * ]);
 */
export function createContext<T>(initialValue: T): {
  Provider: m.Component<{value: T}>;
  Consumer: m.Component<(value: T) => m.Children | void>;
};
export function createContext<T>(): {
  Provider: m.Component<{value: T}>;
  Consumer: m.Component<(value: T | undefined) => m.Children | void>;
};
export function createContext<T>(initialValue?: T): {
  Provider: m.Component<{value: T}>;
  Consumer: m.Component<(value: T | undefined) => m.Children | void>;
} {
  let currentContext: T | undefined = initialValue;

  return {
    Provider: {
      view({attrs, children}: m.Vnode<{value: T}>): m.Children {
        const previousContext = currentContext;
        currentContext = attrs.value;
        return [
          children,
          m({
            view() {
              currentContext = previousContext;
            },
          }),
        ];
      },
    },
    Consumer: {
      view({children}: m.Vnode<(value: T | undefined) => m.Children | void>) {
        const viewFn: (context: T | undefined) => m.Children | void =
          Array.isArray(children) && typeof children[0] === 'function'
            ? children[0]
            : () => null;
        return viewFn(currentContext);
      },
    },
  };
}

/**
 * Options for {@link startDragGesture}.
 */
export interface DragOptions {
  // The pointerdown event that initiates the drag. Its `currentTarget` must
  // be an HTMLElement and is the element that captures the pointer for the
  // duration of the gesture.
  readonly e: PointerEvent;

  // The distance in pixels the pointer must move from its initial position
  // before the drag is considered started. Defaults to 0px (i.e. dragging
  // starts immediately on pointerdown).
  readonly deadzonePx?: number;

  // Called once when the pointer first moves beyond `deadzonePx`. May
  // return per-gesture handlers that take precedence over the top-level
  // `onDrag`/`onDragEnd` for the rest of this gesture — useful when the
  // drag needs to capture state at the moment it starts. If you don't need
  // to initialize anything, omit this and use `onDrag`/`onDragEnd` directly.
  readonly onDragStart?: (e: PointerEvent) => {
    readonly onDrag?: (e: PointerEvent) => void;
    readonly onDragEnd?: (e: PointerEvent) => void;
  } | void;

  // Called for each pointermove after the deadzone is crossed. Ignored for
  // a given gesture if `onDragStart` returned its own `onDrag`.
  readonly onDrag?: (e: PointerEvent) => void;

  // Called when the gesture ends (pointerup/pointercancel) after the
  // deadzone was crossed. Ignored for a given gesture if `onDragStart`
  // returned its own `onDragEnd`.
  readonly onDragEnd?: (e: PointerEvent) => void;

  // Called if the pointer is released before crossing the deadzone — i.e.
  // the gesture turned out to be a click rather than a drag. `onDragStart`
  // will not have been invoked when this fires.
  readonly onDragFailed?: (e: PointerEvent) => void;
}

/**
 * Turns a pointerdown event into a deadzone-gated drag gesture.
 *
 * Captures the pointer on `e.currentTarget`, then waits for movement to
 * exceed `deadzonePx` before invoking `onDragStart`. Subsequent pointermove
 * events are routed to `onDrag`; pointerup/pointercancel ends the
 * gesture and invokes `onDragEnd`. If the pointer is released before the
 * deadzone is crossed, `onDragFailed` is invoked instead — useful for
 * distinguishing clicks from drags on the same element.
 *
 * Pointer capture is released and all listeners are removed automatically
 * when the gesture ends, regardless of how it ends.
 *
 * @example
 * m('.draggable', {
 *   onpointerdown: (e: PointerEvent) => {
 *     startDragGesture({
 *       e,
 *       deadzonePx: 5,
 *       onDrag: (ev) => console.log('moved to', ev.clientX, ev.clientY),
 *       onDragEnd: () => console.log('drag ended'),
 *       onDragFailed: () => console.log('was a click, not a drag'),
 *     });
 *   },
 * });
 *
 * Use `onDragStart` instead when the gesture needs to capture state at the
 * moment dragging actually begins (e.g. clone a node):
 *
 * @example
 * startDragGesture({
 *   e,
 *   deadzonePx: 5,
 *   onDragStart: (ev) => {
 *     const cloned = element.cloneNode();
 *     return {
 *       onDrag: (e2) => cloned.style.transform = `translate(...)`,
 *       onDragEnd: () => cloned.remove(),
 *     };
 *   },
 * });
 */
export function startDragGesture(options: DragOptions): void {
  const {
    e,
    deadzonePx = 0,
    onDragStart,
    onDragFailed,
    onDrag,
    onDragEnd,
  } = options;
  const el = assertIsInstance(e.currentTarget, HTMLElement);
  const startX = e.clientX;
  const startY = e.clientY;
  el.setPointerCapture(e.pointerId);

  let started = false;
  let handlers: {
    onDrag?: (e: PointerEvent) => void;
    onDragEnd?: (e: PointerEvent) => void;
  } = {};

  function teardown() {
    el.removeEventListener('pointermove', onPointerMove);
    el.removeEventListener('pointerup', onPointerUp);
    el.removeEventListener('pointercancel', onPointerUp);
    el.removeEventListener('contextmenu', onContextMenu);
    if (el.hasPointerCapture(e.pointerId)) {
      el.releasePointerCapture(e.pointerId);
    }
  }

  // Suppress the context menu for the duration of the gesture. Right-click
  // drags otherwise pop up the browser menu over the dragged element. We
  // also can't rely on the menu cleaning up our state for us: Chrome does
  // not fire `pointercancel` (or `lostpointercapture` until the next pointer
  // move after the menu closes) when a native context menu opens over a
  // captured pointer, so a drag started with the right button can be left
  // stuck if we don't preventDefault here. See w3c/pointerevents#408.
  function onContextMenu(ev: Event) {
    ev.preventDefault();
    ev.stopPropagation();
  }

  function start(ev: PointerEvent) {
    started = true;
    const perGesture = onDragStart?.(ev);
    handlers = {
      onDrag: perGesture?.onDrag ?? onDrag,
      onDragEnd: perGesture?.onDragEnd ?? onDragEnd,
    };
  }

  function onPointerMove(ev: PointerEvent) {
    m.redraw();
    if (!started) {
      const dx = ev.clientX - startX;
      const dy = ev.clientY - startY;
      if (Math.hypot(dx, dy) < deadzonePx) return;
      start(ev);
    } else {
      handlers.onDrag?.(ev);
    }
  }

  function onPointerUp(ev: PointerEvent) {
    m.redraw();
    teardown();
    if (started) {
      handlers.onDragEnd?.(ev);
    } else {
      onDragFailed?.(ev);
    }
  }

  el.addEventListener('pointermove', onPointerMove);
  el.addEventListener('pointerup', onPointerUp);
  el.addEventListener('pointercancel', onPointerUp);
  el.addEventListener('contextmenu', onContextMenu);

  // With a zero deadzone the gesture starts immediately on pointerdown, so
  // even a click-without-moving counts as a drag (no `onDragFailed` path).
  if (deadzonePx === 0) start(e);
}
