import {
  Classification,
  deDuplicateList,
  EntityPositions,
  FieldId,
  FileFieldData,
  getDataKeyFromFieldType,
  IError,
  Message,
  Paragraph,
  ParagraphClassification,
  Resource,
  UserClassification,
  UserFieldMetadata,
  TokenAnnotation,
  DEFAULT_NER_KEY,
  ConversationFieldPages,
  longToShortFieldType,
  LabelSets,
  LinkFieldData,
  CloudLink,
  NestedPosition,
  ResourceField,
  ReadableResource,
} from '@nuclia/core';
import { SafeUrl } from '@angular/platform-browser';

export type Thumbnail = { uri: string; blob: SafeUrl };
export type EditResourceView = 'preview' | 'resource' | 'classification' | 'annotation' | 'add-field';

export interface ParagraphWithText extends Paragraph {
  paragraphId: string;
  text: string;
}

export interface ParagraphWithTextAndClassifications extends ParagraphWithText {
  // labels added to the paragraph by the user, as well as cancellation of backend labels
  userClassifications: UserClassification[];
  // labels generated by the backend which weren't cancelled by the user
  generatedClassifications: Classification[];
  // User and generated labels that are not cancelled
  activeClassifications: Classification[];
}

export interface ParagraphWithTextAndImage extends ParagraphWithTextAndClassifications {
  imagePath?: string;
}

export interface ParagraphWithTextAndAnnotations extends ParagraphWithText {
  annotations: EntityAnnotation[];
  annotatedText: string;
}

export interface EntityGroup {
  id: string;
  title: string;
  color: string;
  custom?: boolean;
  entities: string[];
}

export interface EntityAnnotation extends TokenAnnotation {
  family: string;
}

export const DATA_AUGMENTATION_ERROR = 'DATAAUGMENTATION';

type ParagraphClassificationMap = { [paragraphId: string]: UserClassification[] };

export const getParagraphs = (fieldId: FieldId, resource: Resource): Paragraph[] => {
  const dataKey = getDataKeyFromFieldType(fieldId.field_type);
  if (!dataKey || !resource.data[dataKey]) {
    return [];
  }
  return resource.data[dataKey]?.[fieldId.field_id]?.extracted?.metadata?.metadata?.paragraphs || [];
};

export const getConversationParagraphs = (
  field: ResourceField,
  resource: Resource,
  messages: Message[],
): ParagraphWithTextAndClassifications[] => {
  // Resources that are arag sessions do not have extracted data.
  // Therefore, we need a create a resource with the complete data to be able to call "getParagraphText"
  const fullResource = new ReadableResource({ ...resource, data: { conversations: { [field.field_id]: field } } });

  // split_metadata is an object, its properties have no order. The message list is used to set the correct order.
  const metadata = field.extracted?.metadata;
  return Object.keys(metadata?.split_metadata || {})
    .filter((split) => messages.findIndex((item) => item.ident === split) >= 0)
    .sort((a, b) => messages.findIndex((item) => item.ident === a) - messages.findIndex((item) => item.ident === b))
    .reduce((acc, split) => {
      acc = acc.concat(
        (metadata?.split_metadata?.[split]?.paragraphs || []).map((paragraph) => ({
          ...paragraph,
          text: fullResource.getParagraphText(field.field_type, field.field_id, paragraph, split),
          paragraphId: getParagraphId(resource.id, field, paragraph),
          userClassifications: [],
          generatedClassifications: [],
          activeClassifications: [],
        })),
      );
      return acc;
    }, [] as ParagraphWithTextAndClassifications[]);
};

export function getErrors(fieldId: FieldId, resource: Resource): IError[] {
  const dataKey = getDataKeyFromFieldType(fieldId.field_type);
  if (!dataKey || !resource.data[dataKey]) {
    return [];
  }
  const fieldData = resource.data[dataKey]?.[fieldId.field_id];
  return fieldData?.errors || (fieldData?.error ? [fieldData.error] : []);
}

export function getFieldMetadataForClassifications(
  field: FieldId,
  paragraphs: ParagraphWithTextAndClassifications[],
  existingEntries: UserFieldMetadata[],
): UserFieldMetadata[] {
  const paragraphClassifications: ParagraphClassification[] = paragraphs
    .map((p) =>
      p.userClassifications.length > 0
        ? {
            key: p.paragraphId,
            classifications: p.userClassifications,
          }
        : null,
    )
    .filter((classification) => !!classification) as ParagraphClassification[];

  let existingField = false;
  const newEntries = existingEntries.map((entry) => {
    if (entry.field.field === field.field_id && entry.field.field_type === field.field_type) {
      existingField = true;
      return {
        ...entry,
        paragraphs: paragraphClassifications,
      };
    } else {
      return entry;
    }
  });

  if (!existingField) {
    newEntries.push({
      field: { field: field.field_id, field_type: field.field_type },
      paragraphs: paragraphClassifications,
    });
  }
  return newEntries;
}

