<script setup>

import { useI18n } from 'vue-i18n';
const i18n = useI18n();
const t = i18n.t;

import { ref, computed, useTemplateRef, watch, onActivated, onDeactivated, inject } from 'vue';
import { Button, SingleSelect, Icon, TableView, TextInput, ProgressBar } from '@cloudron/pankow';
import { API_ORIGIN, APP_TYPES, HSTATES, ISTATES, RSTATES } from '../constants.js';
import ActionBar from '../components/ActionBar.vue';
import AppsModel from '../models/AppsModel.js';
import ApplinksModel from '../models/ApplinksModel.js';
import DomainsModel from '../models/DomainsModel.js';
import ApplinkDialog from '../components/ApplinkDialog.vue';
import PostInstallDialog from '../components/PostInstallDialog.vue';

const appsModel = AppsModel.create();
const domainsModel = DomainsModel.create();
const applinksModel = ApplinksModel.create();

const VIEW_TYPE = {
  LIST: 'list',
  GRID: 'grid',
};

let refreshInterval;

const ready = ref(false);
const filter = ref('');
const profile = inject('profile');
const apps = ref([]);
const viewType = ref((localStorage.appsView && (localStorage.appsView === VIEW_TYPE.GRID || localStorage.appsView === VIEW_TYPE.LIST)) ? localStorage.appsView : VIEW_TYPE.GRID);
const tagFilter = ref('');
const tagFilterOptions = ref([{
  id: '',
  name: 'All tags',
}]);
const domainFilter = ref('');
const domainFilterOptions = ref([]);
const stateFilter = ref('');
const stateFilterOptions = [
  { id: '', label: 'All states' },
  { id: 'running', label: 'Running' },
  { id: 'stopped', label: 'Stopped' },
  { id: 'update_available', label: 'Update available' },
  { id: 'not_responding', label: 'Not responding' },
];
const STATE_FILTER_IDS = new Set(['', 'running', 'stopped', 'update_available', 'not_responding']);
const APPS_HASH_PATH = '#/apps';

function syncFiltersToUrl() {
  if (window.location.hash.split('?')[0] !== APPS_HASH_PATH) return;
  const params = new URLSearchParams();
  if (filter.value) params.set('search', filter.value);
  if (domainFilter.value) params.set('domain', domainFilter.value);
  if (stateFilter.value) params.set('state', stateFilter.value);
  if (tagFilter.value) params.set('tag', tagFilter.value);
  const query = params.toString();
  const newHash = query ? `${APPS_HASH_PATH}?${query}` : APPS_HASH_PATH;
  history.replaceState(null, '', newHash);
}

let filterDebounceTimer;
function scheduleSyncFilterToUrl() {
  clearTimeout(filterDebounceTimer);
  filterDebounceTimer = setTimeout(syncFiltersToUrl, 350);
}

const listColumns = {
  icon: {
    width: '40px'
  },
  label: {
    label: 'Label',
    sort: (a, b, fullA, fullB) => {
      if (!fullA || !fullA) return -1;
      const checkA = fullA.label || fullA.subdomain || fullA.fqdn;
      const checkB = fullB.label || fullB.subdomain || fullB.fqdn;
      return checkA.toLowerCase() < checkB.toLowerCase() ? -1 : (checkA.toLowerCase() > checkB.toLowerCase() ? 1 : 0);
    },
  },
  fqdn: {
    label: 'Location',
    sort: true,
    hideMobile: true,
  },
  appTitle: {
    label: 'App',
    hideMobile: true,
    sort: (a, b, fullA, fullB) => {
      if (!fullA || !fullA) return -1;
      const checkA = fullA.manifest.title;
      const checkB = fullB.manifest.title;
      return checkA < checkB ? -1 : (checkA > checkB ? 1 : 0);
    },
  },
  sso: {
    hideMobile: true,
    sort: (a, b, fullA, fullB) => {
      if (!fullA || !fullA) return -1;

      const checkA = fullA.type === APP_TYPES.LINK ? 0 : (fullA.ssoAuth && fullA.manifest.addons.oidc ? 1 : (fullA.ssoAuth && (!fullA.manifest.addons.oidc && !fullA.manifest.addons.email) ? 2 : (!fullA.ssoAuth && !fullA.manifest.addons.email ? 3 : 4)));
      const checkB = fullB.type === APP_TYPES.LINK ? 0 : (fullB.ssoAuth && fullB.manifest.addons.oidc ? 1 : (fullB.ssoAuth && (!fullB.manifest.addons.oidc && !fullB.manifest.addons.email) ? 2 : (!fullB.ssoAuth && !fullB.manifest.addons.email ? 3 : 4)));

      return checkA - checkB;
    },
    width: '30px',
  },
  status: {
    label: 'Status',
    hideMobile: true,
    sort: (a, b, fullA, fullB) => {
      if (!fullA || !fullA) return -1;
      const checkA = fullA.installationState + '-' + fullA.runState + '-' + fullA.health;
      const checkB = fullB.installationState + '-' + fullB.runState + '-' + fullB.health;
      return checkA < checkB ? -1 : (checkA > checkB ? 1 : 0);
    },
  },
  checklist: {},
  actions: {
    width: '100px',
  }
};

