<script lang="ts">
const DAY_RANGE_VALUE = 24 * 60 * 60 * 1000;

const formatDuration = (ms: number, options?: { withinDay?: boolean }) => {
  const { withinDay = false } = options ?? {};
  const duration = dayjs.duration(ms);
  if (withinDay) {
    if (duration.asDays() > 1) {
      throw new Error('时长不能超过24小时');
    }
  }
  const days = duration.days();
  const hours = duration.hours();
  const minutes = duration.minutes();
  const seconds = duration.seconds();
  let result = '';
  if (days > 0) {
    result += `${days}天`;
  }
  if (hours > 0) {
    result += `${hours}小时`;
  }
  if (minutes > 0) {
    result += `${minutes}分钟`;
  }
  if (seconds > 0) {
    result += `${seconds}秒`;
  }
  if (result === '') {
    result = '0秒';
  }
  return result;
};
</script>

<script setup lang="ts">
import {
  batchExportRecordCheckApi,
  getChannelListApi,
  getRecordCheckApi,
  pageDefParameterApi,
  reloadAllRecordCheckApi,
  reloadRecordCheckApi,
  type NdmNvrResultVO,
  type RecordInfo,
  type RecordItem,
  type Station,
} from '@/apis';
import { useSettingStore } from '@/stores';
import { downloadByData, parseErrorFeedback } from '@/utils';
import { useMutation, useQuery, useQueryClient } from '@tanstack/vue-query';
import { refDebounced } from '@vueuse/core';
import { isCancel } from 'axios';
import dayjs from 'dayjs';
import destr from 'destr';
import { DownloadIcon, RotateCwIcon } from 'lucide-vue-next';
import { NButton, NCard, NDataTable, NFlex, NIcon, NInput, NModal, NPagination, NPopconfirm, NPopover, NRadioButton, NRadioGroup, NTooltip, useThemeVars, type DataTableColumns } from 'naive-ui';
import { storeToRefs } from 'pinia';
import { computed, onBeforeUnmount, ref, toRefs, watch } from 'vue';

type DailyLossItem = {
  date: string;
  total: number; // 缺失时长，单位：ms
  percent: number; // 缺失比例，范围：0-100
  chunks: (RecordItem & { startValue: number; endValue: number })[];
};

type NdmRecordCheckAggregated = {
  gbCode: string;
  channelName: string;
  range: RecordItem;
  dailyLoss: DailyLossItem[];
};

const props = defineProps<{
  ndmDevice: NdmNvrResultVO;
  station: Station;
}>();

const { ndmDevice, station } = toRefs(props);

const settingStore = useSettingStore();
const { activeRequests } = storeToRefs(settingStore);

const themeVars = useThemeVars();

const queryClient = useQueryClient();

const filterType = ref<'all' | 'some' | 'none'>('all');

const abortController = ref<AbortController>(new AbortController());

const NVR_RECORD_CHECK_KEY = 'nvr-record-check-query';

const deviceUniqueKey = computed(() => [station.value.code, ndmDevice.value.id]);

const DAY_OFFSET = 90;

const {
  data: recordChecks,
  isFetching: loading,
  refetch: refetchRecordChecks,
} = useQuery({
  queryKey: computed(() => [NVR_RECORD_CHECK_KEY, deviceUniqueKey.value, ndmDevice.value.lastDiagTime]),
  enabled: computed(() => activeRequests.value),
  refetchInterval: 30 * 1000,
  gcTime: 0,
  queryFn: async ({ signal }) => {
    const checks = await getRecordCheckApi(ndmDevice.value, DAY_OFFSET, [], { stationCode: station.value.code, signal });
    return checks;
  },
});

watch(activeRequests, (active) => {
  if (!active) queryClient.cancelQueries({ queryKey: [NVR_RECORD_CHECK_KEY] });
});

