import {IMethodResult} from '@jacquesparis/objects-model';
import {service} from '@loopback/core';
import {isArray} from 'lodash';
import {ApplicationError} from '../../helper';
import {EntityName, ObjectNode, ObjectTree} from '../../models';
import {
  CurrentContext,
  InsideRestService,
  ObjectNodeService,
  ObjectTreeService,
  ObjectTypeService,
} from '../../services';
import {ActionEntityService} from '../../services/action-entity/action-entity.service';
import {TransientEntityService} from '../../services/transient-entity/transient-entity.service';
import {UriCompleteService} from '../../services/uri-complete/uri-complete.service';
import {
  LOCATABLE_ITEMS_HOLDER_TYPE,
  LOCATION_PROVIDER,
  LOCATION_REFERENCE_TYPE,
} from './location.const';

export interface LocationServiceContributor {
  locatableItems?: {
    objectType: string;
    entityType: EntityName;
    treeItemsField: string;
    treeItemsConditionField?: string;
    locationField: string;
  }[];
  locationReferences?: {
    objectType: string;
    locationConditionField?: string;
    locationField: string;
  }[];
}
export class LocationService {
  private locatableItems: {
    objectType: string;
    entityType: EntityName;
    treeItemsConditionField?: string;
    treeItemsField: string;
    locationField: string;
  }[] = [];
  private locationReferences: {
    objectType: string;
    locationConditionField?: string;
    locationField: string;
  }[] = [];
  constructor(
    @service(ActionEntityService)
    private actionEntityService: ActionEntityService,
    @service(ObjectTreeService) private objectTreeService: ObjectTreeService,
    @service(ObjectNodeService) private objectNodeService: ObjectNodeService,
    @service(ObjectTypeService) private objectTypeService: ObjectTypeService,
    @service(TransientEntityService)
    private transientEntityService: TransientEntityService,
    @service(InsideRestService) private insideRestService: InsideRestService,
    @service(UriCompleteService) private uriCompleteService: UriCompleteService,
  ) {
    this.actionEntityService.registerNewMethodFunction<ObjectNode>(
      LOCATION_PROVIDER,
      LocationService.name,
      'Propagate location to resources',
      EntityName.objectNode,
      'propagateLocation',
      LOCATABLE_ITEMS_HOLDER_TYPE.name,
      this.propagateLocation.bind(this),
      'update',
    );
    this.actionEntityService.registerNewMethodFunction<ObjectNode>(
      LOCATION_PROVIDER,
      LocationService.name,
      'Set resources location',
      EntityName.objectNode,
      'setResourcesLocation',
      LOCATABLE_ITEMS_HOLDER_TYPE.name,
      this.setResourcesLocation.bind(this),
      'update',
    );

    this.transientEntityService.registerTransientEntityTypeFunction(
      LOCATION_PROVIDER,
      LocationService.name,
      //      'Complete paragraphsTemplateChoice json schema definition with paragraphsTemplateChoice from referenced template and add its conditional paragraph template configuration',
      'Complete locatable items holder with propagate location method',
      EntityName.objectNode,
      LOCATABLE_ITEMS_HOLDER_TYPE.name,
      this.completeLocatableItemsHolderNode.bind(this),
    );

    this.actionEntityService.registerNewMethodFunction<ObjectNode>(
      LOCATION_PROVIDER,
      LocationService.name,
      'Update location from resources location',
      EntityName.objectNode,
      'setLocationReference',
      LOCATABLE_ITEMS_HOLDER_TYPE.name,
      this.setLocationReference.bind(this),
      'update',
    );

    this.transientEntityService.registerTransientEntityTypeFunction(
      LOCATION_PROVIDER,
      LocationService.name,
      //      'Complete paragraphsTemplateChoice json schema definition with paragraphsTemplateChoice from referenced template and add its conditional paragraph template configuration',
      'Complete location reference holder with set location reference method',
      EntityName.objectNode,
      LOCATION_REFERENCE_TYPE.name,
      this.completeLocationReferenceNode.bind(this),
    );
  }