function createAppActionMenu(app) {
  return [{
    icon: 'fa-solid fa-arrow-up',
    label: t('app.updateAvailableTooltip'),
    visible: !!app.updateInfo,
    href: `#/app/${app.id}/updates`,
  }, {
    icon: 'fa-solid fa-cog',
    label: t('app.configureTooltip'),
    href: `#/app/${app.id}/info`,
    quickAction: true,
    visible: isOperator(app),
  }, {
    separator: true,
    visible: isOperator(app) || !!app.updateInfo,
  }, {
    icon: 'fa-solid fa-align-left',
    label: t('app.logsActionTooltip'),
    visible: app.type !== APP_TYPES.LINK,
    target: '_blank',
    href: '/logs.html?appId=' + app.id,
  }, {
    icon: 'fa-solid fa-terminal',
    label: t('app.terminalActionTooltip'),
    visible: app.type !== APP_TYPES.PROXIED && app.type !== APP_TYPES.LINK,
    target: '_blank',
    href: '/terminal.html?id=' + app.id,
  }, {
    icon: 'fa-solid fa-folder',
    label: t('app.filemanagerActionTooltip'),
    visible: !!app.manifest?.addons?.localstorage,
    target: '_blank',
    href: '/filemanager.html#/home/app/' + app.id,
  }];
}

function createAppLinkActionMenu(app) {
  return [{
    icon: 'fa-solid fa-cog',
    label: t('app.configureTooltip'),
    quickAction: true,
    visible: isOperator(app),
    action: openAppEdit.bind(null, app),
  }];
}

const filteredApps = computed(() => {
  return apps.value.filter(a => {
    if (a.type === APP_TYPES.LINK) {
        return a.upstreamUri.includes(filter.value);
    } else { // app or proxy
      return a.fqdn.includes(filter.value)
        || a.secondaryDomains.some(sd => sd.fqdn.includes(filter.value))
        || a.redirectDomains.some(rd => rd.fqdn.includes(filter.value))
        || a.aliasDomains.some(ad => ad.fqdn.includes(filter.value))
        || a.id.includes(filter.value)
        || (a.label ? a.label.toLowerCase().indexOf(filter.value.toLocaleLowerCase()) !== -1 : false)
        || a.manifest.title?.toLocaleLowerCase().includes(filter.value.toLocaleLowerCase()); // title is optional in manifest
    }
  }).filter(a => {
    if (!domainFilter.value) return true;
    if (a.type === APP_TYPES.LINK) return false;
    return a.domain === domainFilter.value
      || a.secondaryDomains.some(sd => sd.domain === domainFilter.value)
      || a.redirectDomains.some(rd => rd.domain === domainFilter.value)
      || a.aliasDomains.some(ad => ad.domain === domainFilter.value);
  }).filter(a => {
    if (!tagFilter.value) return true;
    return a.tags.indexOf(tagFilter.value) !== -1;
  }).filter(a => {
    if (!stateFilter.value) return true;
    if (a.type === APP_TYPES.LINK) return false;

    if (stateFilter.value === 'running') return a.runState === RSTATES.RUNNING && a.health === HSTATES.HEALTHY && a.installationState === ISTATES.INSTALLED;
    if (stateFilter.value === 'stopped') return a.runState === RSTATES.STOPPED;
    if (stateFilter.value === 'update_available') return a.updateInfo;

    return a.runState === RSTATES.RUNNING && (a.health !== HSTATES.HEALTHY || a.installationState !== ISTATES.INSTALLED); // not responding
  }).sort((a, b) => {
    const labelA = a.label || a.subdomain || a.fqdn;
    const labelB = b.label || b.subdomain || b.fqdn;

    return labelA.localeCompare(labelB);
  });
});