export function addEntitiesToGroups(allGroups: EntityGroup[], entitiesMap: { [key: string]: string[] }) {
  Object.entries(entitiesMap).forEach(([groupId, entities]) => {
    const group = allGroups.find((g) => g.id === groupId);
    if (group) {
      entities.forEach((entity) => {
        if (!group.entities.includes(entity)) {
          group.entities.push(entity);
        }
      });
    }
  });
}

export function getGeneratedFieldAnnotations(
  resource: Resource,
  fieldId: FieldId,
  families: EntityGroup[],
): EntityAnnotation[] {
  const dataKey = getDataKeyFromFieldType(fieldId.field_type);
  const annotations: EntityAnnotation[] = [];
  if (dataKey && resource.data[dataKey]) {
    const positions: EntityPositions =
      resource.data[dataKey]?.[fieldId.field_id]?.extracted?.metadata?.metadata.positions || {};
    Object.entries(positions).forEach(([family, entityPosition]) => {
      const familyId = family.split('/')[0];
      const familyTitle = families.find((group) => group.id === familyId)?.title || '';
      entityPosition.position.forEach((position) =>
        annotations.push({
          klass: familyId,
          family: familyTitle,
          token: entityPosition.entity,
          start: position.start,
          end: position.end,
        }),
      );
    });
    Object.entries(resource.data[dataKey]?.[fieldId.field_id]?.extracted?.metadata?.metadata.entities || {})
      .filter(([key]) => key !== DEFAULT_NER_KEY)
      .forEach(([, entities]) => {
        entities.entities.forEach((entity) => {
          entity.positions.forEach((position) => {
            annotations.push({
              klass: entity.label,
              family: entity.label,
              token: entity.text,
              start: position.start,
              end: position.end,
            });
          });
        });
      });
  }
  return annotations;
}

export function isSameAnnotation(a: EntityAnnotation, b: EntityAnnotation) {
  return a.end === b.end && a.start === b.start && a.family === b.family && a.klass === b.klass && a.token === b.token;
}

export function getParagraphAnnotations(
  allAnnotations: EntityAnnotation[],
  paragraph: Paragraph,
  families: EntityGroup[],
) {
  const annotations: EntityAnnotation[] = allAnnotations
    .filter(
      (annotation) =>
        families.find((family) => family.id === annotation.klass) &&
        annotation.start >= (paragraph.start || 0) &&
        annotation.end <= (paragraph.end || 0),
    )
    .map((annotation) => ({
      ...annotation,
      start: annotation.start - (paragraph.start || 0),
      end: annotation.end - (paragraph.start || 0),
    }))
    .sort(sortByPosition);
  return annotations;
}

export function sortByPosition(a: EntityAnnotation, b: EntityAnnotation): number {
  if (a.start < b.start) {
    return -1;
  } else if (a.start > b.start) {
    return 1;
  } else {
    return 0;
  }
}

export function getHighlightedAnnotations(allAnnotations: EntityAnnotation[]): EntityAnnotation[] {
  return allAnnotations.reduce((list, annotation) => {
    if (!list.find((item) => isSameAnnotation(annotation, item))) {
      list.push(annotation);
    }
    return list;
  }, [] as EntityAnnotation[]);
}

export function getAnnotatedText(
  paragraphText: string,
  annotations: EntityAnnotation[],
  selectedFamily?: EntityGroup | null,
): string {
  let textWithMarks = '';
  let currentIndex = 0;
  // Annotations might overlap each other
  const markPositions = combineOverlaps(annotations);

  markPositions.forEach((position) => {
    let markText = sliceUnicode(paragraphText, position.start, position.end);
    if (selectedFamily) {
      const selectedAnnotations = annotations
        .filter((annotation) => annotation.klass === selectedFamily?.id)
        .filter((annotation) => annotation.start >= position.start && annotation.start < position.end);
      markText = getMarkWithSelectedAnnotations(paragraphText, position, selectedAnnotations, selectedFamily?.color);
    }
    textWithMarks += `${sliceUnicode(paragraphText, currentIndex, position.start)}<mark>${markText}</mark>`;
    currentIndex = position.end;
  });
  textWithMarks += sliceUnicode(paragraphText, currentIndex);
  return textWithMarks;
}