  private findMedian(inputArray: number[]): number {
    inputArray.sort();
    const inputLength = inputArray.length;
    const middleIndex = Math.floor(inputLength / 2);
    const oddLength = inputLength % 2 !== 0;
    let median;
    if (oddLength) {
      // if array length is odd -> return element at middleIndex
      median = inputArray[middleIndex];
    } else {
      median = (inputArray[middleIndex] + inputArray[middleIndex - 1]) / 2;
    }
    return median;
  }

  public async setLocationReference(
    objectNode: ObjectNode,
    args: {},
    ctx: CurrentContext,
  ): Promise<IMethodResult> {
    const locationReferenceFields = await this.getLocationReferenceFields(
      objectNode,
      ctx,
    );

    if (0 === locationReferenceFields.length) {
      throw ApplicationError.incompatible({
        method: 'setLocationReference',
        objectType: objectNode.objectTypeId,
      });
    }

    const objectTree = await this.objectTreeService.loadTree(
      objectNode.id as string,
      ctx,
    );
    const foundLocations = await this.getExistingLocations(objectTree, ctx);

    if (0 === foundLocations.length) {
      throw ApplicationError.notFound({[objectNode.title]: 'localized items'});
    }

    const locations: [number, number][] = foundLocations
      .map((location) => location.split(','))
      .filter((locationParts) => 2 === locationParts.length)
      .map((locationParts) => [
        parseFloat(locationParts[0]),
        parseFloat(locationParts[1]),
      ]);

    if (0 === locations.length) {
      throw ApplicationError.notFound({[objectNode.title]: 'localized items'});
    }

    const newLocation =
      '' +
      this.findMedian(locations.map((parts) => parts[0])) +
      ',' +
      this.findMedian(locations.map((parts) => parts[1]));

    const nodeUpdate: Partial<ObjectNode> = {};
    for (const locationReferenceField of locationReferenceFields) {
      nodeUpdate[locationReferenceField.locationField] = newLocation;
      if (
        locationReferenceField.locationConditionField &&
        !nodeUpdate[locationReferenceField.locationConditionField]
      ) {
        nodeUpdate[locationReferenceField.locationConditionField] =
          objectNode.title;
      }
    }
    return await this.objectNodeService.modifyById(
      objectNode.id as string,
      nodeUpdate,
      ctx,
      true,
    );
  }

  private async getExistingLocations(
    objectTree: ObjectTree,
    ctx: CurrentContext,
    foundLocations: string[] = [],
  ): Promise<string[]> {
    const locatableItems = await this.getLocatableItems(
      objectTree.treeNode,
      ctx,
    );

    for (const localizedItem of locatableItems.fillPositions) {
      foundLocations.push(
        localizedItem.objectNode[localizedItem.locationField],
      );
    }

    if (isArray(objectTree.children)) {
      for (const subTree of objectTree.children) {
        const subTreePosition = await this.getPositionFromNode(
          subTree.treeNode,
          ctx,
        );
        if (!subTreePosition) {
          await this.getExistingLocations(subTree, ctx, foundLocations);
        }
      }
    }

    return foundLocations;
  }

  async completeLocationReferenceNode(
    objectNode: ObjectNode,
    ctx: CurrentContext,
  ): Promise<void> {
    const locationReferenceFields = await this.getLocationReferenceFields(
      objectNode,
      ctx,
    );

    if (0 < locationReferenceFields.length) {
      if (!objectNode.entityCtx) {
        objectNode.entityCtx = {entityType: EntityName.objectNode};
      }
      if (!objectNode.entityCtx?.actions) {
        objectNode.entityCtx.actions = {};
      }
      if (!objectNode.entityCtx.actions.methods) {
        objectNode.entityCtx.actions.methods = [];
      }

      objectNode.entityCtx.actions.methods.push({
        methodId: 'setLocationReference',
        methodName: 'Localize this event from images geolocation',
        actionName: 'Set location',
        parameters: {
          properties: {},
        },
        icon: 'far fa-map',
      });
    }
  }