const { mutate: reloadAllRecordCheck, isPending: reloading } = useMutation({
  mutationFn: async () => {
    abortController.value.abort();
    abortController.value = new AbortController();
    await reloadAllRecordCheckApi(DAY_OFFSET, { stationCode: station.value.code, signal: abortController.value.signal });
  },
  onSuccess: () => {
    window.$message.success('正在逐步刷新中，请稍后点击刷新按钮查看');
  },
  onError: (error) => {
    if (isCancel(error)) return;
    console.error(error);
    const errorFeedback = parseErrorFeedback(error);
    window.$message.error(errorFeedback);
  },
});

const { mutate: reloadRecordCheckByGbId } = useMutation({
  mutationFn: async (params: { gbCode: string }) => {
    abortController.value.abort();
    abortController.value = new AbortController();
    const channelList = await getChannelListApi(ndmDevice.value, { stationCode: station.value.code, signal: abortController.value.signal });
    const channel = channelList.find((channel) => channel.code === params.gbCode);
    if (!channel) throw new Error('通道不存在');
    window.$message.loading('刷新耗时较长, 请不要多次刷新, 并耐心等待...', {
      duration: 1000 * 60 * 60 * 24 * 300,
    });
    const isSuccess = await reloadRecordCheckApi(channel, DAY_OFFSET, { stationCode: station.value.code, signal: abortController.value.signal });
    window.$message.destroyAll();
    if (isSuccess) {
      window.$message.success('刷新成功');
    } else {
      window.$message.error('刷新失败');
    }
  },
  onSuccess: () => {
    refetchRecordChecks();
  },
  onError: (error) => {
    if (isCancel(error)) return;
    console.error(error);
    const errorFeedback = parseErrorFeedback(error);
    window.$message.error(errorFeedback);
  },
});

const { mutate: exportRecordCheck, isPending: exporting } = useMutation({
  mutationFn: async () => {
    abortController.value.abort();
    abortController.value = new AbortController();
    const { records = [] } = await pageDefParameterApi(
      {
        model: {
          key: 'NVR_GAP_SECONDS',
        },
        extra: {},
        current: 1,
        size: 1,
        sort: 'id',
        order: 'descending',
      },
      {
        signal: abortController.value.signal,
      },
    );
    const gapSeconds = parseInt(records.at(0)?.value ?? '5');

    abortController.value.abort();
    abortController.value = new AbortController();
    const data = await batchExportRecordCheckApi(
      {
        checkDuration: DAY_OFFSET,
        gapSeconds,
        stationCode: [station.value.code],
      },
      {
        signal: abortController.value.signal,
      },
    );

    return data;
  },
  onSuccess: (data) => {
    const time = dayjs().format('YYYY-MM-DD_HH-mm-ss');
    downloadByData(data, `${station.value.name}_录像缺失记录_${time}.xlsx`);
  },
  onError: (error) => {
    if (isCancel(error)) return;
    console.error(error);
    const errorFeedback = parseErrorFeedback(error);
    window.$message.error(errorFeedback);
  },
});

onBeforeUnmount(() => {
  abortController.value.abort();
});

// 按天聚合录像缺失片段