const applinkDialog = useTemplateRef('applinkDialog');
const postInstallDialog = useTemplateRef('postInstallDialog');
const searchInput = useTemplateRef('searchInput');

// hook for applinks otherwise it is a link
function openAppEdit(app, event) {
  if (app.type === APP_TYPES.LINK) {
    applinkDialog.value.open(app);
    event.preventDefault();
  }

  event.stopPropagation();
}

function onOpenApp(app, event) {
  function stopEvent() {
    event.stopPropagation();
    event.preventDefault();
  }

  if (app.installationState !== ISTATES.INSTALLED) {
    if (app.installationState === ISTATES.ERROR && isOperator(app)) window.location.href = `#/app/${app.id}/repair`;
    return stopEvent();
  }

  // app.health can also be null to indicate insufficient data
  if (!app.health) return stopEvent();
  if (app.runState === RSTATES.STOPPED) return stopEvent();

  if (app.health === HSTATES.UNHEALTHY || app.health === HSTATES.ERROR || app.health === HSTATES.DEAD) {
    if (isOperator(app)) window.location.href = `#/app/${app.id}/repair`;
    return stopEvent();
  }

  if (localStorage['confirmPostInstall_' + app.id]) {
    postInstallDialog.value.open(app, true);
    return stopEvent();
  }
}

function isOperator(app) {
  return app.accessLevel === 'operator' || app.accessLevel === 'admin';
}

async function refreshApps() {
  const [error, result] = await appsModel.list();
  if (error) return console.error(error);

  const [applinkError, applinks] = await applinksModel.list();
  if (applinkError) return console.error(applinkError);

  // amend properties to mimick full app
  for (const applink of applinks) {
    applink.type = APP_TYPES.LINK;
    applink.origin = applink.upstreamUri;
    applink.fqdn = applink.upstreamUri;
    applink.manifest = { addons: {}};
    applink.installationState = ISTATES.INSTALLED;
    applink.runState = RSTATES.RUNNING;
    applink.health = HSTATES.HEALTHY;
    applink.iconUrl = `${API_ORIGIN}/api/v1/applinks/${applink.id}/icon?access_token=${localStorage.token}&ts=${applink.ts}`;
    applink.accessLevel = profile.value.isAtLeastAdmin ? 'admin' : 'user';

    result.push(applink);
  }

  apps.value = result;

  // gets all tags used by all apps, flattens the arrays and new Set() will dedupe
  const tags = [...new Set(apps.value.map(a => a.tags).flat())].map(t => { return { id: t, name: t }; });
  tagFilterOptions.value = [{ id: '', name: 'All tags', }].concat(tags);
}

function toggleView() {
  viewType.value = viewType.value === VIEW_TYPE.LIST ? VIEW_TYPE.GRID : VIEW_TYPE.LIST;
  localStorage.appsView = viewType.value;
}

const view = useTemplateRef('view');
const itemWidth = ref('unset');

function setItemWidth() {
  if (!view.value) return;

  const width = view.value.offsetWidth;
  const gap = 25; // grid item margin offset

  if (width <= 360) itemWidth.value = '100%';
  else if (width <= 550) itemWidth.value = Number((width-gap*2)/2).toFixed() + 'px';
  else itemWidth.value = '190px';
}

function onKeyDownHandler(event) {
  if (event.key === 'Escape') filter.value = '';
}

function onAppsHashChange() {
  if (window.location.hash.split('?')[0] !== APPS_HASH_PATH) return;
  if (window.location.hash.indexOf('?') >= 0) return;
  filter.value = '';
  domainFilter.value = '';
  stateFilter.value = '';
  tagFilter.value = '';
}

