import React from "react";
import isEmpty from "lodash/isEmpty";
import pickBy from "lodash/pickBy";
import omitBy from "lodash/omitBy";
import uniq from "lodash/uniq";

import type { EventMixinCalculatedValues } from "./add-events";
import { isFunction } from "./helpers";

const GLOBAL_EVENT_REGEX = /^onGlobal(.*)$/;

type ComponentEventKey = string | number;
export interface ComponentEvent {
  target?: "parent" | string;
  eventKey?: ComponentEventKey | ComponentEventKey[];
  eventHandlers: ComponentEventHandlers;
}
// Normally we'd use Template Literal Types, but we're avoiding it to maximize TS compatibility with TS < 4.1
export type ComponentEventName = string; // `on${Capitalize<string>}`;
export interface ComponentEventHandlers {
  [k: ComponentEventName]: ComponentEventHandler;
}
export type ComponentEventHandler = (
  evt: React.SyntheticEvent,
  childProps: unknown,
  eventKey: ComponentEventKey,
  eventName: ComponentEventName,
) => UpdatedProps;
export type UpdatedProps = any;

interface ComponentWithEvents extends EventMixinCalculatedValues {
  state;
  setState;
}

/* Returns all own and shared events that should be attached to a single target element,
 * i.e. an individual bar specified by target: "data", eventKey: [index].
 * Returned events are scoped to the appropriate state. Either that of the component itself
 * (i.e. VictoryBar) in the case of own events, or that of the parent component
 * (i.e. VictoryChart) in the case of shared events
 */
// eslint-disable-next-line max-params
export function getEvents(
  this: ComponentWithEvents,
  props,
  target?,
  eventKey?,
  // eslint-disable-next-line no-shadow
  getScopedEvents?,
) {
  // Returns all events that apply to a particular target element
  const getEventsByTarget = (events: Array<ComponentEvent>) => {
    const getSelectedEvents = () => {
      const targetEvents = events.reduce((memo, event) => {
        if (event.target !== undefined) {
          const matchesTarget = Array.isArray(event.target)
            ? event.target.includes(target)
            : `${event.target}` === `${target}`;
          return matchesTarget ? memo.concat(event) : memo;
        }
        return memo.concat(event);
      }, [] as ComponentEvent[]);

      if (eventKey !== undefined && target !== "parent") {
        return targetEvents.filter((obj) => {
          const targetKeys = obj.eventKey;
          const useKey = (key) => (key ? `${key}` === `${eventKey}` : true);
          return Array.isArray(targetKeys)
            ? targetKeys.some((k) => useKey(k))
            : useKey(targetKeys);
        });
      }
      return targetEvents;
    };

    const selectedEvents = getSelectedEvents();
    return (
      Array.isArray(selectedEvents) &&
      selectedEvents.reduce(
        (memo, event) => {
          return event ? Object.assign(memo, event.eventHandlers) : memo;
        },
        {} as ComponentEvent["eventHandlers"],
      )
    );
  };

  /* Returns all events from props and defaultEvents from components. Events handlers
   * specified in props will override handlers for the same event if they are also
   * specified in defaultEvents of a sub-component
   */
  const getAllEvents = () => {
    // Mandatory usage: `getEvents.bind(this)`

    if (Array.isArray(this.componentEvents)) {
      return Array.isArray(props.events)
        ? this.componentEvents.concat(...props.events)
        : this.componentEvents;
    }

    return props.events;
  };

  const allEvents = getAllEvents();
  const ownEvents =
    allEvents && isFunction(getScopedEvents)
      ? getScopedEvents(getEventsByTarget(allEvents), target)
      : undefined;
  if (!props.sharedEvents) {
    return ownEvents;
  }
  const getSharedEvents = props.sharedEvents.getEvents;
  const sharedEvents =
    props.sharedEvents.events &&
    getSharedEvents(getEventsByTarget(props.sharedEvents.events), target);
  return Object.assign({}, sharedEvents, ownEvents);
}

/* Returns a modified events object where each event handler is replaced by a new
 * function that calls the original handler and then calls setState with the return
 * of the original event handler assigned to state property that maps to the target
 * element.
 */
