<template>
  <div>
    <TfTableFilters v-if="filters.length">
      <template #emptyCell>
        <slot name="emptyCell">
          &mdash;
        </slot>
      </template>
      <template
        v-if="$slots.filterLabel || showFilterLabel"
        #filterLabel
      >
        <slot name="filterLabel">
          {{ $slots.filterLabel || 'Filters:' }}
        </slot>
      </template>
    </TfTableFilters>
    <TfTablePagination v-if="tableData.length && (showPageResults || pagination)" />
    <div
      v-if="$slots.title || $slots.ctaHeader || $slots.header"
      :class="{ 'cta-only' : (!$slots.title && !$slots.header) }"
      data-test="table header"
      class="tf-table-header"
    >
      <slot name="header">
        <h2 v-if="$slots.title">
          <slot name="title" />
        </h2>
        <div
          v-if="$slots.ctaHeader"
          class="btn-group"
        >
          <slot name="ctaHeader" />
        </div>
      </slot>
    </div>
    <table class="table table-parent">
      <TfTableHead />
      <tbody>
        <template v-if="!$slots.error && isLoaded && tableRowIndexes.length">
          <template v-for="tableDataIdx in tableRowIndexes">
            <!--
              Wrap b/c tableData[tableDataIdx] won't exist if data has been deleted from the parent.
            -->
            <template v-if="tableData[tableDataIdx]">
              <slot
                name="parentRow"
                v-bind="{
                  columns: tableMeta.columns,
                  isExpanded: expandedChildren.includes(tableData[tableDataIdx][findIndexBy]),
                  rowData: tableData[tableDataIdx],
                }"
              >
                <TfTableParentRow
                  :key="tableDataIdx"
                  data-test="table row"
                  v-bind="{
                    rowData: tableData[tableDataIdx],
                  }"
                >
                  <TfTableTd
                    v-for="column in tableMeta.columns"
                    :key="column.prop"
                    v-bind="{
                      column,
                      rowData: tableData[tableDataIdx],
                    }"
                  >
                    <template #emptyCell>
                      <slot name="emptyCell">
                        &mdash;
                      </slot>
                    </template>
                  </TfTableTd>
                </TfTableParentRow>
              </slot>
              <template
                v-if="expandedChildren.includes(tableData[tableDataIdx][findIndexBy]) &&
                  tableData[tableDataIdx].children &&
                  tableData[tableDataIdx].children.length"
              >
                <TfChildTable
                  v-for="child in tableData[tableDataIdx].children"
                  :key="`${child.columnKey}-${tableDataIdx}`"
                  :colspan="tableMeta.columns.length + 1"
                  :child-data="child"
                  :child-columns="tableMeta.childColumns[child.columnKey]"
                >
                  <template
                    slot="childTable"
                    slot-scope="{ columns, data }"
                  >
                    <slot
                      name="childTable"
                      :data="data"
                      :columns="columns"
                    >
                      <table class="table table-child">
                        <thead>
                          <tr>
                            <th
                              v-for="column in columns"
                              :key="column.label"
                              :width="column.width"
                              v-text="column.label"
                            />
                          </tr>
                        </thead>
                        <tbody>
                          <TfChildRow
                            v-for="(childRowData, index) in child.data"
                            :key="`${child.columnKey}-${index}`"
                            :child-row-data="childRowData"
                            :child-columns="tableMeta.childColumns[child.columnKey]"
                          />
                        </tbody>
                      </table>
                    </slot>
                  </template>
                </TfChildTable>
              </template>
            </template>
          </template>
        </template>
        <tr
          v-else
          class="is-empty"
        >
          <td :colspan="hasChildren ? tableMeta.columns.length + 1 : tableMeta.columns.length">
            <div
              v-if="!isLoaded || isSorting"
              v-loading="true"
            />
            <template v-else>
              <ElAlert
                v-if="$slots.error"
                :closable="false"
                show-icon
                type="error"
              >
                <slot name="error" />
              </ElAlert>
              <slot
                v-else-if="!tableData.length"
                name="empty"
              >
                There doesn't appear to be any data for this table.
              </slot>
              <slot
                v-else
                name="noFilteredResults"
              >
                There doesn't appear to be any data that matches the selected filters.
              </slot>
            </template>
          </td>
        </tr>
      </tbody>
    </table>
    <div
      v-if="$slots.footer"
      class="tf-table-footer"
    >
      <slot name="footer" />
    </div>
    <TfTablePagination v-if="tableData.length && (showPageResults || pagination)" />
  </div>