const ndmRecordChecksAggregated = computed(() => {
  // 1. 解析diagInfo字段
  const parsedChecks = (recordChecks.value ?? []).map((check) => {
    return { ...check, diagInfo: destr<RecordInfo>(check.diagInfo) };
  });

  // 2. 按gbCode分组
  // 原始数据的基本单元是一个通道在一天内的录像诊断，
  // 所以我们要将相同通道的诊断数据组织到一起，于是形成一个Map结构
  const recordChecksMap = new Map<string, typeof parsedChecks>();
  parsedChecks.forEach((check) => {
    const { gbCode } = check;
    if (!recordChecksMap.has(gbCode)) {
      recordChecksMap.set(gbCode, []);
    }
    recordChecksMap.get(gbCode)?.push(check);
  });

  // 3. 按天进行聚合
  // 我们的最终目标是从每个通道的录像记录中解析出缺失的录像片段，
  // 并按天来组织这些片段，形成NdmRecordCheckAggregated结构
  const aggregated = Array.from(recordChecksMap.entries()).map<NdmRecordCheckAggregated>(([gbCode, checks]) => {
    // 首先，将该通道的所有录像记录合并到一个数组中，
    // 并对这些记录进行排序，确保按时间顺序排列
    const records = checks
      .flatMap((check) => {
        return check.diagInfo.recordList.map((record) => {
          const startValue = dayjs(record.startTime).valueOf();
          const endValue = dayjs(record.endTime).valueOf();
          const startTime = dayjs(record.startTime).format('YYYY-MM-DD HH:mm:ss');
          const endTime = dayjs(record.endTime).format('YYYY-MM-DD HH:mm:ss');
          return { startValue, endValue, startTime, endTime };
        });
      })
      .sort(({ startValue: startValue1 }, { startValue: startValue2 }) => {
        return startValue1 - startValue2;
      });

    const tomorrow = dayjs().add(1, 'day');

    // 由于DAY_OFFSET实际上不包含今天，而获取的数据又是包含今天的，
    // 所以实际的时间范围是 DAY_OFFSET + 1 天
    const dateLength = DAY_OFFSET + 1;

    // 初始化每日缺失记录，
    // 在处理完成后，如果有一天的数据没有变化，就说明这一天没有缺失录像
    const dailyLoss = Array.from({ length: dateLength }).map<NdmRecordCheckAggregated['dailyLoss'][number]>((_, index) => {
      return {
        date: tomorrow.subtract(dateLength - index, 'day').format('YYYY-MM-DD'),
        total: 0,
        percent: 0,
        chunks: [],
      };
    });

    // 开始解析按天组织的缺失录像片段，
    // 缺失片段的持续时间很可能是跨天甚至是跨越多天的，所以为了将缺失片段分配到每一天，我们采用「游标 + 切片」的设计
    // 首先，确定时间范围的开始和结束点
    const rangeStart = dayjs(dailyLoss.at(0)?.date).startOf('day').valueOf();
    const rangeEnd = dayjs(dailyLoss.at(-1)?.date).add(1, 'day').startOf('day').valueOf();
    // 初始化时间游标，从第一天的开始时间开始
    let timeCursor = rangeStart;
    records.forEach((record) => {
      const recordStart = record.startValue;
      const recordEnd = record.endValue;

      // 如果timeCursor < recordStart，说明 [timeCursor, recordStart] 这段时间的录像是缺失的，
      // 而这一段缺失有可能是跨天的，我们需要进行处理
      while (timeCursor < recordStart) {
        // 当前游标所属的日期
        const cursorDate = dayjs(timeCursor).format('YYYY-MM-DD');
        // 当前游标所属日期的末尾（下一天的开始时间）
        const cursorDateEnd = dayjs(cursorDate).add(1, 'day').startOf('day').valueOf();

        // 确定这一段缺失的终点，
        // 要么是 [timeCursor, recordStart]（没跨天），
        // 要么是 [timeCursor, cursorDateEnd]（跨天），
        // 我们取较小的那个
        const sliceEnd = Math.min(recordStart, cursorDateEnd);
        // 只要这段缺失有效，就记下它
        if (timeCursor < sliceEnd) {
          const loss = dailyLoss.find((loss) => loss.date === cursorDate);
          if (!!loss) {
            const startValue = timeCursor;
            const endValue = sliceEnd;
            const startTime = dayjs(startValue).format('YYYY-MM-DD HH:mm:ss');
            const endTime = dayjs(endValue).format('YYYY-MM-DD HH:mm:ss');
            loss.chunks.push({ startValue, endValue, startTime, endTime });
            loss.total += endValue - startValue;
            loss.percent = (loss.total / DAY_RANGE_VALUE) * 100;
          }
          // 推进游标
          timeCursor = sliceEnd;
        } else {
          // 假设这段缺失无效，说明这一天的数据有错乱，
          // 我们推进游标到下一天的开始时间
          timeCursor = cursorDateEnd;
        }
      }

      // 上面我们处理了 [timeCursor, recordStart] 这段时间的缺失，
      // 而 [recordStart, recordEnd] 这段时间的录像是完整的，
      // 所以我们可以直接推进游标到 recordEnd
      // 使用 Math.max 是为了防止两段录像记录交叉从而导致游标又发生回退
      timeCursor = Math.max(timeCursor, recordEnd);
    });

    // 现在我们处理完了所有的录像记录，但如果游标还没有到rangeEnd，
    // 说明还有一段缺失的录像记录没有被处理到，
    // 我们需要将这一段缺失记录分配到最后一天
    while (timeCursor < rangeEnd) {
      const cursorDate = dayjs(timeCursor).format('YYYY-MM-DD');
      const cursorDateEnd = dayjs(cursorDate).add(1, 'day').startOf('day').valueOf();
      const sliceEnd = Math.min(rangeEnd, cursorDateEnd);
      if (timeCursor < sliceEnd) {
        const loss = dailyLoss.find((loss) => loss.date === cursorDate);
        if (!!loss) {
          const startValue = timeCursor;
          const endValue = sliceEnd;
          const startTime = dayjs(startValue).format('YYYY-MM-DD HH:mm:ss');
          const endTime = dayjs(endValue).format('YYYY-MM-DD HH:mm:ss');
          loss.chunks.push({ startValue, endValue, startTime, endTime });
          loss.total += endValue - startValue;
          loss.percent = (loss.total / DAY_RANGE_VALUE) * 100;
        }
        timeCursor = sliceEnd;
      } else {
        timeCursor = cursorDateEnd;
      }
    }

    return {
      gbCode: gbCode,
      channelName: checks.at(-1)?.name ?? '',
      range: {
        startTime: records.at(0)?.startTime ?? '',
        endTime: records.at(-1)?.endTime ?? '',
      },
      dailyLoss: dailyLoss,
    };
  });

  // 最后我们把所有的gbCode按照字典序进行排序
  return aggregated.sort((check1, check2) => {
    return check1.gbCode.localeCompare(check2.gbCode);
  });
});

