import {
  DIRECTION_ALL,
  Manager,
  Pan,
  Press,
  Tap,
  TouchMouseInput,
} from "@egjs/hammerjs";
import {
  CSSResultGroup,
  LitElement,
  PropertyValues,
  TemplateResult,
  css,
  html,
  nothing,
  svg,
} from "lit";
import {
  customElement,
  property,
  query,
  queryAll,
  state,
} from "lit/decorators";
import { classMap } from "lit/directives/class-map";
import { ifDefined } from "lit/directives/if-defined";
import { fireEvent } from "../common/dom/fire_event";
import { clamp } from "../common/number/clamp";
import { svgArc } from "../resources/svg-arc";
import { isTouch } from "../util/is_touch";

const MAX_ANGLE = 270;
const ROTATE_ANGLE = 360 - MAX_ANGLE / 2 - 90;
const RADIUS = 145;

function xy2polar(x: number, y: number) {
  const r = Math.sqrt(x * x + y * y);
  const phi = Math.atan2(y, x);
  return [r, phi];
}

function rad2deg(rad: number) {
  return (rad / (2 * Math.PI)) * 360;
}

type ActiveSlider = "low" | "high" | "value";

declare global {
  interface HASSDomEvents {
    "value-changing": { value: unknown };
    "low-changing": { value: unknown };
    "low-changed": { value: unknown };
    "high-changing": { value: unknown };
    "high-changed": { value: unknown };
  }
}

const A11Y_KEY_CODES = new Set([
  "ArrowRight",
  "ArrowUp",
  "ArrowLeft",
  "ArrowDown",
  "PageUp",
  "PageDown",
  "Home",
  "End",
]);

export type ControlCircularSliderMode = "start" | "end" | "full";

@customElement("ha-control-circular-slider")
export class HaControlCircularSlider extends LitElement {
  @property({ type: Boolean, reflect: true }) public disabled = false;

  @property({ type: Boolean, reflect: true }) public readonly = false;

  @property({ type: Boolean }) public dual = false;

  @property({ type: String }) public mode?: ControlCircularSliderMode;

  @property({ type: Boolean }) public inactive = false;

  @property({ type: String }) public label?: string;

  @property({ type: String, attribute: "low-label" }) public lowLabel?: string;

  @property({ type: String, attribute: "high-label" })
  public highLabel?: string;

  @property({ type: Number }) public value?: number;

  @property({ type: Number }) public low?: number;

  @property({ type: Number }) public high?: number;

  @property({ type: Number }) public current?: number;

  @property({ type: Number }) public step = 1;

  @property({ type: Number }) public min = 0;

  @property({ type: Number }) public max = 100;

  @property({ type: Boolean, attribute: "prevent-interaction-on-scroll" })
  public preventInteractionOnScroll = false;

  @state() public _localValue?: number = this.value;

  @state() public _localLow?: number = this.low;

  @state() public _localHigh?: number = this.high;

  @state() public _activeSlider?: ActiveSlider;

  @state() public _lastSlider?: ActiveSlider;

  private _valueToPercentage(value: number) {
    return (
      (clamp(value, this.min, this.max) - this.min) / (this.max - this.min)
    );
  }

  private _percentageToValue(value: number) {
    return (this.max - this.min) * value + this.min;
  }

  private _steppedValue(value: number) {
    return Math.round(value / this.step) * this.step;
  }

  private _boundedValue(value: number) {
    const min =
      this._activeSlider === "high"
        ? Math.min(this._localLow ?? this.max)
        : this.min;
    const max =
      this._activeSlider === "low"
        ? Math.max(this._localHigh ?? this.min)
        : this.max;
    return Math.min(Math.max(value, min), max);
  }