watch(filter, () => scheduleSyncFilterToUrl());
watch(domainFilter, syncFiltersToUrl);
watch(stateFilter, syncFiltersToUrl);
watch(tagFilter, syncFiltersToUrl);

onActivated(async () => {
  setItemWidth();

  const qi = window.location.hash.indexOf('?');
  const params = qi >= 0 ? new URLSearchParams(window.location.hash.slice(qi + 1)) : new URLSearchParams();

  filter.value = params.get('search') ?? '';

  const stateParam = params.get('state') ?? '';
  stateFilter.value = STATE_FILTER_IDS.has(stateParam) ? stateParam : '';

  const domainParam = params.get('domain') ?? '';
  const domainExists = domainFilterOptions.value.some(opt => opt.id === domainParam);
  domainFilter.value = domainExists ? domainParam : '';

  const tagParam = params.get('tag') ?? '';
  const tagExists = tagFilterOptions.value.some(opt => opt.id === tagParam);
  tagFilter.value = tagExists ? tagParam : '';

  await refreshApps();
  ready.value = true;

  const [error, result] = await domainsModel.list();
  if (error) return console.error(error);

  domainFilterOptions.value = [{ id: '', domain: 'All domains', }].concat(result.map(d => { d.id = d.domain; return d; }));

  refreshInterval = setInterval(refreshApps, 5000);

  window.addEventListener('resize', setItemWidth);
  window.addEventListener('keydown', onKeyDownHandler);
  window.addEventListener('hashchange', onAppsHashChange);

  if (window.innerWidth > 575) setTimeout(() => searchInput.value.focus(), 0);
});

onDeactivated(() => {
  window.removeEventListener('keydown', onKeyDownHandler);
  window.removeEventListener('hashchange', onAppsHashChange);
  clearInterval(refreshInterval);
});

</script>

