import {
  type CollectionKind,
  SetterObserver,
  subscriberCollection,
  type AccessorType,
  type ICollectionObserver,
  type ISubscriberCollection,
  type IObserverLocator,
} from '@aurelia/runtime';
import { getCollectionObserver, INodeObserver, INodeObserverConfigBase } from './observer-locator';
import { atLayout, atNode, atObserver, hasOwnProperty } from '../utilities';

import type { INode } from '../dom';
import type { ValueAttributeObserver } from './value-attribute-observer';
import { mixinNodeObserverUseConfig } from './observation-utils';
import { isArray } from '@aurelia/kernel';

export interface IInputElement extends HTMLInputElement {
  model?: unknown;
  $observers?: {
    model?: SetterObserver;
    value?: ValueAttributeObserver;
  };
  matcher?: typeof defaultMatcher;
}

function defaultMatcher(a: unknown, b: unknown): boolean {
  return a === b;
}

export interface CheckedObserver extends
  ISubscriberCollection { }

export class CheckedObserver implements INodeObserver {
  static {
    mixinNodeObserverUseConfig(CheckedObserver);
    subscriberCollection(CheckedObserver, null!);
  }

  public type: AccessorType = (atNode | atObserver | atLayout) as AccessorType;

  /** @internal */
  private _value: unknown = void 0;

  /** @internal */
  private _oldValue: unknown = void 0;

  /** @internal */
  public readonly _el: IInputElement;

  /** @internal */
  private _collectionObserver?: ICollectionObserver<CollectionKind> = void 0;

  /**
   * There' situation when a checked observers is used together with `model.bind` on the checkbox
   *
   * Then this checked box also needs to observe the changes of tht model so that it will be able to referesh the value accordingly
   *
   * @internal
   */
  private _valueObserver?: ValueAttributeObserver | SetterObserver = void 0;

  /** @internal */
  private readonly oL: IObserverLocator;

  /**
   * Used by mixing defined methods subscribe/unsubscribe
   *
   * @internal
   */
  public _listened: boolean = false;

  /** @internal */
  public _config: INodeObserverConfigBase;

  /**
   * Comes from mixin
   */
  public useConfig!: (config: INodeObserverConfigBase) => void;

  public constructor(
    obj: INode,
    // deepscan-disable-next-line
    _key: PropertyKey,
    config: INodeObserverConfigBase,
    observerLocator: IObserverLocator,
  ) {
    this._el = obj as IInputElement;
    this.oL = observerLocator;
    this._config = config;
  }

  public getValue(): unknown {
    return this._value;
  }

  public setValue(newValue: unknown): void {
    const currentValue = this._value;
    if (newValue === currentValue) {
      return;
    }
    this._value = newValue;
    this._oldValue = currentValue;
    this._observe();
    this._synchronizeElement();
    this._flush();
  }

  public handleCollectionChange(): void {
    this._synchronizeElement();
  }

  public handleChange(_newValue: unknown, _previousValue: unknown): void {
    this._synchronizeElement();
  }

  /** @internal */
  private _synchronizeElement(): void {
    const currentValue = this._value;
    const obj = this._el;
    const elementValue = hasOwnProperty.call(obj, 'model') ? obj.model : obj.value;
    const isRadio = obj.type === 'radio';
    const matcher = obj.matcher !== void 0 ? obj.matcher : defaultMatcher;

    if (isRadio) {
      obj.checked = !!matcher(currentValue, elementValue);
    } else if (currentValue === true) {
      obj.checked = true;
    } else {
      let hasMatch = false;
      if (isArray(currentValue)) {
        hasMatch = currentValue.findIndex(item => !!matcher(item, elementValue)) !== -1;
      } else if (currentValue instanceof Set) {
        for (const v of currentValue) {
          if (matcher(v, elementValue)) {
            hasMatch = true;
            break;
          }
        }
      } else if (currentValue instanceof Map) {
        for (const pair of currentValue) {
          const existingItem = pair[0];
          const $isChecked = pair[1];
          // a potential complain, when only `true` is supported
          // but it's consistent with array
          if (matcher(existingItem, elementValue) && $isChecked === true) {
            hasMatch = true;
            break;
          }
        }
      }
      obj.checked = hasMatch;
    }
  }

