<template>
  <interact
    droppable
    @dropmove="disabled || loading ? undefined : handleDragOver($event)"
    @drop.prevent="disabled || loading ? undefined : handleDrop($event)"
    @dragleave="handleDragLeave"
    class="grid"
  >
    <template v-if="loading">
      <template v-for="loader in cols * rows">
        <div :key="loader">
          <slot name="loader"></slot>
        </div>
      </template>
    </template>

    <template v-else>
      <div class="highlight-container" ref="highlightContainer">
        <slot name="highlight">
          <div class="highlight"></div>
        </slot>
      </div>

      <DragContainer
        v-for="item in items"
        :key="item.key"
        :drag-i-d="item.key"
        :x="getInt('x', item)"
        :y="getInt('y', item)"
        :w="getInt('w', item)"
        :h="getInt('h', item)"
        :num-siblings="item.numSiblings"
        :sibling-index="item.siblingIndex"
        :data="getObject('data', item)"
        :context="context"
        :grid-id="gridId"
        :disabled="disabled || loading || item.disabled"
        @dragstart="emitContainerDragStart"
        @dragend="emitContainerDragEnd"
      >
        <slot v-bind="transformItem(item)" :raw-item="item" name="item">
          <dl>
            <dt>Key</dt>
            <dd>{{ item.key }}</dd>
            <dt>Position</dt>
            <dd>{{ item.x }}, {{ item.y }}</dd>
            <dt>Size</dt>
            <dd>{{ item.w }} × {{ item.h }}</dd>
            <dt>Data</dt>
            <dd>{{ item.data }}</dd>
          </dl>
        </slot>
      </DragContainer>
      <template v-for="disabledField in disabledFields">
        <GridItem
          class="disabledField"
          :x="disabledField.x"
          :y="disabledField.y"
          :key="disabledField.x + '|' + disabledField.y"
        >
          <slot name="disabledField" :is-dragged-over="isDraggedOver"></slot>
        </GridItem>
      </template>
      <slot></slot>
    </template>
  </interact>
</template>

<script>
import DragContainer from "./DragContainer.vue";
import { v4 as uuidv4 } from "uuid";
import GridItem from "./GridItem.vue";