  public registerLocationProvider(contributor: LocationServiceContributor) {
    if (contributor.locatableItems) {
      this.locatableItems.push(...contributor.locatableItems);
    }
    if (contributor.locationReferences) {
      this.locationReferences.push(...contributor.locationReferences);
    }
  }

  async completeLocatableItemsHolderNode(
    objectNode: ObjectNode,
    ctx: CurrentContext,
  ): Promise<void> {
    if (!objectNode.entityCtx) {
      objectNode.entityCtx = {entityType: EntityName.objectNode};
    }
    if (!objectNode.entityCtx?.actions) {
      objectNode.entityCtx.actions = {};
    }
    if (!objectNode.entityCtx.actions.methods) {
      objectNode.entityCtx.actions.methods = [];
    }

    const hasLocation = !!(await this.getPositionFromNode(objectNode, ctx));

    if (hasLocation) {
      objectNode.entityCtx.actions.methods.push({
        methodId: 'propagateLocation',
        methodName: 'Propagate location to images',
        actionName: 'Propagate location',
        parameters: {
          properties: {
            forced: {
              title: 'replace any existing position',
              type: 'boolean',
            },
            recursively: {
              title: 'propagate recursively any known event position',
              type: 'boolean',
            },
          },
        },
        icon: 'far fa-map',
      });
    } else {
      objectNode.entityCtx.actions.methods.push({
        methodId: 'setResourcesLocation',
        methodName: 'Set images location',
        actionName: 'Set location',
        parameters: {
          properties: {
            locationPosition: {
              title: 'Location position',
              type: 'string',
              'x-schema-form': {
                type: 'position',
              },
            },
            forced: {
              title: 'replace any existing position',
              type: 'boolean',
            },
            recursively: {
              title: 'propagate recursively any known event position',
              type: 'boolean',
            },
          },
        },
        icon: 'far fa-map',
      });
    }
  }

  private async setPosition(
    objectNode: ObjectNode,
    position: string | undefined,
    forced: boolean,
    recursively: boolean,
    ctx: CurrentContext,
  ) {
    const objectTree = await this.objectTreeService.loadTree(
      objectNode.id as string,
      ctx,
    );
    await this.setPositions(objectTree, position, forced, recursively, ctx);
  }

  private async getLocatableItems(
    objectNode: ObjectNode,
    ctx: CurrentContext,
  ): Promise<{
    emptyPositions: {locationField: string; objectNode: ObjectNode}[];
    fillPositions: {locationField: string; objectNode: ObjectNode}[];
  }> {
    const result: {
      emptyPositions: {locationField: string; objectNode: ObjectNode}[];
      fillPositions: {locationField: string; objectNode: ObjectNode}[];
    } = {emptyPositions: [], fillPositions: []};
    const implementedTypes = await this.objectTypeService.getImplementedTypes(
      objectNode.objectTypeId,
    );
    for (const locatableItem of this.locatableItems) {
      if (implementedTypes.includes(locatableItem.objectType)) {
        if (locatableItem.treeItemsConditionField) {
          if (
            !isArray(objectNode[locatableItem.treeItemsConditionField]) ||
            0 === objectNode[locatableItem.treeItemsConditionField].length
          ) {
            break;
          }
        }
        if (locatableItem.treeItemsField) {
          const tree = await this.insideRestService.read(
            this.uriCompleteService.getUri(
              locatableItem.entityType,
              objectNode.id as string,
              ctx,
            ),
            ctx,
          );
          if (isArray(tree[locatableItem.treeItemsField])) {
            for (const resourceTree of tree[locatableItem.treeItemsField]) {
              if (
                resourceTree.treeNode &&
                !resourceTree.treeNode[locatableItem.locationField]
              ) {
                result.emptyPositions.push({
                  locationField: locatableItem.locationField,
                  objectNode: resourceTree.treeNode,
                });
              } else if (resourceTree.treeNode) {
                result.fillPositions.push({
                  locationField: locatableItem.locationField,
                  objectNode: resourceTree.treeNode,
                });
              }
            }
          }
        }
      }
    }
    return result;
  }

