import React, { CSSProperties, ReactNode } from 'react';

import { innerWidth, innerHeight } from './utils/innerSize';
import series from './utils/series';
import shallowEqual from './utils/shallowEqual';
import throttle from './utils/throttle';
import uniqueId from './utils/uniqueId';
import whilst from './utils/whilst';

function assertElementFitsWidth(el: HTMLElement, width: number) {
  return el.scrollWidth - 1 <= width;
}

function assertElementFitsHeight(el: HTMLElement, height: number) {
  return el.scrollHeight - 1 <= height;
}

interface Props {
  children?: ReactNode;
  text?: string;
  min: number;
  max: number;
  mode: 'single' | 'multi';
  forceSingleModeWidth: boolean;
  throttle: number;
  onReady?: (a: number) => void;
  autoResize: boolean;
  style?: Record<string, unknown>;
  forceWidth?: boolean;
}

export default class TextFit extends React.Component<Props> {
  static defaultProps = {
    min: 1,
    max: 100,
    mode: 'multi',
    forceSingleModeWidth: true,
    throttle: 50,
    autoResize: true,
  };

  _child: HTMLDivElement;
  _parent: HTMLDivElement;
  pid: number;

  constructor(props) {
    super(props);

    this.handleWindowResize = throttle(this.handleWindowResize, props.throttle);
  }

  state = {
    fontSize: null,
    ready: false,
  };

  componentDidMount() {
    const { autoResize }: Readonly<{ autoResize?: boolean }> = this.props;
    if (autoResize) {
      window.addEventListener('resize', this.handleWindowResize);
    }
    this.process();
  }

  componentDidUpdate(prevProps) {
    const { ready } = this.state;
    if (!ready) return;
    if (shallowEqual(this.props, prevProps)) return;
    this.process();
  }

  componentWillUnmount() {
    const { autoResize } = this.props;
    if (autoResize) {
      window.removeEventListener('resize', this.handleWindowResize);
    }
    // Setting a new pid will cancel all running processes
    this.pid = uniqueId();
  }

  handleWindowResize: (context) => unknown = () => {
    this.process();
  };

  process() {
    const { min, max, mode, forceSingleModeWidth, onReady } = this.props;
    const el = this._parent;
    const wrapper = this._child;

    const originalWidth = innerWidth(el);
    const originalHeight = innerHeight(el);

    if (originalHeight <= 0 || isNaN(originalHeight)) {
      console.warn(
        'Can not process element without height. Make sure the element is displayed and has a static height.'
      );
      return;
    }

    if (originalWidth <= 0 || isNaN(originalWidth)) {
      console.warn(
        'Can not process element without width. Make sure the element is displayed and has a static width.'
      );
      return;
    }

    const pid = uniqueId();
    this.pid = pid;

    const shouldCancelProcess = () => pid !== this.pid;

    const testPrimary =
      mode === 'multi'
        ? () => assertElementFitsHeight(wrapper, originalHeight)
        : () => assertElementFitsWidth(wrapper, originalWidth);

    const testSecondary =
      mode === 'multi'
        ? () => assertElementFitsWidth(wrapper, originalWidth)
        : () => assertElementFitsHeight(wrapper, originalHeight);

    let mid: number;
    let low = min;
    let high = max;

    this.setState({ ready: false });

    series(
      [
        // Step 1:
        // Binary search to fit the element's height (multi line) / width (single line)
        stepCallback =>
          whilst(
            () => low <= high,
            whilstCallback => {
              if (shouldCancelProcess()) return whilstCallback(true);
              mid = parseInt(String((low + high) / 2), 10);
              this.setState({ fontSize: mid }, () => {
                if (shouldCancelProcess()) return whilstCallback(true);
                if (testPrimary()) low = mid + 1;
                else high = mid - 1;
                return whilstCallback();
              });
            },
            stepCallback
          ),
        // Step 2:
        // Binary search to fit the element's width (multi line) / height (single line)
        // If mode is single and forceSingleModeWidth is true, skip this step
        // in order to not fit the elements height and decrease the width
        stepCallback => {
          if (mode === 'single' && forceSingleModeWidth) return stepCallback();
          if (testSecondary()) return stepCallback();
          low = min;
          high = mid;
          return whilst(
            () => low < high,
            whilstCallback => {
              if (shouldCancelProcess()) return whilstCallback(true);
              mid = parseInt(String((low + high) / 2), 10);
              this.setState({ fontSize: mid }, () => {
                if (pid !== this.pid) return whilstCallback(true);
                if (testSecondary()) low = mid + 1;
                else high = mid - 1;
                return whilstCallback();
              });
            },
            stepCallback
          );
        },
        // Step 3
        // Limits
        stepCallback => {
          // We break the previous loop without updating mid for the final time,
          // so we do it here:
          mid = Math.min(low, high);

          // Ensure we hit the user-supplied limits
          mid = Math.max(mid, min);
          mid = Math.min(mid, max);

          // Sanity check:
          mid = Math.max(mid, 0);

          if (shouldCancelProcess()) return stepCallback(true);
          this.setState({ fontSize: mid }, stepCallback);
        },
      ],
      err => {
        // err will be true, if another process was triggered
        if (err || shouldCancelProcess()) return;
        this.setState({ ready: true }, () => onReady(mid));
      }
    );
  }

  render() {
    const { children, text, style, mode, ...props } = this.props;
    const { fontSize, ready } = this.state;
    const finalStyle = {
      ...style,
      fontSize: fontSize,
    };

    const wrapperStyle: CSSProperties = {
      display: ready ? 'block' : 'inline-block',
      whiteSpace: 'inherit',
    };
    if (mode === 'single') wrapperStyle.whiteSpace = 'nowrap';

    return (
      <div
        ref={c => {
          this._parent = c;
        }}
        style={finalStyle}
        {...props}
      >
        <div
          ref={c => {
            this._child = c;
          }}
          style={wrapperStyle}
        >
          {text && ready ? text : children}
        </div>
      </div>
    );
  }
}