export default {
  name: "DragGrid",
  components: {
    GridItem,
    DragContainer,
  },
  emits: ["input", "itemChanged", "containerDragStart", "containerDragEnd"],
  data() {
    return {
      isDraggedOver: false,
    };
  },
  props: {
    rows: {
      type: Number,
      required: true,
    },
    cols: {
      type: Number,
      required: true,
    },
    posValidation: {
      type: Function,
      required: false,
      default: undefined,
    },
    validateElement: {
      type: Function,
      required: false,
      default: undefined,
    },
    value: {
      type: Array,
      required: true,
    },
    context: {
      type: String,
      required: false,
      default: uuidv4,
    },
    gridId: {
      type: String,
      required: false,
      default: uuidv4,
    },
    noHighlight: {
      type: Boolean,
      required: false,
      default: false,
    },
    disabled: {
      type: Boolean,
      required: false,
      default: false,
    },
    disabledFields: {
      type: Array,
      required: false,
      default: () => [],
    },
    loading: {
      type: Boolean,
      required: false,
      default: false,
    },
    multipleItemsY: {
      type: Boolean,
      required: false,
      default: false,
    },
  },
  methods: {
    positionAllowed(x, y, key) {
      if (x < 0 || y < 0) return false;
      if (x > this.cols) return false;
      if (y > this.rows) return false;

      if (
        this.disabledFields.filter((field) => field.x === x && field.y === y)
          .length > 0
      ) {
        // Field is disabled
        return false;
      }

      if (!this.multipleItemsY) {
        for (let item of this.value) {
          if (key === item.key) continue;
          if (
            x >= this.getInt("x", item) &&
            x < this.getInt("x", item) + this.getInt("w", item)
          ) {
            if (
              y >= this.getInt("y", item) &&
              y < this.getInt("y", item) + this.getInt("h", item)
            ) {
              return false;
            }
          }
        }
      }
      if (this.posValidation) return this.posValidation(x, y, key);
      return true;
    },
    handleDragOver(event) {
      let data = event.relatedTarget.dataset.transfer;
      if (!data) return;
      let element = JSON.parse(data);
      let coords = this.getCoords(
        event.dragEvent.client.x - element.mouseX,
        event.dragEvent.client.y - element.mouseY,
      );

      if (element.context !== this.context || this.noHighlight) {
        this.$refs.highlightContainer.style.display = "none";
        return;
      }

      this.isDraggedOver = true;

      let newPositionValid = true;

      for (let x = coords.x; x < coords.x + element.w; x++) {
        for (let y = coords.y; y < coords.y + element.h; y++) {
          newPositionValid = this.positionAllowed(x, y, element.key);
          if (!newPositionValid) break;
        }
        if (!newPositionValid) break;
      }

      if (!newPositionValid) {
        this.$refs.highlightContainer.style.display = "none";
        return;
      }

      this.$refs.highlightContainer.style.display = "block";
      this.$refs.highlightContainer.style.gridColumnStart = coords.x + "";
      this.$refs.highlightContainer.style.gridRowStart = coords.y + "";
      this.$refs.highlightContainer.style.gridColumnEnd = "span " + element.w;
      this.$refs.highlightContainer.style.gridRowEnd = "span " + element.h;
    },
    handleDrop(event) {
      this.isDraggedOver = false;
      this.$refs.highlightContainer.style.display = "none";
      let data = event.relatedTarget.dataset.transfer;
      if (!data) return;
      let element = JSON.parse(data);

      if (this.validateElement) this.validateElement(element);

      if (element.context !== this.context) {
        return;
      }

      let coords = this.getCoords(
        event.dragEvent.client.x - element.mouseX,
        event.dragEvent.client.y - element.mouseY,
      );

      let newPositionValid = true;

      for (let x = coords.x; x < coords.x + element.w; x++) {
        for (let y = coords.y; y < coords.y + element.h; y++) {
          newPositionValid = this.positionAllowed(x, y, element.key);
          if (!newPositionValid) break;
        }
        if (!newPositionValid) break;
      }

      if (!newPositionValid) return;

      element.x = coords.x;
      element.y = coords.y;

      try {
        let valueCopy = structuredClone(this.value);

        let index = valueCopy.findIndex((i) => {
          return i.key === element.key;
        });
        if (index >= 0) valueCopy.splice(index, 1);

        let elementCopy = structuredClone(element);

        elementCopy.context = undefined;
        elementCopy.originGridId = undefined;
        elementCopy.mouseX = undefined;
        elementCopy.mouseY = undefined;

        valueCopy.push(elementCopy);
        this.$emit("input", valueCopy);
      } catch (e) {
        if (e.code === DOMException.DATA_CLONE_ERR) {
          // We use functions for properties → we can't clone; only emit `item-changed` event
          console.debug(
            "Grid couldn't be cloned, please listen to the `item-changed` event and handle changes yourself.",
          );
        } else {
          throw e;
        }
      }

      element.gridId = this.gridId;

      this.$emit("itemChanged", element);
    },
    handleDragLeave() {
      this.isDraggedOver = false;
      this.$refs.highlightContainer.style.display = "none";
    },
    clamp: (min, num, max) => Math.min(Math.max(num, min), max),
    getCoords(x, y) {
      let rect = this.$el.getBoundingClientRect();
      return {
        x: this.clamp(
          1,
          Math.ceil((x - rect.x) / (rect.width / this.cols)),
          this.cols,
        ),
        y: this.clamp(
          1,
          Math.ceil((y - rect.y) / (rect.height / this.rows)),
          this.rows,
        ),
      };
    },
    getInt(property, item) {
      let val = item[property] || 1;
      return val instanceof Function ? val(this.gridData) : parseInt(val);
    },
    getObject(property, item) {
      let val = item[property] || {};
      return val instanceof Function ? val(this.gridData) : val;
    },
    transformItem(item) {
      let newItem = { key: item.key };
      newItem.x = this.getInt("x", item);
      newItem.y = this.getInt("y", item);
      newItem.w = this.getInt("w", item);
      newItem.h = this.getInt("h", item);
      newItem.data = this.getObject("data", item);
      return newItem;
    },
    emitContainerDragStart(dataTransfer) {
      this.$emit("containerDragStart", dataTransfer);
    },
    emitContainerDragEnd(dataTransfer) {
      this.$emit("containerDragEnd", dataTransfer);
    },
  },
  computed: {
    gridData() {
      return {
        gridId: this.gridId,
        context: this.context,
      };
    },
    items() {
      if (!this.multipleItemsY) return this.value;

      if (this.value.some((item) => item.w > 1)) {
        console.warn(
          "You are using multipleItemsY but some items have a width greater than 1.",
          "This is not supported and will lead to unexpected behaviour.",
        );
      }

      // calculate numSiblings for each field
      // First dimension: the columns
      let xSiblings = [];
      this.value.forEach((item) => {
        for (let i = 0; i < item.h; i++) {
          if (!xSiblings[item.x]) xSiblings[item.x] = [];
          if (xSiblings[item.x][item.y + i] === undefined) {
            xSiblings[item.x][item.y + i] = 0;
          }
          xSiblings[item.x][item.y + i]++;
        }
      });

      let xSiblingsCopy = structuredClone(xSiblings);

      return this.value.map((item) => {
        let numSiblings = xSiblings[item.x]
          .filter((i, index) => {
            return index >= item.y && index < item.y + item.h;
          })
          .reduce((a, b) => Math.max(a, b), 0);
        for (let i = item.y; i < item.h + item.y; i++) {
          xSiblingsCopy[item.x][i]--;
        }
        let offset = xSiblingsCopy[item.x]
          .filter((i, index) => {
            return index >= item.y && index < item.y + item.h;
          })
          .reduce((a, b) => Math.max(a, b), 0);
        return {
          ...item,
          numSiblings: numSiblings,
          siblingIndex: offset,
        };
      });
    },
  },
};
</script>

<style scoped>
.highlight {
  background: darkgrey;
  border: grey dashed 2px;
  width: 100%;
  height: 100%;
}

.highlight-container {
  display: none;
  transition: all 2s ease-in-out;
  pointer-events: none;
  user-select: none;
  width: 100%;
  height: 100%;
}

.grid {
  display: grid;
  grid-template-columns: repeat(v-bind(cols), 1fr);
  grid-template-rows: repeat(v-bind(rows), 1fr);
  width: 100%;
  height: 100%;
  min-width: 100px;
  min-height: 100px;
  gap: 1em;
  touch-action: none;
  isolation: isolate;
}
</style>