  protected updated(changedProps: PropertyValues): void {
    super.updated(changedProps);
    if (!this._activeSlider) {
      if (changedProps.has("value")) {
        this._localValue = this.value;
      }
      if (changedProps.has("low")) {
        this._localLow = this.low;
      }
      if (changedProps.has("high")) {
        this._localHigh = this.high;
      }
    }

    if (
      (changedProps.has("_localValue") &&
        changedProps.get("_localValue") == null) ||
      (changedProps.has("_localLow") &&
        changedProps.get("_localLow") == null) ||
      (changedProps.has("_localHigh") &&
        changedProps.get("_localHigh") == null) ||
      changedProps.has("preventInteractionOnScroll")
    ) {
      this._destroyListeners();
      this._setupListeners();
    }
  }

  connectedCallback(): void {
    super.connectedCallback();
    this._setupListeners();
  }

  disconnectedCallback(): void {
    super.disconnectedCallback();
  }

  private _managers: HammerManager[] = [];

  private _getPercentageFromEvent = (e: HammerInput) => {
    const bound = this._slider.getBoundingClientRect();
    const x = (2 * (e.center.x - bound.left - bound.width / 2)) / bound.width;
    const y = (2 * (e.center.y - bound.top - bound.height / 2)) / bound.height;

    const [, phi] = xy2polar(x, y);

    const offset = (360 - MAX_ANGLE) / 2;

    const angle = ((rad2deg(phi) + offset - ROTATE_ANGLE + 360) % 360) - offset;

    return Math.max(Math.min(angle / MAX_ANGLE, 1), 0);
  };

  @query("#slider")
  private _slider;

  @queryAll("[data-interaction]")
  private _interactions?: HTMLElement[];

  private _findActiveSlider(value: number): ActiveSlider {
    if (!this.dual) return "value";
    const low = Math.max(this._localLow ?? this.min, this.min);
    const high = Math.min(this._localHigh ?? this.max, this.max);
    if (low >= value) {
      return "low";
    }
    if (high <= value) {
      return "high";
    }
    const lowDistance = Math.abs(value - low);
    const highDistance = Math.abs(value - high);
    return lowDistance <= highDistance ? "low" : "high";
  }

  private _setActiveValue(value: number) {
    switch (this._activeSlider) {
      case "high":
        this._localHigh = value;
        break;
      case "low":
        this._localLow = value;
        break;
      case "value":
        this._localValue = value;
        break;
    }
  }

  private _getActiveValue(): number | undefined {
    switch (this._activeSlider) {
      case "high":
        return this._localHigh;
      case "low":
        return this._localLow;
      case "value":
        return this._localValue;
    }
    return undefined;
  }

