/*
 * Copyright (C) 2018-2022 Open StApps
 * This program is free software: you can redistribute it and/or modify it
 * under the terms of the GNU General Public License as published by the Free
 * Software Foundation, version 3.
 *
 * This program is distributed in the hope that it will be useful, but WITHOUT
 * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
 * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
 * more details.
 *
 * You should have received a copy of the GNU General Public License along with
 * this program. If not, see <https://www.gnu.org/licenses/>.
 */
import {
  Api,
  Issue,
  IssueState,
  MembershipScope,
  MergeRequestState,
  Project,
  User,
} from '@openstapps/gitlab-api';
import {Logger} from '@openstapps/logger';
import mustache from 'mustache';
import path from 'path';
import {cwd} from 'process';
import {getProjects} from '../common.js';
import {BOLD_LABELS, CONCURRENCY, GROUPS, LABEL_WEIGHTS, NEXT_MEETING} from '../configuration.js';
import differenceInWeeks from 'date-fns/differenceInWeeks';
import formatISO from 'date-fns/formatISO';
import format from 'date-fns/format';
import de from 'date-fns/locale/de';
import {readFile, writeFile} from 'fs/promises';
import {mapAsyncLimit} from '@openstapps/collection-utils';

/**
 * A structure for template compilation
 */
export interface StructureForTemplate {
  /**
   * List of issues by assignee
   */
  issuesByAssignee: AssigneeWithIssues[];
  /**
   * Meeting day
   */
  meetingDay: string;
  /**
   * Timestamp, when the report was generated
   */
  timestamp: string;
}

/**
 * A map
 */
export interface IssuesGroupedByAssigneeId {
  [assigneeId: number]: AssigneeWithIssues;
}

/**
 * An assignee with assigned issues
 */
export interface AssigneeWithIssues {
  /**
   * Assignee of the issues
   */
  assignee: User;
  /**
   * Counts of issues
   */
  issueCounts: {
    /**
     * Count of closed issues
     */
    closed: number;
    /**
     * Count of opened issues
     */
    opened: number;
  };
  /**
   * List of issues
   */
  issues: IssueWithMeta[];
  /**
   * Quota of closed issues
   */
  quota: number;
}

/**
 * Issue with meta information
 */
export interface IssueWithMeta extends Issue {
  /**
   * Whether an issue branch exists
   */
  $branchExists: boolean;
  /**
   * Whether the issue is closed
   */
  $closed: boolean;
  /**
   * List of labels
   */
  $labels: Array<{
    /**
     * Whether to print the label bold
     */
    bold: boolean;
    /**
     * Actual label
     */
    label: string;
  }>;
  /**
   * URL of merge request
   */
  $mergeRequestUrl: string;
  /**
   * Name of the project
   */
  $project: string;
  /**
   * Number of weeks the issue is open
   */
  $weeksOpen: number;
}

/**
 * Merge request data
 */
export interface MergeRequestsForProjects {
  [projectId: string]: Array<{
    /**
     * IID of issue
     */
    issue_iid: number;
    /**
     * URL of issue
     */
    web_url: string;
  }>;
}

/**
 * Check if issue state is opened or closed
 * @param state State to check
 */
export function issueStateIsOpenedOrClosed(
  state: IssueState,
): state is IssueState.OPENED | IssueState.CLOSED {
  return ['opened', 'closed'].includes(state);
}

/**
 * Get merge request URLs from given data
 * @param projectMergeRequests Merge requests data (object containing array of objects)
 * @param projectId Project ID to get data about merge requests for
 * @param issueIid Issue IID in a specific project (relative ID, and not issue's GitLab API ID)
 */
export function getMergeRequestUrls(
  projectMergeRequests: MergeRequestsForProjects,
  projectId: number,
  issueIid: number,
): string[] {
  if (projectMergeRequests[projectId] === undefined || projectMergeRequests[projectId].length === 0) {
    return [];
  }

  return projectMergeRequests[projectId]
    .filter(object => object.issue_iid === issueIid)
    .map(object => object.web_url);
}

/**
 * Get issues from all groups with a specific milestone
 * @param api GitLab API to make requests with
 * @param label Label to filter by
 * @param groups List of groups to get issues for
 */
