import { Arr } from '@ephox/katamari';
import { Classes, Css } from '@ephox/sugar';

import type * as Boxes from '../../alien/Boxes';
import type { AlloyComponent } from '../../api/component/ComponentApi';
import { applyPositionCss, type PositionCss } from '../../positioning/view/PositionCss';

import * as Dockables from './Dockables';
import type { DockingConfig, DockingDecision, DockingMode, DockingState, DockingViewport } from './DockingTypes';

const morphToStatic = (component: AlloyComponent, config: DockingConfig, state: DockingState): void => {
  state.setDocked(false);
  Arr.each([ 'left', 'right', 'top', 'bottom', 'position' ], (prop) => Css.remove(component.element, prop));
  config.onUndocked(component);
};

const morphToCoord = (component: AlloyComponent, config: DockingConfig, state: DockingState, position: PositionCss): void => {
  const isDocked = position.position === 'fixed';
  state.setDocked(isDocked);
  applyPositionCss(component.element, position);
  const method = isDocked ? config.onDocked : config.onUndocked;
  method(component);
};

const updateVisibility = (component: AlloyComponent, config: DockingConfig, state: DockingState, viewport: DockingViewport, morphToDocked: boolean = false): void => {
  config.contextual.each((contextInfo) => {
    // Make the dockable component disappear if the context is outside the viewport
    contextInfo.lazyContext(component).each((box) => {
      const isVisible = Dockables.isPartiallyVisible(box, viewport.bounds);
      if (isVisible !== state.isVisible()) {
        state.setVisible(isVisible);

        // If morphing to docked and the context isn't visible then immediately set
        // the fadeout class and don't worry about transitioning, as the context
        // would never have been in view while docked
        if (morphToDocked && !isVisible) {
          Classes.add(component.element, [ contextInfo.fadeOutClass ]);
          contextInfo.onHide(component);
        } else {
          const method = isVisible ? Dockables.appear : Dockables.disappear;
          method(component, contextInfo);
        }
      }
    });
  });
};

const applyFixedMorph = (
  component: AlloyComponent,
  config: DockingConfig,
  state: DockingState,
  viewport: DockingViewport,
  morph: Dockables.FixedMorph
) => {
  // This "updateVisibility" call is potentially duplicated with the
  // call in refreshInternal for isDocked. We might want to consolidate them.
  // The difference between them is the "morphToDocked" flag.
  updateVisibility(component, config, state, viewport, true);
  morphToCoord(component, config, state, morph.positionCss);
};

const applyMorph = (
  component: AlloyComponent,
  config: DockingConfig,
  state: DockingState,
  viewport: DockingViewport,
  morph: Dockables.MorphInfo
) => {
  // Apply the morph result depending on its type
  switch (morph.morph) {
    case 'static': {
      return morphToStatic(component, config, state);
    }
    case 'absolute': {
      return morphToCoord(component, config, state, morph.positionCss);
    }
    case 'fixed': {
      return applyFixedMorph(component, config, state, viewport, morph);
    }
  }
};

const refreshInternal = (component: AlloyComponent, config: DockingConfig, state: DockingState): void => {
  // Absolute coordinates (considers scroll)
  const viewport: DockingViewport = config.lazyViewport(component);

  updateVisibility(component, config, state, viewport);

  Dockables.tryMorph(component, viewport, state).each((morph) => {
    applyMorph(component, config, state, viewport, morph);
  });
};

const resetInternal = (component: AlloyComponent, config: DockingConfig, state: DockingState): void => {
  // Morph back to the original position
  const elem = component.element;
  state.setDocked(false);
  const viewport = config.lazyViewport(component);
  Dockables.calculateMorphToOriginal(component, viewport, state).each(
    (staticOrAbsoluteMorph: Dockables.StaticMorph | Dockables.AbsoluteMorph) => {
      // This code is very similar to the "applyMorph" function above. The main difference
      // is that it doesn't consider fixed position, because something that is docking
      // can't currently start with fixed position
      switch (staticOrAbsoluteMorph.morph) {
        case 'static': {
          morphToStatic(component, config, state);
          break;
        }
        case 'absolute': {
          morphToCoord(component, config, state, staticOrAbsoluteMorph.positionCss);
          break;
        }
        default:
      }
    }
  );

  // Remove contextual visibility classes
  state.setVisible(true);
  config.contextual.each((contextInfo) => {
    Classes.remove(elem, [ contextInfo.fadeInClass, contextInfo.fadeOutClass, contextInfo.transitionClass ]);
    contextInfo.onShow(component);
  });

  // Apply docking again to reset the position
  refresh(component, config, state);
};

const refresh = (component: AlloyComponent, config: DockingConfig, state: DockingState): void => {
  // Ensure the component is attached to the document/world, if not then do nothing as we can't
  // check if the component should be docked or not when in a detached state
  if (component.getSystem().isConnected()) {
    refreshInternal(component, config, state);
  }
};

const reset = (component: AlloyComponent, config: DockingConfig, state: DockingState): void => {
  // If the component is not docked then there's no need to reset the state,
  // so only reset when docked
  if (state.isDocked()) {
    resetInternal(component, config, state);
  }
};

const forceDockWithDecision = (
  getDecision: (winBox: Boxes.Bounds, leftX: number, v: DockingViewport) => DockingDecision
) => (
  component: AlloyComponent,
  config: DockingConfig,
  state: DockingState
): void => {
  const viewport = config.lazyViewport(component);
  const optMorph = Dockables.forceDockWith(component.element, viewport, state, getDecision);
  optMorph.each((morph) => {
    // ASSUMPTION: This "applyFixedMorph" sets state.setDocked to true.
    applyFixedMorph(component, config, state, viewport, morph);
  });
};

const forceDockToTop = forceDockWithDecision(Dockables.forceTopPosition);

const forceDockToBottom = forceDockWithDecision(Dockables.forceBottomPosition);

const isDocked = (component: AlloyComponent, config: DockingConfig, state: DockingState): boolean =>
  state.isDocked();

const setModes = (component: AlloyComponent, config: DockingConfig, state: DockingState, modes: DockingMode[]): void =>
  state.setModes(modes);

const getModes = (component: AlloyComponent, config: DockingConfig, state: DockingState): DockingMode[] =>
  state.getModes();

export {
  refresh,
  reset,
  isDocked,
  getModes,
  setModes,
  forceDockToTop,
  forceDockToBottom
};