  private _setupListeners() {
    if (this._interactions && this._managers.length === 0) {
      this._interactions.forEach((interaction) => {
        const mc = new Manager(interaction, {
          inputClass: TouchMouseInput,
        });

        this._managers.push(mc);

        const pressToActivate = this.preventInteractionOnScroll && isTouch;

        // If press to activate is true, a 50ms press is required to activate the slider
        mc.add(
          new Press({
            enable: pressToActivate,
            pointers: 1,
            time: 50,
          })
        );

        const panRecognizer = new Pan({
          direction: DIRECTION_ALL,
          enable: !pressToActivate,
          threshold: 0,
        });

        mc.add(panRecognizer);

        mc.add(new Tap({ event: "singletap" }));

        mc.on("press", (e) => {
          e.srcEvent.stopPropagation();
          e.srcEvent.preventDefault();
          if (this.disabled || this.readonly) return;
          const percentage = this._getPercentageFromEvent(e);
          const raw = this._percentageToValue(percentage);
          this._activeSlider = this._findActiveSlider(raw);
          const bounded = this._boundedValue(raw);
          this._setActiveValue(bounded);
          const stepped = this._steppedValue(bounded);
          if (this._activeSlider) {
            fireEvent(this, `${this._activeSlider}-changing`, {
              value: stepped,
            });
          }
          panRecognizer.set({ enable: true });
        });

        mc.on("pressup", (e) => {
          e.srcEvent.stopPropagation();
          e.srcEvent.preventDefault();
          const percentage = this._getPercentageFromEvent(e);
          const raw = this._percentageToValue(percentage);
          const bounded = this._boundedValue(raw);
          const stepped = this._steppedValue(bounded);
          this._setActiveValue(stepped);
          if (this._activeSlider) {
            fireEvent(this, `${this._activeSlider}-changing`, {
              value: undefined,
            });
            fireEvent(this, `${this._activeSlider}-changed`, {
              value: stepped,
            });
          }
          this._activeSlider = undefined;
        });

        mc.on("pan", (e) => {
          e.srcEvent.stopPropagation();
          e.srcEvent.preventDefault();
        });
        mc.on("panstart", (e) => {
          if (this.disabled || this.readonly) return;
          const percentage = this._getPercentageFromEvent(e);
          const raw = this._percentageToValue(percentage);
          this._activeSlider = this._findActiveSlider(raw);
          this._lastSlider = this._activeSlider;
          this.shadowRoot?.getElementById("#slider")?.focus();
        });
        mc.on("pancancel", () => {
          if (this.disabled || this.readonly) return;
          this._activeSlider = undefined;
          if (pressToActivate) {
            panRecognizer.set({ enable: false });
          }
        });
        mc.on("panmove", (e) => {
          if (this.disabled || this.readonly) return;
          const percentage = this._getPercentageFromEvent(e);
          const raw = this._percentageToValue(percentage);
          const bounded = this._boundedValue(raw);
          this._setActiveValue(bounded);
          const stepped = this._steppedValue(bounded);
          if (this._activeSlider) {
            fireEvent(this, `${this._activeSlider}-changing`, {
              value: stepped,
            });
          }
        });
        mc.on("panend", (e) => {
          if (this.disabled || this.readonly) return;
          const percentage = this._getPercentageFromEvent(e);
          const raw = this._percentageToValue(percentage);
          const bounded = this._boundedValue(raw);
          const stepped = this._steppedValue(bounded);
          this._setActiveValue(stepped);
          if (this._activeSlider) {
            fireEvent(this, `${this._activeSlider}-changing`, {
              value: undefined,
            });
            fireEvent(this, `${this._activeSlider}-changed`, {
              value: stepped,
            });
          }
          this._activeSlider = undefined;
          if (pressToActivate) {
            panRecognizer.set({ enable: false });
          }
        });
        mc.on("singletap", (e) => {
          if (this.disabled || this.readonly) return;
          const percentage = this._getPercentageFromEvent(e);
          const raw = this._percentageToValue(percentage);
          this._activeSlider = this._findActiveSlider(raw);
          const bounded = this._boundedValue(raw);
          const stepped = this._steppedValue(bounded);
          this._setActiveValue(stepped);
          if (this._activeSlider) {
            fireEvent(this, `${this._activeSlider}-changing`, {
              value: undefined,
            });
            fireEvent(this, `${this._activeSlider}-changed`, {
              value: stepped,
            });
          }
          this._lastSlider = this._activeSlider;
          this.shadowRoot?.getElementById("#slider")?.focus();
          this._activeSlider = undefined;
          if (pressToActivate) {
            panRecognizer.set({ enable: false });
          }
        });
      });
    }
  }

  private get _tenPercentStep() {
    return Math.max(this.step, (this.max - this.min) / 10);
  }