function getMarkWithSelectedAnnotations(
  paragraphText: string,
  position: { start: number; end: number },
  selectedAnnotations: EntityAnnotation[],
  color: string,
) {
  let text = '';
  let index = position.start;
  const highlightedStyle = `style="background-color:${color}"`;

  combineOverlaps(selectedAnnotations).forEach((selected) => {
    const selectedText = sliceUnicode(paragraphText, selected.start, selected.end);
    text += `${sliceUnicode(paragraphText, index, selected.start)}<span ${highlightedStyle}>${selectedText}</span>`;
    index = selected.end;
  });
  text += sliceUnicode(paragraphText, index, position.end);
  return text;
}

function combineOverlaps(annotations: EntityAnnotation[]) {
  return annotations.sort(sortByPosition).reduce(
    (acc, curr) => {
      const previous = acc[acc.length - 1];
      const isOverlapping = curr.start >= previous?.start && curr.start <= previous?.end;
      if (isOverlapping) {
        acc[acc.length - 1] = { start: previous.start, end: Math.max(previous.end, curr.end) };
      } else {
        acc.push({ start: curr.start, end: curr.end });
      }
      return acc;
    },
    [] as { start: number; end: number }[],
  );
}

/**
 * In JavaScript, '🤖'.length is 2, but all positions in API responses are based on Python and in Python len('🤖') is 1.
 * By converting the string to an array, we can get the correct length and slicing becomes consistent with the API
 * (because the array will split the string into characters, no matter how long they are)
 * @param str
 * @param start
 * @param end
 */
export const sliceUnicode = (str: string | string[] | undefined, start?: number, end?: number) => {
  if (!str) {
    return '';
  }
  if (!Array.isArray(str)) {
    str = Array.from(str);
  }
  return str.slice(start, end).join('');
};

export const getClassificationsPayload = (resource: Resource, labels: Classification[]): UserClassification[] => {
  const extracted = deDuplicateList(
    (resource.computedmetadata?.field_classifications || []).reduce((acc, field) => {
      return acc.concat(field.classifications || []);
    }, [] as Classification[]),
  );
  const userClassifications = labels.filter(
    (label) => !extracted.some((l) => l.labelset === label.labelset && l.label === label.label),
  );
  const cancellations = extracted
    .filter((label) => !labels.some((l) => l.labelset === label.labelset && l.label === label.label))
    .map((label) => ({ ...label, cancelled_by_user: true }));
  return [...userClassifications, ...cancellations];
};

export function getParagraphsWithImages(
  paragraphs: ParagraphWithTextAndClassifications[],
  fieldData: FileFieldData | LinkFieldData,
): ParagraphWithTextAndImage[] {
  const generatedFiles =
    fieldData.extracted && 'file' in fieldData.extracted
      ? (fieldData as FileFieldData).extracted?.file?.file_generated || {}
      : (fieldData as LinkFieldData).extracted?.link?.file_generated || {};
  const nestedListPosition =
    fieldData.extracted && 'file' in fieldData.extracted
      ? fieldData.extracted?.file?.nested_list_position
      : getLinkFilesPositions(paragraphs, generatedFiles);

  const imagePositions = Object.entries(nestedListPosition || {}).filter(
    ([filename]) => generatedFiles?.[filename]?.content_type?.startsWith('image/'),
  );
  if (imagePositions.length === 0) {
    return paragraphs;
  }
  return paragraphs.reduce((acc, paragraph, index, all) => {
    imagePositions.forEach(([filename, positions]) => {
      positions.positions.forEach((position) => {
        if ((position.start || 0) > (all[index - 1]?.end || 0) && (position.start || 0) <= (paragraph.end || 0)) {
          acc.push({
            text: '',
            paragraphId: `${filename}-${position.start}-${position.end}`,
            imagePath: generatedFiles?.[filename]?.uri || '',
            userClassifications: [],
            generatedClassifications: [],
            activeClassifications: [],
          });
        }
      });
    });
    acc.push(paragraph);
    return acc;
  }, [] as ParagraphWithTextAndImage[]);
}

function getLinkFilesPositions(paragraphs: Paragraph[], files: { [id: string]: CloudLink }) {
  return paragraphs.reduce(
    (acc, curr) => {
      const filename = curr.representation?.reference_file;
      if (filename && files[filename] && !acc[filename] && curr.start) {
        acc[filename] = { positions: [{ start: curr.start + 1, end: curr.start + 1 }] };
      }
      return acc;
    },
    {} as { [id: string]: { positions: NestedPosition[] } },
  );
}