export async function getIssues(api: Api, label: string, groups: number[]): Promise<Issue[]> {
  const issueResults = await Promise.all(
    groups.map(groupId =>
      api.getIssues({
        groupId: groupId,
      }),
    ),
  );

  const issues = issueResults.flat().filter(issue => issue.labels.includes(label));

  Logger.log(`Fetched ${issues.length} issue(s).`);

  return issues;
}

/**
 * Get IDs of issues with branches for projects
 * @param api GitLab API To make requests with
 * @param projects List of projects
 */
export async function getIssueBranches(api: Api, projects: Project[]): Promise<{[k: string]: number[]}> {
  const projectBranches: {[k: string]: number[]} = {};

  await mapAsyncLimit(
    projects,
    async project => {
      const branches = await api.getBranchesForProject(project.id);

      // extract issue number from branch
      projectBranches[project.id] = branches
        .map(branch => {
          return branch.name.split('-')[0];
        })
        .filter(branchNameStart => {
          return branchNameStart.match(/^[0-9]+$/);
        })
        .map(branchNameStart => {
          return Number.parseInt(branchNameStart, 10);
        });
    },
    CONCURRENCY,
  );

  return projectBranches;
}

/**
 * Get issues grouped by assignees
 * @param api GitLab API to make requests with
 * @param label Label to generate the report for
 */
export async function getIssuesGroupedByAssignees(api: Api, label: string): Promise<AssigneeWithIssues[]> {
  const issuesByAssignee: IssuesGroupedByAssigneeId = {};

  const groups = await mapAsyncLimit(
    GROUPS,
    async groupId => {
      const subGroups = await api.getSubGroupsForGroup(groupId);
      return subGroups.map(group => {
        return group.id;
      });
    },
    CONCURRENCY,
  ).then(it => it.flat());
  groups.push(...groups, ...GROUPS);

  const [issues, projects] = await Promise.all([getIssues(api, label, groups), getProjects(api, groups)]);

  const issueBranches = await getIssueBranches(api, projects);
  const mergeRequests = await getMergeRequests(api, projects);

  for (const issue of issues) {
    if (issue.assignee === null) {
      Logger.warn('Issue without assignee!', issue.web_url);

      continue;
    }

    if (issuesByAssignee[issue.assignee.id] === undefined) {
      issuesByAssignee[issue.assignee.id] = {
        assignee: issue.assignee,
        issueCounts: {
          closed: 0,
          opened: 0,
        },
        issues: [],
        quota: 0,
      };
    }

    if (issue.state === IssueState.REOPENED) {
      issue.state = IssueState.OPENED;
    }

    const issueMeta = {
      $branchExists:
        issueBranches[issue.project_id] !== undefined && issueBranches[issue.project_id].includes(issue.iid),
      $closed: issue.state === IssueState.CLOSED,
      $issue: issue,
      $labels: issue.labels.map((issueLabel: string) => {
        return {
          bold: BOLD_LABELS.includes(issueLabel),
          label: issueLabel,
        };
      }),
      $mergeRequestUrl: getMergeRequestUrls(mergeRequests, issue.project_id, issue.iid)[0],
      $project: issue.web_url.replace('https://gitlab.com/', '').split('/-/issues/')[0],
      $weeksOpen: differenceInWeeks(Date.now(), new Date(issue.created_at)),
    };

    const issueWithMeta: IssueWithMeta = {
      ...issue,
      ...issueMeta,
    };

    if (issueStateIsOpenedOrClosed(issue.state)) {
      issuesByAssignee[issue.assignee.id].issueCounts[issue.state]++;
      issuesByAssignee[issue.assignee.id].issues.push(issueWithMeta);
    }
  }

  // calculate quota
  for (const _assigneeId in issuesByAssignee) {
    if (!issuesByAssignee.hasOwnProperty(_assigneeId)) {
      continue;
    }

    const assigneeId = Number.parseInt(_assigneeId, 10);

    issuesByAssignee[assigneeId].quota = Math.floor(
      (issuesByAssignee[assigneeId].issueCounts.closed /
        (issuesByAssignee[assigneeId].issueCounts.opened +
          // tslint:disable-next-line:no-magic-numbers
          issuesByAssignee[assigneeId].issueCounts.closed)) *
        100,
    );
  }

  // sort issues by weight of labels and status
  for (const _assigneeId in issuesByAssignee) {
    if (!issuesByAssignee.hasOwnProperty(_assigneeId)) {
      continue;
    }

    const assigneeId = Number.parseInt(_assigneeId, 10);

    issuesByAssignee[assigneeId].issues.sort((a, b) => {
      let weightA = 0;
      let weightB = 0;

      for (const issueLabel in LABEL_WEIGHTS) {
        if (!LABEL_WEIGHTS.hasOwnProperty(issueLabel)) {
          continue;
        }

        if (a.labels.includes(issueLabel)) {
          weightA += LABEL_WEIGHTS[issueLabel];
        }

        if (b.labels.includes(issueLabel)) {
          weightB += LABEL_WEIGHTS[issueLabel];
        }
      }

      if (a.state === IssueState.CLOSED) {
        // tslint:disable-next-line:no-magic-numbers
        weightA -= 10;
      }

      if (b.state === IssueState.CLOSED) {
        // tslint:disable-next-line:no-magic-numbers
        weightB -= 10;
      }

      return weightB - weightA;
    });
  }

  return Object.values(issuesByAssignee);
}