</template>

<script>
  // components
  import moment from 'moment';
  import TfChildRow from './ChildRow';
  import TfChildTable from './ChildTable';
  import TfTableFilters from './Filters';
  import TfTableHead from './Head';
  import TfTablePagination from './Pagination';

  export default {
    name: 'TfTable',
    components: {
      TfChildRow,
      TfChildTable,
      TfTableFilters,
      TfTableHead,
      TfTablePagination,
    },
    provide() {
      return {
        TfTable: this,
      };
    },
    props: {
      /**
       * When provided, the table will run this function instead of the default sortObjects function.
       * This function will be passed the same arguments as the default sortObjects function.
       */
      customSort: {
        type: Function,
        default: null,
      },
      /**
       * filterValues should be a URL encoded string if ever used.
       * required if `parentalControls` is true.
       */
      filterValues: {
        type: String,
        default: '',
      },
      /**
       * findIndexBy is a unique id for various logic to point to the original data.
       * Most data has an 'id' key so that's the default.
       */
      findIndexBy: {
        type: String,
        default: 'id',
      },
      /**
       * pageLimit sets the number of rows to show per page.
       */
      pageLimit: {
        type: Number,
        default: 50,
      },
      /**
       * truncatedPageRange sets the max number of pages that can be displayed at once. Set to 0 to show all pages.
       * Number also needs to be odd in order to have the active page in the middle.
       */
      truncatedPageRange: {
        type: Number,
        default: 0,
        validator: (value) => Boolean(value % 2 || value === 0),
      },
      /**
       * pageSelected dictates what page to show for paginated data.
       * required if `parentalControls` is true.
       */
      pageSelected: {
        type: Number,
        default: 1,
      },
      /**
       * Setting `pagination` to `true` will evaluate various data/props and render a tfPagination component at the bottom and top of the table.
       */
      pagination: {
        type: Boolean,
        default: false,
      },
      /**
       * `parentalControls` is really just the way to control the table via a route string.
       * Setting this to `true` will control filters updates, the selected page, and the sorting order.
       */
      parentalControls: {
        type: Boolean,
        default: false,
      },
      /**
       * `showPageResults` will determine if the page results should be shown in the table pagination component.
       */
      showPageResults: {
        type: Boolean,
        default: false,
      },
      /**
       * `showFilterLabel` will determine if the table filter component should render the default label.
       */
      showFilterLabel: {
        type: Boolean,
        default: false,
      },
      /**
       * `tableData` is an array of objects to be evaluated and rendered in the table.
       */
      tableData: {
        type: Array,
        default: () => [],
      },
      /**
       * `tableMeta` contains each column's metaData (like the `prop`, `label`, `sortable`, etc) and the table sort order and prop.
       */
      tableMeta: {
        type: Object,
        default: () => ({}),
      },
    },
    data: (vm) => ({
      expandedChildren: [],
      isSorting: false,
      isLoaded: false,
      filters: [],
      filteredTableDataIndexes: [],
      localPageSelected: vm.pageSelected,
      localFilterValues: {},
      sortColumn: null,
      sortDirection: null,
      sortProp: null,
      tableRows: [],
      tableRowIndexes: [],
      totalPages: 0,
    }),
    computed: {
      /**
       * Evaulates the table data to see if any of the rows have children with length.
       *
       * @returns {boolean}
       */
      hasChildren() {
        return this.tableData.some((rowData) => rowData.children);
      },
    },
    watch: {
      /**
       * Watch the filterValues prop to apply new values from the parent.
       */
      filterValues() {
        let filterValues = {};

        if (this.filterValues) {
          filterValues = JSON.parse(decodeURI(this.filterValues));

          Object.entries(filterValues).forEach(([key, value]) => {
            filterValues[key] = value;
          });
        }

        this.$set(this, 'localFilterValues', filterValues);
        this.sortData({ updateData: true });
      },
      /**
       * Watcher for pageSelected from parent when using routes.
       *
       * @param {string} newPage
       */
      pageSelected(newPage) {
        this.localPageSelected = newPage;
        this.setTableRows();
      },
      /**
       * Watch the tableData prop to sort any new data from the parent.
       */
      tableData() {
        this.sortData({ updateData: true });
      },
      /**
       * Update sortColumn, sortDirection, sortProp when using parental controls.
       * Then emit pageSelected and sort based on conditions.
       * Notes: When using $router.push({ params: pageSelected })
       * The sort can potentially be flagged as new as well
       * so we need to test the old/new props to prevent the sort from firing.
       *
       * @param {object} newValue
       * @param {string} newValue.direction
       * @param {string} newValue.prop
       * @param {object} oldValue
       * @param {string} oldValue.direction
       * @param {string} oldValue.prop
       *
       * @returns {void}
       */
      'tableMeta.sort': function (
        { direction, prop },
        { direction: oldDirection, prop: oldProp },
      ) {
        if (
          oldDirection === direction
          && oldProp === prop
        ) {
          return;
        }

        const reverseDirection = oldProp === prop;

        this.sortColumn = prop;
        this.sortDirection = direction;
        this.sortProp = prop;

        // If we need to reset the page here, we emit up pageSelected with 1.
        // The watcher for pageSelected will then sort the data.
        // If we don't need to reset the pageSelected, we sort the data.
        if (this.localPageSelected !== 1) {
          this.$emit('pageSelected', 1);
          this.sortData({ updateData: !reverseDirection, reverseDirection });

          return;
        }

        this.sortData({ updateData: !reverseDirection, reverseDirection });
      },
    },
    /**
     * On created, we want to set up the local sort and filter data appropriately.
     * Once all of that is taken care of, we wait for forceNextTick to sort all the data and loaded states.
     * the $nextTick() isn't working correctly and there's more info on https://github.com/vuejs/vue/issues/9200.
     */
    created() {
      if (this.tableMeta.sort) {
        const { prop, sortable } = this.tableMeta.columns.find((columnObj) => (
          // If targeted sorting, we need to return the column where 'sortable' matches the sort prop.
          // Otherwise find column where the prop is is the sort prop.
          columnObj.sortable === this.tableMeta.sort.prop
          || columnObj.prop === this.tableMeta.sort.prop
        ));
        const sortColumn = prop;
        let sortProp = prop;

        if (sortable && typeof sortable === 'string') {
          sortProp = sortable;
        }

        this.sortColumn = sortColumn;
        this.sortDirection = this.tableMeta.sort.direction;
        this.sortProp = sortProp;
      }

      // Check to see if any filters exist and create this.localFilterValues and this.filters
      let filters = this.tableMeta.columns.filter(({ filter }) => filter);

      if (filters.length || this.filterValues) {
        // Create the localFilterValues
        const filterValues = Object.assign(
          {},
          ...filters.map(({ prop }) => ({ [prop]: [] })),
        );
        let existingFilters = {};

        // Override any empty values with filterValues prop.
        if (this.parentalControls && this.filterValues) {
          existingFilters = JSON.parse(decodeURI(this.filterValues));

          Object.entries(existingFilters).forEach(([key, value]) => {
            filterValues[key] = value;
          });
        }
        this.$set(this, 'localFilterValues', filterValues);

        // Create the filters array
        filters = filters.map(({ filter, label, prop }, filterIndex) => {
          // If no order is provided, the index will be applied
          // to the filter hierarchy
          const order = Number.isNaN(filter.order)
            ? filterIndex
            : filter.order;
          const { type } = filter;
          let { options } = filter;
          let dateType;

          // If no options are provided and the prop isn't string/range,
          // create an array of unique IDs and map with label/value
          if (!options && ['string', 'range'].includes(type)) {
            const uniqOptions = [
              ...new Set(this.tableData.map((dataObj) => dataObj[prop])),
            ].map((id) => {
              const value = id || '';

              return {
                label: value,
                value,
              };
            });

            options = this.$TF.Formatting.sortObjects(uniqOptions, 'value');
          }

          if (['month', 'date-range', 'date-range-input'].includes(type)) {
            dateType = type;
          }

          if (type === 'month-and-date-range') {
            dateType = existingFilters[prop] && Array.isArray(existingFilters[prop][0])
              ? 'date-range'
              : 'month';
          }

          return {
            dateType,
            label,
            options,
            order,
            popoverWidth: filter.popoverWidth,
            prop,
            type,
            width: filter.width,
          };
        });

        const sortedFilters = this.$TF.Formatting.sortObjects(filters, 'filterOrder');

        this.$set(this, 'filters', sortedFilters);
      }

      this.$forceNextTick(() => {
        this.isLoaded = true;
        this.sortData({ updateData: true });
      });
    },
    methods: {
      /**
       * Change the sort criteria based on what was clicked.
       * This is a method that is triggered directly in the TfTableHead child component.
       *
       * @param {object} column
       */
      changeSortCriteria(column) {
        const sortColumn = column.prop;
        const reverseDirection = sortColumn === this.sortProp;
        let sortDirection = 'asc';
        let sortProp = sortColumn;

        if (column.sortable && typeof column.sortable === 'string') {
          sortProp = column.sortable;
        }

        if (sortProp === this.sortProp && this.sortDirection === 'asc') {
          sortDirection = 'desc';
        }

        this.$emit('changeSortCriteria', {
          sortDirection,
          sortProp,
        });

        // Escape if controlled by parent b/c watcher will handle updating the local props and sort.
        if (this.parentalControls) {
          return;
        }

        this.sortColumn = sortColumn;
        this.sortDirection = sortDirection;
        this.sortProp = sortProp;

        if (this.localPageSelected !== 1) {
          this.localPageSelected = 1;
        }

        this.sortData({ updateData: !reverseDirection, reverseDirection });
      },
      /**
       * Find the dateType key in the appropriate filter and change the passed param.
       * This is a method that is triggered directly in the TfTableFilters child component.
       *
       * @param {string} filterProp
       * @param {string} dateFilterType
       */
      onChangeDateFilterType(filterProp, dateFilterType) {
        const index = this.filters.findIndex(({ prop }) => prop === filterProp);

        this.filters[index].dateType = dateFilterType;
      },
      /**
       * Change the values of the appropriate filter based on the passed params
       * and emits an encoded URI to the parent.
       * This is a method that is triggered directly in the TfTableFilters child component.
       *
       * @param {object} $event
       * @param {string} $event.prop
       * @param {string} $event.value
       */
      onChangeFilterValue({ prop, value = [] }) {
        if (this.localPageSelected !== 1) {
          this.onPageSelected();
        }

        const filterValues = decodeURI(this.filterValues || '');
        const filters = filterValues
          ? JSON.parse(filterValues)
          : JSON.parse(`{${filterValues}}`);

        filters[prop] = value;

        this.$emit('changeFilter', JSON.stringify(filters));

        /*
          Escape if controlled by parent.
          Watcher will handle updating the localFilter and sort.
        */
        if (this.parentalControls) {
          return;
        }

        this.$set(this.localFilterValues, [prop], value);
        this.sortData({ updateData: true });
      },
      /**
       * Change the selected page and shows/hide the loader appropriately.
       * This is a method that is triggered directly in the TfTablePagination child component as well as throughout the component.
       *
       * @param {number} newPage
       */
      onPageSelected(newPage = 1) {
        this.$emit('pageSelected', newPage);

        /*
          Escape if controlled by parent.
          Watcher will handle updating the local page select and sort.
        */
        if (this.parentalControls) {
          return;
        }

        this.localPageSelected = newPage;
        this.setTableRows();
      },
      /**
       * Clears out each of the localFilterValues keys and emits to the parent.
       * This is a method that is triggered directly in the TfTableFilters child component.
       */
      onResetFilters() {
        Object.entries(this.localFilterValues).forEach(([key]) => {
          this.$set(this.localFilterValues, [key], []);
        });

        if (this.localPageSelected !== 1) {
          this.onPageSelected();
        }

        this.$emit('changeFilter');
        this.sortData({ updateData: true });
      },
      /**
       * Pushes or removes the parent row ID from expandedChildren.
       * This is a method that is triggered directly in the TfTableParentRow child component.
       *
       * @param {number} parentRowId
       */
      toggleChildRow(parentRowId) {
        if (this.expandedChildren.includes(parentRowId)) {
          this.$delete(
            this.expandedChildren,
            this.expandedChildren.findIndex((id) => id === parentRowId),
          );
          return;
        }

        this.expandedChildren.push(parentRowId);
      },
      /**
       * Set the tableRows according to the pagination.
       */
      setTableRows() {
        this.isSorting = true;

        this.$forceNextTick(() => {
          const tableRowIndexes = !this.pagination
            ? this.filteredTableDataIndexes
            : this.filteredTableDataIndexes.slice(
              (this.localPageSelected - 1) * this.pageLimit,
              this.localPageSelected * this.pageLimit,
            );
          const totalPages = this.pagination
            ? Math.ceil(this.filteredTableDataIndexes.length / this.pageLimit)
            : null;

          this.$set(this, 'tableRowIndexes', tableRowIndexes);
          this.totalPages = totalPages;
          this.isSorting = false;
        });
      },
      /**
       * Sort the table data by appropriate filters and sorts.
       * If updateData is passed (usually on fresh load or if changing filters), we want to filter through the localFilterValues
       *
       * @param {boolean} updateData
       */
      sortData({
        reverseDirection = false,
        updateData = false,
      }) {
        this.isSorting = true;

        this.$forceNextTick(() => {
          const useFilteredIndex = reverseDirection && !updateData;
          let filteredTableData = useFilteredIndex
            ? this.filteredTableDataIndexes
            : this.tableData;

          if (updateData && Object.values(this.localFilterValues).length) {
            // 1. iterate through each filter
            this.filters.forEach(({ dateType, prop, type }) => {
              const values = this.localFilterValues[prop];

              if (values && values.length) {
                // 2. filter filteredTableData by prop
                filteredTableData = filteredTableData.filter(({ [prop]: dataValue }) => {
                  // 3. test the value to see if it is included
                  let isIncluded;

                  if (dataValue === null) {
                    isIncluded = false;
                  } else if (type === 'string') {
                    isIncluded = values.includes(dataValue);
                  } else if (type === 'array') {
                    isIncluded = values.every((val) => dataValue.includes(val));
                  } else if (type === 'range') {
                    values.forEach((value) => {
                      const [min, max] = value.split(',').map((n) => Number(n));
                      const isWithinRange = max && dataValue >= min && dataValue <= max;
                      const isOverMin = !max && dataValue >= min;

                      if (isWithinRange || isOverMin) {
                        isIncluded = true;
                      }
                    });
                  } else if (dateType === 'month') {
                    const value = moment(values[0]);
                    const filterDate = moment(dataValue);

                    isIncluded = value.isSame(filterDate, 'month');
                  } else if (['date-range-input', 'date-range'].includes(dateType)) {
                    const value = moment(dataValue);
                    let [startDate, endDate] = values[0];

                    startDate = moment(startDate);
                    endDate = moment(endDate);

                    // https://momentjscom.readthedocs.io/en/latest/moment/05-query/06-is-between/
                    // moment().isBetween(moment-like, moment-like, String, String);
                    // where moment-like is Moment|String|Number|Date|Array
                    isIncluded = value.isBetween(startDate, endDate, undefined, '[]');
                  }

                  return isIncluded;
                });
              }
            });
          }

          // use custom sort function if prop supplied or default sortObjects
          const sortFunction = this.customSort || this.$TF.Formatting.sortObjects;

          // 4. Sort the filtered table data by the correct prop/direction and map its
          // associated table data index.
          const filteredTableDataIndexes = useFilteredIndex
            ? filteredTableData.reverse()
            : sortFunction(filteredTableData, this.sortProp, this.sortDirection)
              .map((row) => (
                this.tableData.findIndex((obj) => obj[this.findIndexBy] === row[this.findIndexBy])
              ));

          this.$set(this, 'filteredTableDataIndexes', filteredTableDataIndexes);

          this.setTableRows();
        });
      },
    },
  };
</script>

<style scoped lang="scss">
  @import 'src/assets/styles/variables';

  .tf-table {
    &-footer {
      box-sizing: border-box;
      display: flex;
      justify-content: center;
      width: 100%;
      padding: 13px 20px;
      border: 1px solid $tf-light-gray;
      border-top: 0;
    }

    &-header {
      box-sizing: border-box;
      display: flex;
      justify-content: space-between;
      width: 100%;
      min-height: 67px;
      padding: 18px 20px;
      border: 1px solid $tf-light-gray;
      border: {
        bottom: 0;
        radius: 6px 6px 0 0;
      }

      &.cta-only {
        justify-content: flex-end;
      }
    }
  }

  div {
    position: relative;
  }

  h2 {
    margin-bottom: 0;
  }
</style>