  public handleEvent(): void {
    let currentValue = this._oldValue = this._value;
    const obj = this._el;
    const elementValue = hasOwnProperty.call(obj, 'model') ? obj.model : obj.value;
    const isChecked = obj.checked;
    const matcher = obj.matcher !== void 0 ? obj.matcher : defaultMatcher;

    if (obj.type === 'checkbox') {
      if (isArray(currentValue)) {
        // Array binding steps on a change event:
        // 1. find corresponding item INDEX in the Set based on current model/value and matcher
        // 2. is the checkbox checked?
        //    2.1. Yes: is the corresponding item in the Array (index === -1)?
        //        2.1.1 No: push the current model/value to the Array
        //    2.2. No: is the corresponding item in the Array (index !== -1)?
        //        2.2.1: Yes: remove the corresponding item
        // =================================================
        const index = currentValue.findIndex(item => !!matcher(item, elementValue));

        // if the checkbox is checkde, and there's no matching value in the existing array
        // add the checkbox model/value to the array
        if (isChecked && index === -1) {
          currentValue.push(elementValue);
        } else if (!isChecked && index !== -1) {
          // if the checkbox is not checked, and found a matching item in the array
          // based on the checkbox model/value
          // remove the existing item
          currentValue.splice(index, 1);
        }
        // when existing currentValue is an array,
        // do not invoke callback as only the array obj has changed
        return;
      } else if (currentValue instanceof Set) {
        // Set binding steps on a change event:
        // 1. find corresponding item in the Set based on current model/value and matcher
        // 2. is the checkbox checked?
        //    2.1. Yes: is the corresponding item in the Set?
        //        2.1.1 No: add the current model/value to the Set
        //    2.2. No: is the corresponding item in the Set?
        //        2.2.1: Yes: remove the corresponding item
        // =================================================

        // 1. find corresponding item
        const unset = {};
        let existingItem: unknown = unset;
        for (const value of currentValue) {
          if (matcher(value, elementValue) === true) {
            existingItem = value;
            break;
          }
        }
        // 2.1. Checkbox is checked, is the corresponding item in the Set?
        //
        // if checkbox is checked and there's no value in the existing Set
        // add the checkbox model/value to the Set
        if (isChecked && existingItem === unset) {
          // 2.1.1. add the current model/value to the Set
          currentValue.add(elementValue);
        } else if (!isChecked && existingItem !== unset) {
          // 2.2.1 Checkbox is unchecked, corresponding is in the Set
          //
          // if checkbox is not checked, and found a matching item in the Set
          // based on the checkbox model/value
          // remove the existing item
          currentValue.delete(existingItem);
        }
        // when existing value is a Set,
        // do not invoke callback as only the Set has been mutated
        return;
      } else if (currentValue instanceof Map) {
        // Map binding steps on a change event
        // 1. find corresponding item in the Map based on current model/value and matcher
        // 2. Set the value of the corresponding item in the Map based on checked state of the checkbox
        // =================================================

        // 1. find the corresponding item
        let existingItem: unknown;
        for (const pair of currentValue) {
          const currItem = pair[0];
          if (matcher(currItem, elementValue) === true) {
            existingItem = currItem;
            break;
          }
        }

        // 2. set the value of the corresponding item in the map
        // if checkbox is checked and there's no value in the existing Map
        // add the checkbox model/value to the Map as key,
        // and value will be checked state of the checkbox
        currentValue.set(existingItem, isChecked);
        // when existing value is a Map,
        // do not invoke callback as only the Map has been mutated
        return;
      }
      currentValue = isChecked;
    } else if (isChecked) {
      currentValue = elementValue;
    } else {
      // if it's a radio and it has been unchecked
      // do nothing, as the radio that was checked will fire change event and it will be handle there
      // a radio cannot be unchecked by user
      return;
    }
    this._value = currentValue;
    this._flush();
  }

  /**
   * Used by mixing defined methods subscribe
   *
   * @internal
   */
  public _start() {
    this._observe();
  }

  /**
   * Used by mixing defined methods unsubscribe
   *
   * @internal
   */
  public _stop(): void {
    this._value = this._oldValue = void 0;
    this._collectionObserver?.unsubscribe(this);
    this._valueObserver?.unsubscribe(this);
    this._collectionObserver = this._valueObserver = void 0;
  }

  /** @internal */
  private _flush(): void {
    oV = this._oldValue;
    this._oldValue = this._value;
    this.subs.notify(this._value, oV);
    oV = void 0;
  }

  /** @internal */
  private _observe() {
    const obj = this._el;

    (this._valueObserver ??= obj.$observers?.model ?? obj.$observers?.value)?.subscribe(this);

    this._collectionObserver?.unsubscribe(this);
    this._collectionObserver = void 0;

    if (obj.type === 'checkbox') {
      (this._collectionObserver = getCollectionObserver(this._value, this.oL))?.subscribe(this);
    }
  }
}

// a reusable variable for `.flush()` methods of observers
// so that there doesn't need to create an env record for every call
let oV: unknown = void 0;