export function getCustomEntities(resource: Resource): { [key: string]: string[] } {
  return resource
    .getFields()
    .map((field) => {
      return Object.entries(field.extracted?.metadata?.metadata?.entities || {})
        .filter(([key]) => key !== DEFAULT_NER_KEY)
        .reduce(
          (acc, [, entities]) => {
            entities.entities.forEach((entity) => {
              acc[entity.label] = (acc[entity.label] || []).concat([entity.text]);
            });
            return acc;
          },
          {} as { [key: string]: string[] },
        );
    })
    .reduce(
      (acc, val) => {
        Object.entries(val).forEach(([key, value]) => {
          acc[key] = (acc[key] || []).concat(value);
        });
        return acc;
      },
      {} as { [key: string]: string[] },
    );
}

export function getParagraphId(resourceId: string, field: FieldId, paragraph: Paragraph) {
  const typeAbbreviation = longToShortFieldType(field.field_type);
  return `${resourceId}/${typeAbbreviation}/${field.field_id}/${paragraph.start}-${paragraph.end}`;
}

export function getParagraphsWithClassifications(
  paragraphs: Paragraph[],
  fieldId: FieldId,
  resource: Resource,
): ParagraphWithTextAndClassifications[] {
  const paragraphClassificationMap = getParagraphClassificationMap(resource, fieldId);
  return paragraphs.map((paragraph) => {
    const paragraphId = getParagraphId(resource.id, fieldId, paragraph);
    const userClassifications = paragraphClassificationMap[paragraphId] || [];
    const generatedClassifications = getGeneratedClassification(paragraph, userClassifications);
    const enhancedParagraph: ParagraphWithTextAndClassifications = {
      ...paragraph,
      text: resource.getParagraphText(fieldId.field_type, fieldId.field_id, paragraph),
      paragraphId,
      userClassifications,
      generatedClassifications,
      activeClassifications: generatedClassifications.concat(
        userClassifications.filter((item) => !item.cancelled_by_user),
      ),
    };
    return enhancedParagraph;
  });
}

function getParagraphClassificationMap(resource: Resource, fieldId: FieldId): ParagraphClassificationMap {
  return (resource.fieldmetadata || []).reduce((annotationMap, userFieldMetadata) => {
    if (
      userFieldMetadata.field.field === fieldId.field_id &&
      userFieldMetadata.field.field_type === fieldId.field_type
    ) {
      annotationMap = {
        ...annotationMap,
        ...(userFieldMetadata.paragraphs || []).reduce((paragraphAnnotations, annotation) => {
          paragraphAnnotations[annotation.key] = annotation.classifications;
          return paragraphAnnotations;
        }, {} as ParagraphClassificationMap),
      };
    }
    return annotationMap;
  }, {} as ParagraphClassificationMap);
}

/**
 * Returns labels generated by the backend which weren't cancelled by the user
 */
function getGeneratedClassification(paragraph: Paragraph, userClassifications: UserClassification[]): Classification[] {
  return (paragraph.classifications || []).filter(
    (classification) =>
      !userClassifications.find(
        (userClassification) =>
          userClassification.cancelled_by_user &&
          userClassification.labelset === classification.labelset &&
          userClassification.label === classification.label,
      ),
  );
}

export function getTotalMessagePages(fieldId: FieldId, resource: Resource): number {
  return (resource.data['conversations']?.[fieldId.field_id]?.value as ConversationFieldPages)?.pages || 0;
}

export function mergeExistingAndNewLabels(
  resource: Resource,
  labelSets: LabelSets,
  labels: Classification[],
): Classification[] {
  const exclusiveLabelSets = Object.entries(labelSets)
    .filter(([, labelSet]) => !labelSet.multiple)
    .filter(([id]) => labels.some((label) => label.labelset === id))
    .map(([id]) => id);

  const resourceLabels = resource.getClassifications().filter((label) => !exclusiveLabelSets.includes(label.labelset));

  return getClassificationsPayload(
    resource,
    deDuplicateList(resourceLabels.concat(labels.map((label) => ({ ...label, cancelled_by_user: false })))),
  );
}

export function removeLabels(resource: Resource, labels: Classification[]): Classification[] {
  const resourceLabels = resource
    .getClassifications()
    .filter((label) => !labels.some((l) => l.labelset === label.labelset && l.label === label.label));

  return getClassificationsPayload(
    resource,
    resourceLabels.map((label) => ({ ...label, cancelled_by_user: false })),
  );
}

export function getResourceErrors(resource: Resource): string {
  return resource
    .getFields()
    .reduce((errors, field) => errors.concat(field.errors || field.error || []), [] as IError[])
    .map((error) => error?.body || '')
    .filter((error) => !!error)
    .join('. ');
}