<template>
  <div ref="view" class="content-large">
    <ApplinkDialog ref="applinkDialog" @success="refreshApps"/>
    <PostInstallDialog ref="postInstallDialog"/>

    <h1 class="view-header">
      {{ $t('apps.title') }}
      <div style="display: flex; gap: 4px; flex-wrap: wrap; margin-top: 10px;">
        <TextInput v-model="filter" ref="searchInput" :placeholder="$t('apps.searchPlaceholder')" />
        <SingleSelect class="pankow-no-mobile" v-if="profile.isAtLeastAdmin" :search-threshold="10" :options="tagFilterOptions" option-key="id" option-label="name" v-model="tagFilter" />
        <SingleSelect class="pankow-no-mobile" v-if="profile.isAtLeastAdmin" :search-threshold="10" :options="stateFilterOptions" option-key="id" v-model="stateFilter" />
        <SingleSelect class="pankow-no-mobile" v-if="profile.isAtLeastAdmin" :search-threshold="10" :options="domainFilterOptions" option-key="id" option-label="domain" v-model="domainFilter" />
        <Button tool outline secondary @click="toggleView()" :icon="viewType === VIEW_TYPE.GRID ? 'fas fa-list' : 'fas fa-grip'"></Button>
      </div>
    </h1>

    <div v-if="!ready">
      <ProgressBar mode="indeterminate" :show-label="false" :slim="true" :show-track="false"/>
    </div>
    <div v-else>
      <div v-if="apps.length && filteredApps.length === 0" class="no-matches-placeholder">
        {{ $t('apps.noMatchesPlaceholder') }}
      </div>

      <TransitionGroup name="grid-animation" tag="div" class="grid" v-if="viewType === VIEW_TYPE.GRID">
        <a v-for="app in filteredApps" :key="app.id" class="grid-item" :class="{ 'item-inactive': app.runState === RSTATES.STOPPED }" @click="onOpenApp(app, $event)" :href="app.origin" target="_blank" :style="{ width: itemWidth }">
          <img :alt="app.label || app.subdomain || app.fqdn" :src="app.iconUrl" v-fallback-image="API_ORIGIN + '/img/appicon_fallback.png'"/>
          <div class="grid-item-label" v-fit-text>{{ app.label || app.subdomain || app.fqdn }}</div>
          <div class="grid-item-task-label" v-if="app.type === APP_TYPES.LINK">{{ $t('app.appLink.title') }}</div>
          <div class="grid-item-task-label" v-else>{{ AppsModel.installationStateLabel(app) }}</div>

          <ProgressBar v-if="app.progress && isOperator(app)" :busy="true" :show-track="false" :value="Math.max(10, app.progress)" :show-label="false" class="apps-progress"/>

          <a class="config" v-show="isOperator(app)" @click="openAppEdit(app, $event)" :href="`#/app/${app.id}/info`" :title="$t('app.configureTooltip')"><Icon icon="fa-solid fa-cog" /></a>
          <div class="grid-item-indictors">
            <a class="grid-item-update-indicator" v-if="app.updateInfo" @click.stop :href="isOperator(app) ? `#/app/${app.id}/updates` : null" v-tooltip="$t('app.updateAvailableTooltip')"><i class="fa-fw fa-solid fa-arrow-up"/></a>
            <a class="grid-item-checklist-indicator" v-if="AppsModel.pendingChecklistItems(app)" @click.stop :href="isOperator(app) ? `#/app/${app.id}/info` : null"><Icon icon="fa-solid fa-triangle-exclamation"/></a>
        </div>
        </a>
      </TransitionGroup>

      <div class="list" v-if="viewType === VIEW_TYPE.LIST && apps.length !== 0">

        <TableView :columns="listColumns" :model="filteredApps">
          <template #icon="{ item:app }">
            <a :href="app.origin" target="_blank">
              <img :alt="app.label || app.subdomain || app.fqdn" class="list-icon" :class="{ 'item-inactive': app.runState === RSTATES.STOPPED }" :src="app.iconUrl" v-fallback-image="API_ORIGIN + '/img/appicon_fallback.png'"/>
            </a>
          </template>
          <template #label="{ item:app }">
            <a :href="app.origin" target="_blank">
              {{ app.label || app.subdomain || app.fqdn }}
            </a>
          </template>
          <template #appTitle="{ item:app }">
            {{ app.manifest.title }}
          </template>
          <template #fqdn="{ item:app }">
            <a :href="app.origin" target="_blank">
              {{ app.fqdn }}
            </a>
          </template>
          <template #status="{ item:app }">
            <div class="list-status">
              {{ AppsModel.installationStateLabel(app) }}
              <ProgressBar v-if="app.progress && isOperator(app)" :busy="true" :value="Math.max(10, app.progress)" :show-label="false" class="apps-progress"/>
            </div>
          </template>
          <template #checklist="{ item:app }">
            <a class="list-item-checklist-indicator" v-if="AppsModel.pendingChecklistItems(app)" :href="`#/app/${app.id}/info`"><Icon icon="fa-solid fa-triangle-exclamation"/></a>
            <a class="list-item-update-indicator" v-if="app.updateInfo" @click.stop :href="isOperator(app) ? `#/app/${app.id}/updates` : null" v-tooltip="$t('app.updateAvailableTooltip')"><i class="fa-solid fa-arrow-up"/></a>
          </template>
          <template #sso="{ item:app }">
            <div v-show="app.type !== APP_TYPES.LINK">
              <Icon icon="fa-brands fa-openid" v-show="app.ssoAuth && app.manifest.addons.oidc" v-tooltip="$t('apps.auth.openid')" />
              <Icon icon="fas fa-user" v-show="app.ssoAuth && (!app.manifest.addons.oidc && !app.manifest.addons.email)" v-tooltip="$t('apps.auth.sso')" />
              <Icon icon="far fa-user" v-show="!app.ssoAuth && !app.manifest.addons.email" v-tooltip="$t('apps.auth.nosso')" />
              <Icon icon="fas fa-envelope" v-show="app.manifest.addons.email" v-tooltip="$t('apps.auth.email')" />
            </div>
          </template>
          <template #actions="{ item:app }">
            <ActionBar v-if="app.type === APP_TYPES.LINK" :actions="createAppLinkActionMenu(app)" />
            <ActionBar v-else :actions="createAppActionMenu(app)" />
          </template>
        </TableView>
      </div>

      <div v-if="apps.length === 0" class="empty-placeholder">
        <!-- admins or not-->
        <div v-if="profile.isAtLeastAdmin">
          <h4 style="font-weight: 400">{{ $t('apps.noApps.title') }}</h4>
          <h5 v-html="$t('apps.noApps.description', { appStoreLink: '#/appstore' })" style="font-weight: 400"></h5>
        </div>
        <div v-else>
          <h4>{{ $t('apps.noAccess.title') }}</h4>
          <h5>{{ $t('apps.noAccess.description') }}</h5>
        </div>
      </div>
    </div>
  </div>