const searchInput = ref<string>('');
const searchInputDebounced = refDebounced(searchInput, 100);

const ndmRecordChecksSearched = computed(() => {
  if (!searchInputDebounced.value.trim()) {
    return ndmRecordChecksAggregated.value;
  }
  return ndmRecordChecksAggregated.value.filter(({ channelName }) => {
    return channelName.includes(searchInputDebounced.value);
  });
});

const ndmRecordChecksFiltered = computed(() => {
  // 最后一天就是「今天」，录像不可能完整，slice的时候别算进去
  return ndmRecordChecksSearched.value.filter(({ dailyLoss }) => {
    if (filterType.value === 'all') {
      return true;
    } else if (filterType.value === 'some') {
      // return dailyLoss.slice(0, -1).some(({ percent }) => percent > 0);
      for (let i = 0; i < dailyLoss.length - 1; i++) {
        if ((dailyLoss[i]?.percent ?? 0) > 0) {
          return true;
        }
      }
      return false;
    } else if (filterType.value === 'none') {
      // return dailyLoss.slice(0, -1).every(({ percent }) => percent === 0);
      for (let i = 0; i < dailyLoss.length - 1; i++) {
        if ((dailyLoss[i]?.percent ?? 0) !== 0) {
          return false;
        }
      }
      return true;
    }
    return false;
  });
});

const page = ref(1);
const pageSize = ref(10);

const ndmRecordChecksPaged = computed(() => {
  const startIndex = (page.value - 1) * pageSize.value;
  const endIndex = page.value * pageSize.value;
  return ndmRecordChecksFiltered.value.slice(startIndex, endIndex);
});

// 当车站号、设备IP、最后诊断时间或筛选类型变化时，重置分页为第一页
watch([() => station.value.code, () => ndmDevice.value.ipAddress, () => ndmDevice.value.lastDiagTime, filterType, searchInputDebounced], () => {
  page.value = 1;
});

// 当车站号、设备IP变化时，重置搜索内容，并将筛选类型重置为「全部」
watch([() => station.value.code, () => ndmDevice.value.ipAddress], () => {
  searchInput.value = '';
  filterType.value = 'all';
});

// 录像诊断块的交互

const dailyCheckContext = ref<{
  show: boolean;
  x: number;
  y: number;
  info?: DailyLossItem;
}>({
  show: false,
  x: 0,
  y: 0,
});