  private _handleKeyDown(e: KeyboardEvent) {
    if (!A11Y_KEY_CODES.has(e.code)) return;
    e.preventDefault();
    if (this._lastSlider) {
      this.shadowRoot?.getElementById(this._lastSlider)?.focus();
    }
    this._activeSlider =
      this._lastSlider ?? ((e.currentTarget as any).id as ActiveSlider);
    this._lastSlider = undefined;

    const value = this._getActiveValue();

    switch (e.code) {
      case "ArrowRight":
      case "ArrowUp":
        this._setActiveValue(
          this._boundedValue((value ?? this.min) + this.step)
        );
        break;
      case "ArrowLeft":
      case "ArrowDown":
        this._setActiveValue(
          this._boundedValue((value ?? this.min) - this.step)
        );
        break;
      case "PageUp":
        this._setActiveValue(
          this._steppedValue(
            this._boundedValue((value ?? this.min) + this._tenPercentStep)
          )
        );
        break;
      case "PageDown":
        this._setActiveValue(
          this._steppedValue(
            this._boundedValue((value ?? this.min) - this._tenPercentStep)
          )
        );
        break;
      case "Home":
        this._setActiveValue(this._boundedValue(this.min));
        break;
      case "End":
        this._setActiveValue(this._boundedValue(this.max));
        break;
    }
    fireEvent(this, `${this._activeSlider}-changing`, {
      value: this._getActiveValue(),
    });
    this._activeSlider = undefined;
  }

  _handleKeyUp(e: KeyboardEvent) {
    if (!A11Y_KEY_CODES.has(e.code)) return;
    this._activeSlider = (e.currentTarget as any).id as ActiveSlider;
    e.preventDefault();
    fireEvent(this, `${this._activeSlider}-changing`, {
      value: undefined,
    });
    fireEvent(this, `${this._activeSlider}-changed`, {
      value: this._getActiveValue(),
    });
    this._activeSlider = undefined;
  }

  private _destroyListeners() {
    if (this._managers.length > 0) {
      this._managers.forEach((manager) => manager.destroy());
      this._managers = [];
    }
  }

  private _strokeCircleDashArc(value: number): [string, string] {
    return this._strokeDashArc(value, value);
  }

  private _strokeDashArc(from: number, to: number): [string, string] {
    const start = this._valueToPercentage(from);
    const end = this._valueToPercentage(to);

    const track = (RADIUS * 2 * Math.PI * MAX_ANGLE) / 360;
    const arc = Math.max((end - start) * track, 0);
    const arcOffset = start * track - 0.5;

    const strokeDasharray = `${arc} ${track - arc}`;
    const strokeDashOffset = `-${arcOffset}`;
    return [strokeDasharray, strokeDashOffset];
  }

