/*
 ecsjs is an entity component system library for JavaScript
 Copyright (C) 2014 Peter Flannery

 This program is free software: you can redistribute it and/or modify
 it under the terms of the GNU Affero General Public License as
 published by the Free Software Foundation, either version 3 of the
 License, or (at your option) any later version.

 This program is distributed in the hope that it will be useful,
 but WITHOUT ANY WARRANTY; without even the implied warranty of
 MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
 GNU Affero General Public License for more details.

 You should have received a copy of the GNU Affero General Public License
 along with this program.  If not, see <http://www.gnu.org/licenses/>.
 */
import { ComponentClassesMap, ComponentMap } from './component-map.js';
import { ComponentNotRegistered, ComponentTypeKeyMissing } from './errors.js';
import { ComponentIterator } from './iterators.js';
import {
  ComponentClassesMapKey,
  ComponentMapKey,
  type Component,
  type ComponentClass,
  type ComponentInstances
} from './types.js';

/**
 * Class for storing entities and their relationships
 * @category Maps
 */
export class EntityMap {
  /**
   * Registered component classes that contain the component instance data
   */
  public components = new ComponentClassesMap()

  private nextId: number = 0

  /**
   * Registers component classes with the {@link EntityMap}
   * @throwsError {@link ComponentTypeKeyMissing} when the specified component type is missing a 'name' parameter
   * @example
   * // component class
   * class MyComponent {
   *   constructor(x) {
   *     this.x = x;
   *   }
   * }
   *
   * ecs.register(MyComponent);
   * // or mulitple
   * ecs.register(MyComponent1, MyComponent2);
   */
  register<TComponentClasses extends ComponentClass<any>[]>(...componentClasses: TComponentClasses) {
    for (const componentClass of componentClasses) {
      const componentName = componentClass.name;
      if (componentName === undefined) throw new ComponentTypeKeyMissing()

      // create the component map
      const componentDataMap: ComponentMap<any> = new ComponentMap();
      this.components.set(componentName, componentDataMap);
    }

    // chain
    return this;
  }

  /**
   * Gets a component class map
   * @throwsError {@link ComponentNotRegistered} when the specified component is not registered
   * @example
   * const positionMap = ecs.getMap(Position)
   * for(const [entityId, position] of positionMap) {
   *   position.x += 1
   * }
   */
  getMap<T>(component: ComponentClass<T>): ComponentMap<T> | undefined {
    const map = this.components.get(component.name);
    if (map === undefined) throw new ComponentNotRegistered(component.name)
    return map
  }

  /**
   * Gets the first entity entry for a component class
   * @throwsError {@link ComponentNotRegistered} when the specified component is not registered
   * @example
   * const [entityId, player] = ecs.firstEntry(Player) ?? []
   */
  firstEntry<TComponent>(component: ComponentClass<TComponent>) {
    return this.getMap(component)?.firstEntry();
  }

  /**
   * Gets the first entity id for a component class 
   * and optionally any related component data
   * @throwsError {@link ComponentNotRegistered} when any of specified component(s) are not registered
   * @example
   * // return the first entity id
   * const entityId = ecs.firstKey(Player)
   * 
   * // or return multiple related component in addition to entity id
   * const [entityId, position, direction] = ecs.firstKey(
   *   Player,
   *   Position,
   *   Direction
   * ) ?? []
   */
  firstKey<TKey extends ComponentClass<any>, T extends ComponentClass<any>[]>(
    keyComponent: TKey,
    ...components: T
  ): ComponentInstances<[ComponentClass<number>, ...T]> | undefined;
  firstKey(keyComponent: ComponentClass<any>, ...components: ComponentClass<any>[]) {
    const entityId = this.getMap(keyComponent)?.firstKey();

    // single component key
    if (arguments.length === 1) return entityId;
    if (entityId === undefined) return undefined;

    // attach multiple related component values
    return [entityId, ...components.map(x => this.getEntity(entityId, x))];
  }

