/*!
 * Copyright (c) 2021, 2023 Eclipse Foundation, Inc.
 *
 * This program and the accompanying materials are made available under the
 * terms of the Eclipse Public License v. 2.0 which is available at
 * http://www.eclipse.org/legal/epl-2.0.
 *
 * Contributors:
 *   Eric Poirier <eric.poirier@eclipse-foundation.org>
 *   Olivier Goulet <olivier.goulet@eclipse-foundation.org>
 *
 * SPDX-License-Identifier: EPL-2.0
 */

//@ts-ignore Types do not exist yet.
import { getProjectsData, getProjectProposals } from "../api/eclipsefdn.projects";
import renderTemplate from './templates/render-template';
import cardsTemplate from './templates/projects/cards.mustache';
import listTemplate from './templates/projects/list.mustache';
import tagFiltersPartial from './templates/partials/tag-filters.partial.mustache';
import searchPartial from './templates/partials/search.partial.mustache';
import { showAll, toggleVisibility, deactivateAll } from './utils';
import { isProjectProposal, isProject, toTitleCase, stringToBoolean } from '../shared/utils';
import { Project, ProjectProposal, getLatestProjectRelease } from '../types/models';

const templates = {
  default: cardsTemplate,
  list: listTemplate,
  'tpl-projects-item': cardsTemplate, // legacy support - it is now 'default'.
};

type ProjectState = 'regular' | 'incubating' | 'archived';
type ProjectProposalState = 'created' | 'draft' | 'withdrawn' | 'review';
type IncludeableState = ('all' | 'active') | Array<ProjectState | ProjectProposalState>;

interface Options {
  types: string[];
  url?: string;
  isStaticSource: boolean;
  pageSize?: number;
  sortingMethod: 'alphanumeric' | 'random';
  templateId: string;
  displayCategories: boolean;

  /** What project and project proposal states to include. Value can be 'all',
    * 'active' or an array of valid project or proposal states.
    */
  includeStates: IncludeableState;
}

const defaultOptions: Options = {
    types: ['projects', 'proposals'],
    url: '',
    isStaticSource: false,
    pageSize: null,
    sortingMethod: 'alphanumeric', 
    templateId: 'default',
    includeStates: 'active',
    displayCategories: false,
}

const validTypes = new Set(['projects', 'proposals']);

const render = async () => {
  const element: HTMLElement = document.querySelector('.eclipsefdn-projects') || document.querySelector('.featured-projects');
  if (!element) return;

  const options = { 
    ...defaultOptions, 
    ...element.dataset, 
    isStaticSource: element.dataset.isStaticSource === 'true',
    types: element.dataset.types
      ? element.dataset.types.split(',').map(t => t.trim())
      : defaultOptions.types,
    includeStates: element.dataset.includeStates ?
      element.dataset.includeStates
        .split(',')
        .map(t => t.trim())
        .reduce(includeableStateReducer, [])
      : 'active', // Use active as default,
    displayCategories: element.dataset.displayCategories ? stringToBoolean(element.dataset.displayCategories) : false,
  };
  
  // Deprecation messages
  if (options.templateId === 'tpl-projects-item') {
    console.warn('The "tpl-projects-item" template has been deprecated. Please remove the template-id data attribute if you intend to use the default projects template.');
  }

  // Validate options.types values. 
  options.types.some((type) => {
    if (!validTypes.has(type)) {
      console.error(`Invalid type provided to eclipsefdn-projects "${type}"`);
    }
  });

  let projects: Array<Project | ProjectProposal> = [];

  // Fetch projects.
  if (options.types.includes('projects')) {
    projects = await getProjectsData(options.url, options.isStaticSource);
  }

  // Fetch project proposals. We do not want to include static source projects
  // here.
  if (!options.isStaticSource && options.types.includes('proposals')) {
    const workingGroupId = extractWorkingGroupIdFromUrl(options.url);
    const [proposals] = await getProjectProposals(workingGroupId);
    if (proposals) projects = [
      ...projects, 
      ...proposals,
    ];
  }

  projects = projects
    .sort(comparators[options.sortingMethod])
    .filter(filterStates(options.includeStates))
    .slice(0, options.pageSize ?? projects.length)

  // Fetch tags (aka. categories).
  const tags = new Map(
    projects
      .flatMap((project: any) => isProjectProposal(project) ? 'proposal' : project.tags)
      .map((tag: string) => [tag, new Array()])
  );

  projects.forEach((project: any) => {
    // Sometimes tags are undefined.
    if (!project.tags) return;
    
    // Push the project to the tag-project map.
    project.tags.forEach((tag: string) => (tags.get(tag) as Array<any>)?.push(project));
  });

  await renderTemplate({
    element,
    templates,
    templateId: options.templateId,
    data: massageDataForTemplate(options.templateId, options.isStaticSource, { 
      projects, 
      filters: Array.from(tags.keys()),
      displayCategories: options.displayCategories,
    }),
    partials: {
      search: searchPartial,
      tagFilters: tagFiltersPartial,
    }
  });

  // Setup tag filters
  const filterButtons: HTMLElement[] = Array.from(element.querySelectorAll('.btn-filter-project'));
  filterButtons.forEach((button) => button.addEventListener('click', () => { 
    button.classList.toggle('active');
    filterProjectsByTag(element);
  }));

  // Setup search filter
  const searchBarElement: HTMLInputElement = element.querySelector('input.search');
  if (searchBarElement) {
    searchBarElement.addEventListener('input', () => {
      deactivateAll(filterButtons);
      filterProjectsBySearch(element, searchBarElement.value);
    });
  }
};