// eslint-disable-next-line max-params
export function getScopedEvents(
  this: ComponentWithEvents,
  events,
  namespace,
  childType,
  baseProps,
) {
  if (isEmpty(events)) {
    return {};
  }

  // Mandatory usage: `getScopedEvents.bind(this)`

  const newBaseProps = baseProps || this.baseProps;
  // returns the original base props or base state of a given target element
  const getTargetProps = (identifier, type) => {
    const { childName, target, key } = identifier;

    const baseType = type === "props" ? newBaseProps : this.state || {};
    const base =
      childName === undefined || childName === null || !baseType[childName]
        ? baseType
        : baseType[childName];
    return key === "parent" ? base.parent : base[key] && base[key][target];
  };

  // Returns the state object with the mutation caused by a given eventReturn
  // applied to the appropriate property on the state object
  const parseEvent = (eventReturn, eventKey) => {
    const childNames =
      namespace === "parent"
        ? eventReturn.childName
        : eventReturn.childName || childType;
    const target = eventReturn.target || namespace;

    // returns all eventKeys to modify for a targeted childName
    const getKeys = (childName) => {
      if (target === "parent") {
        return "parent";
      }
      if (eventReturn.eventKey === "all") {
        return newBaseProps[childName]
          ? Object.keys(newBaseProps[childName]).filter(
              (value) => value !== "parent",
            )
          : Object.keys(newBaseProps).filter((value) => value !== "parent");
      } else if (eventReturn.eventKey === undefined && eventKey === "parent") {
        return newBaseProps[childName]
          ? Object.keys(newBaseProps[childName])
          : Object.keys(newBaseProps);
      }
      return eventReturn.eventKey !== undefined
        ? eventReturn.eventKey
        : eventKey;
    };

    // returns the state object with mutated props applied for a single key
    const getMutationObject = (key, childName) => {
      const baseState = this.state || {};
      if (!isFunction(eventReturn.mutation)) {
        return baseState;
      }
      const mutationTargetProps = getTargetProps(
        { childName, key, target },
        "props",
      );
      const mutationTargetState = getTargetProps(
        { childName, key, target },
        "state",
      );
      const mutatedProps = eventReturn.mutation(
        Object.assign({}, mutationTargetProps, mutationTargetState),
        newBaseProps,
      );
      const childState = baseState[childName] || {};

      const filterState = (state) => {
        if (state[key] && state[key][target]) {
          delete state[key][target];
        }
        if (state[key] && !Object.keys(state[key]).length) {
          delete state[key];
        }
        return state;
      };

      const extendState = (state) => {
        return target === "parent"
          ? Object.assign(state, {
              [key]: Object.assign(state[key] || {}, mutatedProps),
            })
          : Object.assign(state, {
              [key]: Object.assign(state[key] || {}, {
                [target]: mutatedProps,
              }),
            });
      };

      const updateState = (state) => {
        return mutatedProps ? extendState(state) : filterState(state);
      };

      return childName !== undefined && childName !== null
        ? Object.assign(baseState, { [childName]: updateState(childState) })
        : updateState(baseState);
    };

    // returns entire mutated state for a given childName
    const getReturnByChild = (childName) => {
      const mutationKeys = getKeys(childName);
      return Array.isArray(mutationKeys)
        ? mutationKeys.reduce((memo, key) => {
            return Object.assign(memo, getMutationObject(key, childName));
          }, {})
        : getMutationObject(mutationKeys, childName);
    };

    // returns an entire mutated state for all children
    const allChildNames =
      childNames === "all"
        ? Object.keys(newBaseProps).filter((value) => value !== "parent")
        : childNames;
    return Array.isArray(allChildNames)
      ? allChildNames.reduce((memo, childName) => {
          return Object.assign(memo, getReturnByChild(childName));
        }, {})
      : getReturnByChild(allChildNames);
  };

  // Parses an array of event returns into a single state mutation
  const parseEventReturn = (eventReturn, eventKey) => {
    return Array.isArray(eventReturn)
      ? eventReturn.reduce(
          (memo, props) => Object.assign({}, memo, parseEvent(props, eventKey)),
          {},
        )
      : parseEvent(eventReturn, eventKey);
  };

  const compileCallbacks = (eventReturn) => {
    const getCallback = (obj) => isFunction(obj.callback) && obj.callback;
    const callbacks = Array.isArray(eventReturn)
      ? eventReturn.map((evtObj) => getCallback(evtObj))
      : [getCallback(eventReturn)];
    const callbackArray = callbacks.filter((callback) => callback !== false);
    return callbackArray.length
      ? () => callbackArray.forEach((callback) => callback())
      : undefined;
  };

  // A function that calls a particular event handler, parses its return
  // into a state mutation, and calls setState
  // eslint-disable-next-line max-params
  const onEvent = (evt, childProps, eventKey, eventName) => {
    const eventReturn = events[eventName](evt, childProps, eventKey, this);
    if (!isEmpty(eventReturn)) {
      const callbacks = compileCallbacks(eventReturn);

      this.setState(parseEventReturn(eventReturn, eventKey), callbacks);
    }
  };

  // returns a new events object with enhanced event handlers
  return Object.keys(events).reduce((memo, event) => {
    memo[event] = onEvent;
    return memo;
  }, {});
}