</template>

<style scoped>

.action-button {
  visibility: hidden;
}

tr:hover .action-button {
  visibility: visible;
}

.grid-animation-move,
.grid-animation-enter-active,
.grid-animation-leave-active {
  transition: all 0.2s ease;
}

.grid-animation-enter-from {
  opacity: 0;
  transform: translateY(30px);
}

.grid-animation-leave-to {
  opacity: 0;
  transform: translateY(-30px);
}

.item-inactive {
  filter: grayscale(1);
}

.list-icon {
  width: 32px;
  height: 32px;
}

.list-status {
  position: relative;
  height: 100%;
  display: flex;
  align-items: center;
}

.grid-item-task-label {
  opacity: 0.7;
  font-size: 12px;
  text-overflow: ellipsis;
  white-space: nowrap;
  overflow: hidden;
  color: var(--pankow-text-color);
}

.apps-progress {
  position: absolute;
  bottom: 0;
  left: 0;
  width: 100%;
  height: 4px;
}

.list {
  margin-top: 30px;
}

.grid {
  display: flex;
  height: 100%;
  width: 100%;
  transition: 300ms;
  flex-wrap: wrap;
  justify-content: start;
  align-content: start;
}

.grid-item {
  position: relative;
  display: flex;
  flex-direction: column;
  justify-content: center;
  align-items: center;
  width: 190px;
  height: 180px;
  margin: 10px;
  overflow: hidden;
  border-radius: 10px;
  background-color: var(--card-background);
}

.grid-item:focus,
.grid-item:hover {
  box-shadow: 0px 2px 5px rgba(0, 0, 0, 0.1);
  background-color: var(--pankow-color-background-hover) !important;
  text-decoration: none;
}

.grid-item img {
  width: 80px;
  height: 80px;
  object-fit: cover;
}

.grid-item-label {
  font-size: 18px;
  font-weight: 100;
  margin: 5px 0 5px 0;
  color: var(--pankow-text-color);
  text-wrap: nowrap;
}

.config {
  position: absolute;
  color: var(--pankow-text-color);
  font-size: 18px;
  cursor: pointer;
  width: 50px;
  height: 50px;
  border-top-right-radius: 10px;
  right: 0;
  top: 0;
  opacity: 0;
  display: flex;
  justify-content: center;
  align-items: center;
}

.config:focus,
.config:hover {
  opacity: 1;
  color: var(--pankow-color-primary-hover);
}

@media (hover: none) {
  .config {
    opacity: 1;
  }
}

.grid-item:focus .config,
.grid-item:hover .config {
  opacity: 1;
}

.grid-item-indictors {
  position: absolute;
  left: 0;
  top: 0;
}

.grid-item-update-indicator {
  color: var(--pankow-color-success);
  font-size: 18px;
  width: 40px;
  height: 40px;
  border-top-right-radius: 10px;
  display: flex;
  justify-content: center;
  align-items: center;
}

.grid-item-update-indicator:focus,
.grid-item-update-indicator:hover {
  opacity: 1;
  color: var(--pankow-color-success-hover);
}

.grid-item-checklist-indicator {
  color: var(--pankow-color-danger);
  border-radius: 50px;
  font-size: 18px;
  width: 40px;
  height: 40px;
  display: flex;
  justify-content: center;
  align-items: center;
}

.list-item-update-indicator {
  color: var(--pankow-color-success);
}

.list-item-checklist-indicator {
  color: var(--pankow-color-danger);
}

.list-item-checklist-indicator:focus,
.list-item-checklist-indicator:hover,
.grid-item-checklist-indicator:focus,
.grid-item-checklist-indicator:hover {
  color: var(--pankow-color-danger-hover);
}

.empty-placeholder {
  font-size: 18px;
  margin: 10px;
}

.no-matches-placeholder {
  position: absolute;
  width: 100%;
  margin-top: 50px;
  text-align: center;
}

</style>