export default { render };

/**
  * Extracts the working_group search param from a project api url.
  */
const extractWorkingGroupIdFromUrl = (url: string): string => {
  if (!url) return;
  return new URL(url).searchParams.get('working_group');
}

/**
  * Filters project elements based on the active filter buttons.
  *
  * @param widget - The HTML element of the rendered widget.
  */
const filterProjectsByTag = (widget: HTMLElement) => {
  const projectElements: HTMLElement[] = Array.from(widget.querySelectorAll('.featured-project'));

  // Retrieve the active filters.
  const filterButtons: HTMLElement[] = Array.from(widget.querySelectorAll('.btn-filter-project'));
  const activeFilters = filterButtons.filter((button) => button.classList.contains('active')).map((button) => button.innerText ?? '');
  
  if (activeFilters.length === 0) {
    showAll(projectElements);
    return;
  }

  // Compare active filters to each project's tags. Hide the project elements
  // which do not have an active tag.
  projectElements.forEach((projectElement) => toggleVisibility(
    projectElement, 
    projectElement.dataset.tags
      .split(', ')
      .some((tag) => activeFilters.find((filter) => filter.toLowerCase() === tag.toLowerCase()))
    ));
}

const filterProjectsBySearch = (widget: HTMLElement, value: string) => {
  const projectElements: HTMLElement[] = Array.from(widget.querySelectorAll('.featured-project'));

  // If the search query is empty, show all projects.
  if (!value) {
    showAll(projectElements);
    return;
  }

  projectElements.forEach((projectElement) => toggleVisibility(
    projectElement,
    projectElement.dataset.name
      .toLowerCase()
      .includes(value.toLowerCase())
  ));
}

/** 
  * Maps the data in a shape accepted by a specified template.
  *
  * @param templateId - The specified template.
  * @param args - The arguments for the mapper function.
  *
  * @returns Mapped data accepted by the specified template.
  */
const massageDataForTemplate = (templateId: string, isStaticSource: boolean, data: any) => {
  if (isStaticSource) return data;

  return templateId in mappers
    ? mappers[templateId as keyof typeof mappers](data)
    : mappers.default(data);
};

interface DefaultMapperArgs {
  projects: Array<Project | ProjectProposal>;
  displayCategories: boolean;
};

interface ListMapperArgs {
  projects: Array<Project | ProjectProposal>;
  filters: string[];
};