// 为了提升性能，不循环渲染Popover，而改为manual模式，
// 但是当鼠标移动到Popover上时，将触发录像诊断div块的mouseleave事件，从而导致Popover隐藏。
// 为了解决这个问题，当鼠标移出录像诊断块，延迟100ms后再隐藏Popover，
// 在延时期间，如果鼠标再次移入录像诊断块或移入Popover，则取消隐藏Popover的延迟操作，
// 当鼠标离开Popover，再次延时隐藏Popover。
const popoverTimer = ref<ReturnType<typeof setTimeout> | null>(null);

const showDailyCheckPopover = (event: MouseEvent, dailyLossItem: DailyLossItem) => {
  if (!!popoverTimer.value) {
    clearTimeout(popoverTimer.value);
    popoverTimer.value = null;
  }

  const { target } = event;
  if (!target) return;
  const { width, left, top } = (target as HTMLDivElement).getBoundingClientRect();
  dailyCheckContext.value = {
    show: true,
    x: left + width / 2,
    y: top,
    info: dailyLossItem,
  };
};

const hideDailyCheckPopover = () => {
  popoverTimer.value = setTimeout(() => {
    dailyCheckContext.value.show = false;
  }, 100);
};

const onMouseEnterDailyCheckPopover = () => {
  if (!!popoverTimer.value) {
    clearTimeout(popoverTimer.value);
    popoverTimer.value = null;
  }
};

const onMouseLeaveDailyCheckPopover = () => {
  hideDailyCheckPopover();
};

// 录像缺失详情弹窗

const showDailyLossModal = ref(false);

const onClickDailyCheck = () => {
  const { info } = dailyCheckContext.value;
  if (!info) return;
  const { total } = info;
  if (total === 0) return;
  showDailyLossModal.value = true;
};

const columns: DataTableColumns<DailyLossItem['chunks'][number]> = [
  { title: '开始时间', key: 'startTime' },
  { title: '结束时间', key: 'endTime' },
  {
    title: '持续时间',
    key: 'duration',
    render: ({ startValue, endValue }) => {
      return formatDuration(endValue - startValue, { withinDay: true });
    },
  },
];
</script>