  private async setPositions(
    objectTree: ObjectTree,
    position: string | undefined,
    forced: boolean,
    recursively: boolean,
    ctx: CurrentContext,
  ) {
    if (position) {
      const locatableItems = await this.getLocatableItems(
        objectTree.treeNode,
        ctx,
      );
      for (const locatableItem of locatableItems.emptyPositions) {
        await this.objectNodeService.modifyById(
          locatableItem.objectNode.id as string,
          {
            [locatableItem.locationField]: position,
          },
          CurrentContext.get(ctx),
          true,
        );
      }
      if (forced) {
        for (const locatableItem of locatableItems.fillPositions) {
          await this.objectNodeService.modifyById(
            locatableItem.objectNode.id as string,
            {
              [locatableItem.locationField]: position,
            },
            CurrentContext.get(ctx),
            true,
          );
        }
      }
    }

    if (isArray(objectTree.children)) {
      for (const subTree of objectTree.children) {
        const subTreePosition = await this.getPositionFromNode(
          subTree.treeNode,
          ctx,
        );
        if (!subTreePosition) {
          await this.setPositions(subTree, position, forced, recursively, ctx);
        } else if (recursively) {
          await this.setPositions(
            subTree,
            subTreePosition,
            forced,
            recursively,
            ctx,
          );
        }
      }
    }
  }

  public async setResourcesLocation(
    objectNode: ObjectNode,
    args: {locationPosition?: string; forced?: boolean; recursively?: boolean},
    ctx: CurrentContext,
  ): Promise<IMethodResult> {
    if (!args.locationPosition && !args.recursively) {
      throw ApplicationError.missingParameter('locationPosition');
    }
    await this.setPosition(
      objectNode,
      args.locationPosition,
      !!args.forced,
      !!args.recursively,
      ctx,
    );
  }

  private async getLocationReferenceFields(
    objectNode: ObjectNode,
    ctx: CurrentContext,
  ): Promise<{locationField: string; locationConditionField?: string}[]> {
    const result: {
      locationField: string;
      locationConditionField?: string;
    }[] = [];
    const implementedTypes = await this.objectTypeService.getImplementedTypes(
      objectNode.objectTypeId,
    );

    for (const locationReference of this.locationReferences) {
      if (implementedTypes.includes(locationReference.objectType)) {
        result.push({
          locationField: locationReference.locationField,
          locationConditionField: locationReference.locationConditionField,
        });
      }
    }
    return result;
  }

  private async getPositionFromNode(
    objectNode: ObjectNode,
    ctx: CurrentContext,
  ): Promise<string | undefined> {
    const locationReferenceFields = await this.getLocationReferenceFields(
      objectNode,
      ctx,
    );
    let locationPosition: string | undefined = undefined;
    for (const locationreference of locationReferenceFields) {
      if (
        locationreference.locationField in objectNode &&
        !!objectNode[locationreference.locationField] &&
        (!locationreference.locationConditionField ||
          !!objectNode[locationreference.locationConditionField])
      ) {
        locationPosition = objectNode[locationreference.locationField];
        break;
      }
    }
    return locationPosition;
  }

  public async propagateLocation(
    objectNode: ObjectNode,
    args: {forced?: boolean; recursively?: boolean},
    ctx: CurrentContext,
  ): Promise<IMethodResult> {
    const locationPosition = await this.getPositionFromNode(objectNode, ctx);
    if (!locationPosition && !args.recursively) {
      throw ApplicationError.missingParameter('locationPosition');
    }
    await this.setPosition(
      objectNode,
      locationPosition,
      !!args.forced,
      !!args.recursively,
      ctx,
    );
  }
}