interface DefaultTemplateData {
  items: Array<{
    name: string;
    tags: string;
    logo: string;
    summary: string;
    website_url: string;
    version: string;
    state: string;
    isProposal: boolean;
  }>,
}

interface ListTemplateData {
  projects: Array<{
    name: string;
    logo: string;
    pmiUrl: string;
    tags: string[];
    latestReleaseName?: string;
  }>,
  filters: string[]; 
}

/**
  * Data mapper functions for each respective template. Custom templates will
  * use the default mapper.
  */
const mappers: Record<keyof typeof templates, (...args: any) => any> = {
  default: ({ projects, ...args }: DefaultMapperArgs): DefaultTemplateData => {
    let items = projects.map((project) => {
      let tags = isProjectProposal(project) ? ['proposal'] : project.tags;
      if (tags.length === 0) {
        tags = ['Other Tools']
      }

      let version = 'none';
      if (isProject(project) && getLatestProjectRelease(project)) {
        version = getLatestProjectRelease(project).name;
      }

      return {
        version,
        name: project.name,
        summary: project.summary,
        logo: isProjectProposal(project) ? '' : project.logo,
        website_url: project.pmiUrl,
        state: project.status ? toTitleCase(project.status) : '',
        tags: tags.join(', '),
        isProposal: isProjectProposal(project),
      };
    });

    return { items, ...args };
  },
  list: ({ projects, ...args }: ListMapperArgs): ListTemplateData => {
    let mappedProjects = projects.map((project) => {
      let tags = isProject(project) ? project.tags : ['proposal'];
      tags.toString = () => tags.join(', ');

      return {
        name: project.name,
        summary: project.summary,
        logo: isProjectProposal(project) ? '' : project.logo,
        latestReleaseName: isProject(project) ? getLatestProjectRelease(project)?.name : undefined,
        tags: isProject(project) ? project.tags : ['proposal'],
        pmiUrl: project.pmiUrl,
      };
    });
    return {
      projects: mappedProjects,
      ...args,
    };
  },
  'tpl-projects-item': (args: DefaultMapperArgs) => {
    return mappers.default({...args}); 
  },
};

type Comparator = <T>(a: T, b: T) => number;
/** A record of comparator functions for sorting. */
const comparators: Record<Options['sortingMethod'], Comparator> = {
  random: (_projectA: any, _projectB: any) => 0.5 - Math.random(),
  alphanumeric: (projectA: any, projectB: any) => {
    if (projectA.name < projectB.name) return -1;
    if (projectB.name > projectB.name) return 1;
    return 0;
  },
};

const inactiveStates = new Set<ProjectProposalState | ProjectState>([
  'archived',
  'withdrawn',
]);

const filterStates = (states: IncludeableState) => {
  return (project: Project | ProjectProposal): boolean => {
    if (states === 'all') {
      // Return all projects regardless of status.
      return true; 
    } else if (states === 'active') {
      // If project has an inactive state, return false.
      return !inactiveStates.has(project.status);
    } else {
      if (!Array.isArray(states)) {
        throw new TypeError('Invalid state provided.');
      }

      // If the project's status is included in the desired states, return
      // true.
      return states.includes(project.status.toLowerCase() as ProjectState | ProjectProposalState);
    }
  }
}

/** Reduce the includeable state from the data attribute. */
const includeableStateReducer = (accumulator: IncludeableState, current: string): IncludeableState  => {
  // If 'all' or 'active' is provided, return it as a string.
  if (current === 'all' || current === 'active') {
    return current;
  }
  
  // If the previous run had an 'all' or 'active', the accumulator would be a
  // string and not an array. We should not have an array of includeable states
  // if 'all' or 'active' were specified.
  if (!Array.isArray(accumulator)) {
    throw new TypeError('Don\'t specify individual states if using "active" or "all".')
  }
  
  // If not 'all' or 'active', return an array with the includeable states. 
  accumulator.push(current as ProjectProposalState | ProjectState);
  return accumulator;
}