<template>
  <NCard hoverable size="small">
    <template #header>
      <NFlex align="center" :size="24">
        <div>录像诊断</div>
        <NPopconfirm @positive-click="() => reloadAllRecordCheck()">
          <template #trigger>
            <NButton secondary size="small" :loading="reloading">更新所有通道录像诊断</NButton>
          </template>
          <template #default>
            <span>确认更新所有通道录像诊断吗？</span>
          </template>
        </NPopconfirm>
      </NFlex>
    </template>
    <template #header-extra>
      <NFlex>
        <NTooltip trigger="hover">
          <template #trigger>
            <NButton size="small" quaternary circle :loading="loading" @click="() => refetchRecordChecks()">
              <template #icon>
                <NIcon :component="RotateCwIcon" />
              </template>
            </NButton>
          </template>
          <template #default>
            <span>刷新数据</span>
          </template>
        </NTooltip>
        <NTooltip trigger="hover">
          <template #trigger>
            <NButton size="small" quaternary circle :loading="exporting" @click="() => exportRecordCheck()">
              <template #icon>
                <NIcon :component="DownloadIcon" />
              </template>
            </NButton>
          </template>
          <template #default>
            <span>导出录像诊断</span>
          </template>
        </NTooltip>
      </NFlex>
    </template>
    <template #default>
      <NFlex justify="flex-end" align="center" :wrap="false" style="width: 100%; margin-bottom: 6px">
        <NInput v-model:value="searchInput" placeholder="搜索通道名称" clearable />
        <NRadioGroup size="small" v-model:value="filterType">
          <NRadioButton label="全部" :value="'all'" />
          <NRadioButton label="有缺失" :value="'some'" />
          <NRadioButton label="无缺失" :value="'none'" />
        </NRadioGroup>
      </NFlex>
      <template v-for="{ gbCode, channelName, range, dailyLoss } in ndmRecordChecksPaged" :key="gbCode">
        <div style="display: flex; justify-content: space-between">
          <div>
            <span>{{ channelName }}</span>
            <span>{{ '\u3000' }}</span>
            <span>{{ range.startTime }} - {{ range.endTime }}</span>
          </div>
          <NPopconfirm trigger="click" @positive-click="() => reloadRecordCheckByGbId({ gbCode })">
            <template #trigger>
              <NButton ghost size="tiny" type="info">刷新</NButton>
            </template>
            <template #default>
              <span>是否确认刷新？</span>
            </template>
          </NPopconfirm>
        </div>
        <div
          style="position: relative; height: 24px; margin: 2px 0; background-color: #ccc; display: grid"
          :style="{
            gridTemplateRows: `1fr`,
            gridTemplateColumns: `repeat(${dailyLoss.length}, 1fr)`,
          }"
        >
          <template v-for="({ date, total, percent, chunks }, index) in dailyLoss" :key="date">
            <div
              style="border-width: 0 1px; border-style: solid"
              :style="{
                cursor: percent > 0 ? 'pointer' : 'default',
                borderColor: themeVars.baseColor,
                backgroundColor: (() => {
                  // 如果是最后一天(今天)，且录像的确持续到了最后一天，则不设置背景颜色
                  if (index === dailyLoss.length - 1) {
                    if (dayjs(dailyLoss.at(-1)?.date).startOf('day').diff(dayjs(range.endTime)) < 0) {
                      return 'transparent';
                    }
                  }
                  // 不缺失，设置为绿色
                  if (percent === 0) {
                    return `rgb(24, 160, 88)`;
                  }
                  // 将缺失占比映射到范围为 [0.2, 1] 的红色透明度通道
                  const opacity = 0.2 + (1 - 0.2) * (percent / 100);
                  return `rgba(208, 48, 80, ${opacity})`;
                })(),
              }"
              @mouseenter="(event) => showDailyCheckPopover(event, { date, total, percent, chunks })"
              @mouseleave="hideDailyCheckPopover"
              @click="onClickDailyCheck"
            ></div>
          </template>
        </div>
      </template>
    </template>
    <template #action>
      <NFlex justify="flex-end">
        <NPagination size="small" :page="page" :page-size="pageSize" :page-count="Math.ceil(ndmRecordChecksFiltered.length / pageSize)" @update:page="(p) => (page = p)">
          <template #prefix>
            <span>{{ `共 ${ndmRecordChecksFiltered.length} 个通道` }}</span>
          </template>
        </NPagination>
      </NFlex>
    </template>
  </NCard>

  <NPopover
    trigger="manual"
    :show="dailyCheckContext.show"
    :x="dailyCheckContext.x"
    :y="dailyCheckContext.y"
    :show-arrow="false"
    @mouseenter="onMouseEnterDailyCheckPopover"
    @mouseleave="onMouseLeaveDailyCheckPopover"
  >
    <template #default>
      <template v-if="!!dailyCheckContext.info">
        <div>日期：{{ dailyCheckContext.info.date }}</div>
        <template v-if="dailyCheckContext.info.percent > 0">
          <div>缺失时长：{{ formatDuration(dailyCheckContext.info.total, { withinDay: true }) }}</div>
          <div>缺失比例：{{ dailyCheckContext.info.percent.toFixed(2) }}%</div>
          <div style="font-size: xx-small; opacity: 0.5; cursor: pointer" @click="onClickDailyCheck">点击查看详情</div>
        </template>
        <template v-else>
          <div>录像完整</div>
        </template>
      </template>
    </template>
  </NPopover>

  <NModal v-model:show="showDailyLossModal" preset="card" title="录像缺失详情" style="width: 600px">
    <template #default>
      <template v-if="!!dailyCheckContext.info">
        <div style="margin-bottom: 16px; font-weight: bold">{{ dailyCheckContext.info.date }} 共缺失 {{ dailyCheckContext.info.chunks.length }} 个录像片段</div>
        <NDataTable :columns="columns" :data="dailyCheckContext.info.chunks" :pagination="{ pageSize: 10 }" size="small" :min-height="400" :max-height="400" />
      </template>
    </template>
  </NModal>
</template>

<style scoped></style>