/**
 * Get next meeting day
 */
export function getNextMeetingDay() {
  const meetingDay = formatISO(NEXT_MEETING, {representation: 'date'});

  // log found meeting day
  Logger.info(`Generating report for '${meetingDay}' of '${GROUPS.length}' group(s)...`);

  return meetingDay;
}

/**
 * Get a list of merge requests for projects
 * @param api GitLab API to make requests with
 * @param projects List of projects
 */
export async function getMergeRequests(api: Api, projects: Project[]): Promise<MergeRequestsForProjects> {
  const projectMergeRequests: MergeRequestsForProjects = {};

  // iterate over projects
  await mapAsyncLimit(
    projects,
    async project => {
      // check if the project can have merge requests
      if (!project.merge_requests_enabled) {
        return;
      }

      // get all merge requests for the project
      const mergeRequests = await api.getMergeRequests(
        MembershipScope.PROJECTS,
        project.id,
        MergeRequestState.OPENED,
      );

      // extract issue number from merge request
      projectMergeRequests[project.id] = mergeRequests
        .map(mergeRequest => {
          // keep information about web url too
          return {issue_iid: mergeRequest.source_branch.split('-')[0], web_url: mergeRequest.web_url};
        })
        .filter(branchNameStartAndUrl => {
          return branchNameStartAndUrl.issue_iid.match(/^[0-9]+$/);
        })
        .map(branchNameStartAndUrl => {
          return {
            issue_iid: Number.parseInt(branchNameStartAndUrl.issue_iid, 10),
            web_url: branchNameStartAndUrl.web_url,
          };
        });
    },
    CONCURRENCY,
  );

  return projectMergeRequests;
}

/**
 * Generate a report
 * @param api GitLab API to make requests with
 * @param label Label to generate the report for
 * @param template Template to generate the report with
 */
export async function generateReport(api: Api, label: string, template: string): Promise<string> {
  const issuesGroupedByAssignee = await getIssuesGroupedByAssignees(api, label);

  const structureForTemplate: StructureForTemplate = {
    issuesByAssignee: issuesGroupedByAssignee,
    meetingDay: getNextMeetingDay(),
    timestamp: format(Date.now(), 'PPPPpp', {locale: de}),
  };

  return mustache.render(template, structureForTemplate);
}

/**
 * Generate a markdown report
 * @param api GitLab API to make requests with
 * @param label Label to generate the report for
 */
export async function report(api: Api, label: string) {
  const meetingDay = getNextMeetingDay();

  const readReportFile = await readFile(
    // eslint-disable-next-line unicorn/prefer-module
    path.resolve(__dirname, '..', '..', 'templates', 'report.md.mustache'),
  );

  const markdown = await generateReport(api, label, readReportFile.toString());

  let filename = path.join(cwd(), 'reports', `${meetingDay}.md`);

  if (label !== 'meeting') {
    filename = path.join(cwd(), 'reports', `${label}.md`);
  }

  await writeFile(filename, markdown);

  Logger.ok(`Wrote file '${filename}'.`);
}