/*
 * Returns a partially applied event handler for a specific target element
 * This allows event handlers to have access to props controlling each element
 */
export function getPartialEvents(
  events: ComponentEventHandlers,
  eventKey: ComponentEventKey,
  childProps: unknown,
): PartialEvents {
  if (!events) return {};

  return Object.keys(events).reduce((memo, eventName) => {
    const appliedEvent = (evt) =>
      events[eventName](evt, childProps, eventKey, eventName);
    memo[eventName] = appliedEvent;
    return memo;
  }, {} as PartialEvents);
}
export interface PartialEvents {
  [eventName: ComponentEventName]: (evt: React.SyntheticEvent) => UpdatedProps;
}

/* Returns the property of the state object corresponding to event changes for
 * a particular element
 */
// eslint-disable-next-line max-params
export function getEventState(
  this: ComponentWithEvents,
  eventKey: ComponentEventKey,
  namespace: string,
  childType?: string,
) {
  // Mandatory usage: `getEventState.bind(this)`

  const state = this.state || {};
  if (!childType) {
    return eventKey === "parent"
      ? (state[eventKey] && state[eventKey][namespace]) || state[eventKey]
      : state[eventKey] && state[eventKey][namespace];
  }
  return (
    state[childType] &&
    state[childType][eventKey] &&
    state[childType][eventKey][namespace]
  );
}

/**
 * Returns a set of all mutations for shared events
 *
 * @param  {Array} mutations an array of mutations objects
 * @param  {Object} baseProps an object that describes all props for children of VictorySharedEvents
 * @param  {Object} baseState an object that describes state for children of VictorySharedEvents
 * @param  {Array} childNames an array of childNames
 *
 * @return {Object} a object describing all mutations for VictorySharedEvents
 */
// eslint-disable-next-line max-params
export function getExternalMutationsWithChildren(
  mutations,
  baseProps = {},
  baseState = {},
  childNames,
) {
  return childNames.reduce((memo, childName) => {
    const childState = baseState[childName];
    const mutation = getExternalMutations(
      mutations,
      baseProps[childName],
      baseState[childName],
      childName,
    );
    memo[childName] = mutation ? mutation : childState;
    return pickBy(memo, (v) => !isEmpty(v));
  }, {});
}

/**
 * Returns a set of all mutations for a component
 *
 * @param  {Array} mutations an array of mutations objects
 * @param  {Object} baseProps a props object (scoped to a childName when used by shared events)
 * @param  {Object} baseState a state object (scoped to a childName when used by shared events)
 * @param  {String} childName an optional childName
 *
 * @return {Object} a object describing mutations for a given component
 */