  /**
   * Gets the first entity component data for a component class
   * and optionally any related component data
   * @throwsError {@link ComponentNotRegistered} when any of specified component(s) are not registered
   * @example
   * // return the first component value
   * const player = ecs.firstValue(Player)
   * 
   * // or multiple related values in addition to the first component
   * const [player, position, direction] = ecs.firstValue(
   *   Player,
   *   Position,
   *   Direction
   * )
   */
  firstValue<TKey extends ComponentClass<any>, T extends ComponentClass<any>[]>(
    keyComponent: TKey,
    ...components: T
  ): ComponentInstances<[TKey, ...T]> | undefined;
  firstValue(keyComponent: ComponentClass<any>, ...components: ComponentClass<any>[]) {
    // single component
    if (arguments.length === 1) return this.getMap(keyComponent)?.firstValue();

    // get the first entry
    const [entityId, value] = this.getMap(keyComponent)?.firstEntry() ?? [];
    if (entityId === undefined) return undefined;

    // attach multiple related components
    return [value, ...components.map(x => this.getEntity(entityId, x))]
  }

  /**
   * @throwsError {@link ComponentNotRegistered} when the specified component is not registered 
   */
  private getEntity<T>(entityId: number, component: ComponentClass<T>): T | undefined {
    const map = this.components.get(component.name);
    if (map === undefined) throw new ComponentNotRegistered(component.name)

    return map.get(entityId);
  }

  /**
   * Gets component values related to an entity id
   * @throwsError {@link ComponentNotRegistered} when any of specified component(s) are not registered
   * @example
   * // get one
   * const player = ecs.get(entityId, Player)
   * 
   * // or get multiple
   * const [player, position] = ecs.get(entityId, Player, Position) ?? []
   */
  get<T extends ComponentClass<any>[]>(entityId: number, ...components: T): ComponentInstances<T> | undefined;
  get<T extends Component>(entityId: number, ...components: ComponentClass<T>[]): T | (T | undefined)[] | undefined {
    if (components.length > 1) return components.map(x => this.getEntity(entityId, x))

    // return a single component
    return this.getEntity(entityId, components[0])
  }

  /**
   * Check if a component exists for an entity
   * @example
   * const exists = ecs.has(entityId, Position)
   */
  has<T>(entityId: number, component: ComponentClass<T>): boolean {
    // get the component map
    const map = this.components.get(component.name);
    if (map === undefined) return false

    return map.has(entityId);
  }

  /**
   * Checks if all of the specified components exist for an entity
   * @example
   * const hasAll = ecs.hasAll(entityId, Position, Velocity)
   */
  hasAll<T extends ComponentClass<any>[]>(entityId: number, ...components: T): boolean {
    for (let index = 0; index < components.length; index++) {
      const component = components[index];
      const map = this.components.get(component.name);
      if (map === undefined) return false;
      if (map.has(entityId) === false) return false;
    }
    return true
  }

  /**
   * Checks if any of the specified components exist for an entity
   * @example
   * const hasAny = ecs.hasAny(entityId, Position, Velocity)
   */
  hasAny<T extends ComponentClass<any>[]>(entityId: number, ...components: T): boolean {
    for (let index = 0; index < components.length; index++) {
      const component = components[index];
      const map = this.components.get(component.name);
      if (map === undefined) continue;
      if (map.has(entityId)) return true;
    }
    return false
  }

  private setEntity<T extends Component>(entityId: number, componentData: T): T {
    // get the component map
    const map = this.components.get(componentData.constructor.name);
    if (map === undefined) throw new ComponentNotRegistered(componentData.constructor.name)

    // set the entity on the entity map
    map.set(entityId, componentData);

    // return instance
    return componentData;
  }

  /**
   * Add or update multiple component values for an entity
   * @example
   * // set one
   * const player = ecs.set(entityId, new Player());
   * 
   * // or set multiple
   * const [player, position] = ecs.set(
   *   entityId,
   *   new Player(),
   *   new Position()
   * );
   */
  set<T extends Component>(entityId: number, component: T): T;
  set<T extends Component[]>(entityId: number, ...components: T): T;
  set(entityId: number, ...components: Component[]): Component | Component[] {
    if (components.length > 1) return components.map(x => this.setEntity(entityId, x))

    // set and return a single component
    return this.setEntity(entityId, components[0])
  }

  /**
   * Removes the specified component(s) from an entity
   * @example
   * ecs.remove(entityId, Position);
   */
  remove<T extends ComponentClass<any>[]>(entityId: number, ...components: T) {
    for (const component of components) {
      this.removeByKey(entityId, component.name)
    }
  }