  protected renderArc(
    id: string,
    value: number | undefined,
    mode: ControlCircularSliderMode
  ) {
    if (this.disabled) return nothing;

    const path = svgArc({
      x: 0,
      y: 0,
      start: 0,
      end: MAX_ANGLE,
      r: RADIUS,
    });

    const angle =
      value != null ? this._valueToPercentage(value) * MAX_ANGLE : undefined;

    const limit = mode === "end" ? this.max : this.min;

    const current = this.current ?? limit;
    const target = value ?? limit;

    const showActive =
      mode === "end"
        ? target <= current
        : mode === "start"
          ? current <= target
          : false;

    const showTarget = value != null;

    const activeArc = showTarget
      ? showActive
        ? mode === "end"
          ? this._strokeDashArc(target, current)
          : this._strokeDashArc(current, target)
        : this._strokeCircleDashArc(target)
      : undefined;

    const coloredArc =
      mode === "full"
        ? this._strokeDashArc(this.min, this.max)
        : mode === "end"
          ? this._strokeDashArc(target, limit)
          : this._strokeDashArc(limit, target);

    const targetCircle = showTarget
      ? this._strokeCircleDashArc(target)
      : undefined;

    const currentCircle =
      this.current != null &&
      this.current <= this.max &&
      this.current >= this.min &&
      (showActive || this.mode === "full")
        ? this._strokeCircleDashArc(this.current)
        : undefined;

    const onlyDotInteraction =
      (this.preventInteractionOnScroll && isTouch) || false;

    return svg`
      <g class=${classMap({ inactive: Boolean(this.inactive) })}>
        <path
          class="arc arc-clear"
          d=${path}
          stroke-dasharray=${coloredArc[0]}
          stroke-dashoffset=${coloredArc[1]}
        />
        <path
          class="arc arc-colored ${classMap({ [id]: true })}"
          d=${path}
          stroke-dasharray=${coloredArc[0]}
          stroke-dashoffset=${coloredArc[1]}
        />
        ${
          activeArc
            ? svg`
              <path
                .id=${id}
                d=${path}
                class="arc arc-active ${classMap({ [id]: true })}"
                stroke-dasharray=${activeArc[0]}
                stroke-dashoffset=${activeArc[1]}
                role="slider"
                tabindex="0"
                aria-valuemin=${this.min}
                aria-valuemax=${this.max}
                aria-valuenow=${
                  this._localValue != null
                    ? this._steppedValue(this._localValue)
                    : undefined
                }
                aria-disabled=${this.disabled}
                aria-readonly=${this.readonly}
                aria-label=${ifDefined(this.lowLabel ?? this.label)}
                @keydown=${this._handleKeyDown}
                @keyup=${this._handleKeyUp}
              />
            `
            : nothing
        }
        ${
          currentCircle
            ? svg`
              <path
                class="current arc-current"
                d=${path}
                stroke-dasharray=${currentCircle[0]}
                stroke-dashoffset=${currentCircle[1]}
              />
          `
            : nothing
        }
        ${
          targetCircle
            ? svg`
              <!-- Use circle instead of path for interaction (Safari doesn't support well pointer-events with stroke-dasharray) -->
              <circle
                transform="rotate(${angle} 0 0)"
                ?data-interaction=${onlyDotInteraction}
                cx=${RADIUS}
                cy="0"
                />
              <path
                d=${path}
                stroke-dasharray=${targetCircle[0]}
                stroke-dashoffset=${targetCircle[1]}
              />
              <path
                class="target-border ${classMap({ [id]: true })}"
                d=${path}
                stroke-dasharray=${targetCircle[0]}
                stroke-dashoffset=${targetCircle[1]}
              />
              <path
                class="target"
                d=${path}
                stroke-dasharray=${targetCircle[0]}
                stroke-dashoffset=${targetCircle[1]}
              />
          `
            : nothing
        }
      </g>
    `;
  }

  protected render(): TemplateResult {
    const trackPath = svgArc({
      x: 0,
      y: 0,
      start: 0,
      end: MAX_ANGLE,
      r: RADIUS,
    });

    const lowValue = this.dual ? this._localLow : this._localValue;
    const highValue = this._localHigh;
    const current = this.current;

    const currentStroke = current
      ? this._strokeCircleDashArc(current)
      : undefined;

    const onlyDotInteraction =
      (this.preventInteractionOnScroll && isTouch) || false;

    return html`
      <svg
        id="slider"
        viewBox="0 0 320 320"
        overflow="visible"
        class=${classMap({
          pressed: Boolean(this._activeSlider),
        })}
        @keydown=${this._handleKeyDown}
        tabindex=${this._lastSlider ? "0" : "-1"}
      >
        <g
          id="container"
          transform="translate(160 160) rotate(${ROTATE_ANGLE})"
        >
          <path d=${trackPath} ?data-interaction=${!onlyDotInteraction} />
          <path class="background" d=${trackPath} />
          ${currentStroke
            ? svg`
                  <path
                    class="current"
                    d=${trackPath}
                    stroke-dasharray=${currentStroke[0]}
                    stroke-dashoffset=${currentStroke[1]}
                  />
                `
            : nothing}
          ${lowValue != null || this.mode === "full"
            ? this.renderArc(
                this.dual ? "low" : "value",
                lowValue,
                (!this.dual && this.mode) || "start"
              )
            : nothing}
          ${this.dual && highValue != null
            ? this.renderArc("high", highValue, "end")
            : nothing}
        </g>
      </svg>
    `;
  }

  static get styles(): CSSResultGroup {
    return css`
      :host {
        --control-circular-slider-color: var(--primary-color);
        --control-circular-slider-background: var(--disabled-color);
        --control-circular-slider-background-opacity: 0.3;
        --control-circular-slider-low-color: var(
          --control-circular-slider-color
        );
        --control-circular-slider-high-color: var(
          --control-circular-slider-color
        );
        --control-circular-slider-interaction-margin: 12px;
        width: 320px;
        display: block;
      }
      svg {
        width: 100%;
        display: block;
        pointer-events: none;
      }
      g {
        fill: none;
      }
      #slider {
        outline: none;
      }
      path[data-interaction] {
        fill: none;
        cursor: pointer;
        pointer-events: auto;
        stroke: transparent;
        stroke-linecap: round;
        stroke-width: calc(
          24px + 2 * var(--control-circular-slider-interaction-margin)
        );
      }
      circle[data-interaction] {
        r: calc(12px + var(--control-circular-slider-interaction-margin));
        fill: transparent;
        cursor: pointer;
        pointer-events: auto;
      }
      :host([disabled]) [data-interaction],
      :host([readonly]) [data-interaction] {
        cursor: initial;
        pointer-events: none;
      }

      .background {
        fill: none;
        stroke: var(--control-circular-slider-background);
        opacity: var(--control-circular-slider-background-opacity);
        transition:
          stroke 180ms ease-in-out,
          opacity 180ms ease-in-out;
        stroke-linecap: round;
        stroke-width: 24px;
      }

      .arc {
        fill: none;
        stroke-linecap: round;
        stroke-width: 24px;
        transition:
          stroke-width 300ms ease-in-out,
          stroke-dasharray 300ms ease-in-out,
          stroke-dashoffset 300ms ease-in-out,
          stroke 180ms ease-in-out,
          opacity 180ms ease-in-out;
      }

      .target {
        fill: none;
        stroke-linecap: round;
        stroke-width: 18px;
        stroke: white;
        transition:
          stroke-width 300ms ease-in-out,
          stroke-dasharray 300ms ease-in-out,
          stroke-dashoffset 300ms ease-in-out,
          stroke 180ms ease-in-out,
          opacity 180ms ease-in-out;
      }

      .target-border {
        fill: none;
        stroke-linecap: round;
        stroke-width: 24px;
        stroke: white;
        transition:
          stroke-width 300ms ease-in-out,
          stroke-dasharray 300ms ease-in-out,
          stroke-dashoffset 300ms ease-in-out,
          stroke 180ms ease-in-out,
          opacity 180ms ease-in-out;
      }

      .current {
        fill: none;
        stroke-linecap: round;
        stroke-width: 8px;
        stroke: var(--primary-text-color);
        opacity: 0.5;
        transition:
          stroke-width 300ms ease-in-out,
          stroke-dasharray 300ms ease-in-out,
          stroke-dashoffset 300ms ease-in-out,
          stroke 180ms ease-in-out,
          opacity 180ms ease-in-out;
      }

      .arc-current {
        stroke: var(--clear-background-color);
      }

      .arc-clear {
        stroke: var(--clear-background-color);
      }
      .arc-colored {
        opacity: 0.5;
      }
      .arc-active {
        outline: none;
      }
      .arc-active:focus-visible {
        stroke-width: 28px;
      }

      .pressed .arc,
      .pressed .target,
      .pressed .target-border,
      .pressed .current {
        transition:
          stroke-width 300ms ease-in-out,
          stroke 180ms ease-in-out,
          opacity 180ms ease-in-out;
      }

      .inactive .arc,
      .inactive .arc-current {
        opacity: 0;
      }

      .value {
        stroke: var(--control-circular-slider-color);
      }

      .low {
        stroke: var(--control-circular-slider-low-color);
      }

      .high {
        stroke: var(--control-circular-slider-high-color);
      }
    `;
  }
}

declare global {
  interface HTMLElementTagNameMap {
    "ha-control-circular-slider": HaControlCircularSlider;
  }
}