// eslint-disable-next-line max-params
export function getExternalMutations(
  mutations,
  baseProps = {},
  baseState = {},
  childName?,
) {
  const eventKeys = Object.keys(baseProps);
  return eventKeys.reduce((memo, eventKey) => {
    const keyState = baseState[eventKey] || {};
    const keyProps = baseProps[eventKey] || {};
    if (eventKey === "parent") {
      const identifier = { eventKey, target: "parent" };
      const mutation = getExternalMutation(
        mutations,
        keyProps,
        keyState,
        identifier,
      );
      memo[eventKey] =
        mutation !== undefined
          ? Object.assign({}, keyState, mutation)
          : keyState;
    } else {
      // use keys from both state and props so that elements not intially included in baseProps
      // will be used. (i.e. labels)
      const targets = uniq(Object.keys(keyProps).concat(Object.keys(keyState)));
      memo[eventKey] = targets.reduce((m, target) => {
        const identifier = { eventKey, target, childName };
        const mutation = getExternalMutation(
          mutations,
          keyProps[target],
          keyState[target],
          identifier,
        );
        m[target] =
          mutation !== undefined
            ? Object.assign({}, keyState[target], mutation)
            : keyState[target];
        return pickBy(m, (v) => !isEmpty(v));
      }, {});
    }
    return pickBy(memo, (v) => !isEmpty(v));
  }, {});
}

/**
 * Returns a set of mutations for a particular element given scoped baseProps and baseState
 *
 * @param  {Array} mutations an array of mutations objects
 * @param  {Object} baseProps a props object (scoped the element specified by the identifier)
 * @param  {Object} baseState a state object (scoped the element specified by the identifier)
 * @param  {Object} identifier { eventKey, target, childName }
 *
 * @return {Object | undefined} a object describing mutations for a given element, or undefined
 */
// eslint-disable-next-line max-params
export function getExternalMutation(
  mutations,
  baseProps,
  baseState,
  identifier,
) {
  const filterMutations = (mutation, type) => {
    if (typeof mutation[type] === "string") {
      return mutation[type] === "all" || mutation[type] === identifier[type];
    } else if (Array.isArray(mutation[type])) {
      // coerce arrays to strings before matching
      const stringArray = mutation[type].map((m) => `${m}`);
      return stringArray.includes(identifier[type]);
    }
    return false;
  };

  let scopedMutations = Array.isArray(mutations) ? mutations : [mutations];
  if (identifier.childName) {
    scopedMutations = mutations.filter((m) => filterMutations(m, "childName"));
  }
  // find any mutation objects that match the target
  const targetMutations = scopedMutations.filter((m) =>
    filterMutations(m, "target"),
  );
  if (isEmpty(targetMutations)) {
    return undefined;
  }
  const keyMutations = targetMutations.filter((m) =>
    filterMutations(m, "eventKey"),
  );
  if (isEmpty(keyMutations)) {
    return undefined;
  }
  return keyMutations.reduce((memo, curr) => {
    const mutationFunction =
      curr && isFunction(curr.mutation) ? curr.mutation : () => undefined;
    const currentMutation = mutationFunction(
      Object.assign({}, baseProps, baseState),
    );
    return Object.assign({}, memo, currentMutation);
  }, {});
}

/* Returns an array of defaultEvents from sub-components of a given component.
 * i.e. any static `defaultEvents` on `labelComponent` will be returned
 */
export function getComponentEvents(props, components) {
  const events =
    Array.isArray(components) &&
    components.reduce((memo, componentName) => {
      const component = props[componentName];
      const defaultEvents =
        component && component.type && component.type.defaultEvents;
      const componentEvents = isFunction(defaultEvents)
        ? defaultEvents(component.props)
        : defaultEvents;
      return Array.isArray(componentEvents)
        ? memo.concat(...componentEvents)
        : memo;
    }, [] as ComponentEvent[]);
  return events && events.length ? events : undefined;
}

export function getGlobalEventNameFromKey(key) {
  const match = key.match(GLOBAL_EVENT_REGEX);
  return match && match[1] && match[1].toLowerCase();
}

export const getGlobalEvents = (events) =>
  pickBy(events, (_, key) => GLOBAL_EVENT_REGEX.test(key));

export const omitGlobalEvents = (events) =>
  omitBy(events, (_, key) => GLOBAL_EVENT_REGEX.test(key));

export const emulateReactEvent = (event) =>
  Object.assign(event, { nativeEvent: event });