  /**
   * Removes the specified component from an entity
   * @throwsError {@link ComponentNotRegistered} when the specified component is not registered
   * @example
   * ecs.removeByKey(entityId, "Position");
   */
  removeByKey(entityId: number, componentName: string) {
    // get the entity map
    const entityMap = this.components.get(componentName);

    // ensure the map is defined
    if (entityMap === undefined) throw new ComponentNotRegistered(componentName);

    // get the entity
    const entity = entityMap.get(entityId);
    if (entity === undefined) return false;

    // remove the entity from the entity map
    return entityMap.delete(entityId);
  }

  /**
   * Deletes all components from an entity
   * @example
   * ecs.destroyEntity(entityId1)
   * 
   * // or multiple
   * ecs.destroyEntity(entityId1, entityId2)
   */
  destroyEntity(...entityIds: number[]) {
    for (let index = 0; index < entityIds.length; index++) {
      const entityId = entityIds[index];
      for (const map of this.components.values()) {
        if (map.has(entityId)) map.delete(entityId);
      }
    }
  }

  // TODO create id generator
  /**
   * Creates a new entity id for the EntityMap
   * @example
   * const newEntityId = ecs.getNextId()
   * ecs.set(newEntityId, new Player())
   */
  getNextId(): number {
    this.nextId++;
    return this.nextId;
  }

  /**
   * Clears all registered components
   */
  clear() {
    this.components.clear();
    return this;
  }

  /**
   * Clears all component data
   */
  clearComponents() {
    this.components.forEach(x => x.clear())
    return this;
  }

  /**
   * Iterates over each component value that is related to the key component
   * @throwsError {@link ComponentNotRegistered} when any of specified component(s) are not registered
   * @example
   * // iterate each component value that is related to the Player entity
   * const iterator = ecs.iterator(Player, Position)
   * 
   * for(const [playerId, player, position] of iterator) { }
   * 
   * // you can also declare the type of iterator before it's assigned
   * let iterator: IComponentIterator<[Player, Position]>
   * 
   * // then with late bound assignment (keeping the iterator intellisense)
   * iterator = ecs.iterator(Player, Position)
   * 
   * for(const [playerId, player, position] of iterator) {
   *    const moving = player.isMoving
   * }
   */
  iterator<K extends ComponentClass<any>, T extends ComponentClass<any>[]>(
    keyComponent: K,
    ...components: T
  ): ComponentIterator<K, T>
  iterator(keyComponent: ComponentClass<any>, ...components: ComponentClass<any>[]) {
    return new ComponentIterator(this, keyComponent, ...components) as any;
  }

  /**
   * Prints all component maps in a tabular format to the console
   */
  printTable() {
    this.components.forEach(map => console.table(map.toTable(true)));
    return this;
  }

  /**
   * Prints all component data for the specified entity id in a tabular format to the console
   */
  printEntity(entityId: number) {
    for (const map of this.components.values()) {
      if (map.has(entityId) === false) continue;

      const data = map.get(entityId);
      const columns = {
        name: data.constructor.name,
        ...data
      };
      console.table({ [entityId]: columns });
    }

    return this;
  }

  /**
   * Parse's the JSON and returns an EntityMap object
   * @example
   * const json = JSON.stringfy(ecs);
   * const restoredMap = ecs.parse(json);
   */
  static parse(json: string): EntityMap {
    const restored = JSON.parse(json, function (key: string, value: any) {
      if (value.hasOwnProperty('components')) {
        Reflect.setPrototypeOf(value, EntityMap.prototype);
        return value;
      }
      if (value.hasOwnProperty(ComponentMapKey)) return new ComponentMap(value.iterable);
      if (value.hasOwnProperty(ComponentClassesMapKey)) return new ComponentClassesMap(value.iterable);
      return this[key];
    });
    return restored;
  }

  /**
   * A tracing method used for debugging.
   * Intercepts all functions specified and logs each call to the console.
   * @param {Array} funcFilter A list of function names you want to intercept. If no function names are specified then will log all functions called
   * @return {EntityMap} A new entity map with tracing enabled
   */
  static createWithTracing(funcFilter: any) {
    const traceHandler = {
      get(target: any, propKey: string) {
        const targetValue = target[propKey]

        if (typeof targetValue === 'function' && (funcFilter.length === 0 || funcFilter.includes(propKey))) {
          return function (this: any, ...args: any[]) {
            console.groupCollapsed('ecs trace', propKey, args);
            console.trace();
            console.groupEnd();
            return targetValue.apply(this, args);
          }
        }

        return targetValue;
      }
    }

    return new Proxy(new EntityMap(), traceHandler)
  }

}