/*
 * Copyright (C) 2018-2020 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 {Logger} from '@openstapps/logger';
import {
  AccessLevel,
  Branch,
  Discussion,
  Group,
  Issue,
  IssueState,
  Label,
  Member,
  MembershipScope,
  MergeRequest,
  MergeRequestApproval,
  MergeRequestState,
  Milestone,
  Note,
  Project,
  Scope,
  Tag,
  TreeFile,
} from './types.js';
import got from 'got';

export * from './types.js';

/**
 * Sleep for a number of milliseconds
 * @param ms Number of milliseconds to wait
 */
export async function sleep(ms: number): Promise<void> {
  return new Promise(resolve => setTimeout(resolve, ms));
}

/**
 * API request options
 */
export interface ApiRequestOptions {
  /**
   * Data to be sent with the request
   */
  data?: object | null;

  /**
   * HTTP verb to use for the request
   */
  method?: 'DELETE' | 'GET' | 'POST' | 'PUT';

  /**
   * Whether or not to retry on any error
   */
  retryOnAnyError?: boolean;

  /**
   * Amount of tries
   */
  tries?: number;
}

/**
 * GitLab API get issues options
 */
export interface ApiGetIssuesOptions {
  /**
   * Filter issues by group ID
   */
  groupId?: number;
  /**
   * Filter issues by milestone
   */
  milestone?: 'Backlog' | 'No Milestone';
  /**
   * Filter issues by state
   */
  state?: IssueState;
}

/**
 * GitLab API
 */
export class Api {
  /**
   * Private token
   */
  private readonly privateToken: string;

  /**
   * Root url
   */
  private readonly rootUrl: string;

  /**
   * Instantiate new GitLab API
   * @param rootUrl Root URL of the GitLab API
   * @param privateToken Private token for the GitLab API
   */
  constructor(rootUrl: string, privateToken: string) {
    this.rootUrl = rootUrl;
    this.privateToken = privateToken;
  }

  /**
   * Add member to a group or a project
   * @param scope MembershipScope of the ID
   * @param id ID of the group or project
   * @param userId ID of the user
   * @param accessLevel Access level for the new member in the scope
   */
  public async addMember(
    scope: MembershipScope,
    id: number,
    userId: number,
    accessLevel: AccessLevel,
  ): Promise<Member> {
    return this.makeGitLabAPIRequest<Member>(`${scope}/${id}/members`, {
      data: {
        access_level: accessLevel,
        user_id: userId,
      },
      method: 'POST',
    });
  }

  /**
   * Create an issue in GitLab
   * @param projectId ID of the project to create the issue in
   * @param title Title of the issue
   * @param description Description of the issue (can contain slash commands)
   */
  public async createIssue(projectId: number, title: string, description: string): Promise<Issue> {
    return this.makeGitLabAPIRequest<Issue>(`projects/${projectId}/issues`, {
      data: {
        description: description,
        title: title,
      },
      method: 'POST',
    });
  }

  /**
   * Create a new label
   * @param projectId ID of the project to create the label in
   * @param name Name of the label to create
   * @param description Description of the label to create
   * @param color Color of the label to create
   */
  public async createLabel(
    projectId: number,
    name: string,
    description?: string,
    color?: string,
  ): Promise<Label> {
    let _color = '#000000';
    if (typeof color !== 'string' || !/^#[0-9a-fA-F]{3,6}$/.test(color)) {
      _color = '#000000';
    }

    return this.makeGitLabAPIRequest<Label>(`projects/${projectId}/labels`, {
      data: {
        color: _color,
        description,
        name,
      },
      method: 'POST',
    });
  }

  /**
   * Create a milestone in a project
   * @param projectId Project ID to create milestone in
   * @param title Title of the milestone to create
   */
  public async createMilestone(projectId: number, title: string): Promise<void> {
    return this.makeGitLabAPIRequest<void>(`projects/${projectId}/milestones?title=${title}`, {
      method: 'POST',
    });
  }

  /**
   * Create a note (comment) in an issue/merge request
   * @param projectId Project ID, the issue belongs to
   * @param scope Scope of the note
   * @param iid IID of the issue/merge request to create the note in
   * @param body Body of the note to create
   */
  public async createNote(projectId: number, scope: Scope, iid: number, body: string): Promise<void> {
    return this.makeGitLabAPIRequest<void>(`projects/${projectId}/${scope}/${iid}/notes`, {
      data: {
        body,
      },
      method: 'POST',
    });
  }

  /**
   * Delete a label
   * @param projectId ID of the project to delete the label from
   * @param name Name of the label to delete
   */
  public async deleteLabel(projectId: number, name: string): Promise<void> {
    return this.makeGitLabAPIRequest<void>(`projects/${projectId}/labels?name=${name}`, {
      method: 'DELETE',
    });
  }

  /**
   * Delete a member from a group or a project
   * @param scope MembershipScope of the ID
   * @param id ID of the group or project
   * @param userId ID of the user
   */
  public async deleteMember(scope: MembershipScope, id: number, userId: number): Promise<void> {
    return this.makeGitLabAPIRequest<void>(`${scope}/${id}/members/${userId}`, {method: 'DELETE'});
  }

  /**
   * Edit an existing label
   * @param projectId ID of the project to edit the label in
   * @param name Name of the label to edit
   * @param newValues New values for the label
   */
  public async editLabel(projectId: number, name: string, newValues: Partial<Label>): Promise<Label> {
    if (typeof newValues.color === 'string' && !/^#[0-9a-fA-F]{3,6}$/.test(newValues.color)) {
      newValues.color = undefined;
    }

    return this.makeGitLabAPIRequest<Label>(`projects/${projectId}/labels`, {
      data: {
        color: newValues.color,
        description: newValues.description,
        name: name,
        new_name: newValues.name,
      },
      method: 'POST',
    });
  }

  /**
   * Edit member in a group or a project
   * @param scope MembershipScope of the ID
   * @param id ID of the group or project
   * @param userId ID of the user
   * @param accessLevel Access level for the member in the scope
   */
  public async editMember(
    scope: MembershipScope,
    id: number,
    userId: number,
    accessLevel: AccessLevel,
  ): Promise<Member> {
    return this.makeGitLabAPIRequest<Member>(`${scope}/${id}/members`, {
      data: {
        access_level: accessLevel,
        user_id: userId,
      },
      method: 'PUT',
    });
  }

  /**
   * Get branches for a project
   * @param projectId Project ID to get branches for
   */
  public async getBranchesForProject(projectId: number): Promise<Branch[]> {
    return this.makeGitLabAPIRequest<Branch[]>(`projects/${projectId}/repository/branches`);
  }

  /**
   * Get a file from GitLab
   * @param projectId ID of the project the file belongs to
   * @param filePath Path to the file - url encoded
   * @param commitish Commitish of the file
   */
  public async getFile(projectId: number, filePath: string, commitish: string): Promise<unknown> {
    const fileIdentifier = `${encodeURIComponent(filePath).replace('.', '%2E')}/raw?ref=${encodeURIComponent(
      commitish,
    )}`;

    return this.makeGitLabAPIRequest(`projects/${projectId}/repository/files/${fileIdentifier}`);
  }

  /**
   * Get a list of files
   * @param projectId ID of the project
   */
  public async getFileList(projectId: number): Promise<TreeFile[]> {
    return this.makeGitLabAPIRequest<TreeFile[]>(`projects/${projectId}/repository/tree`);
  }

  /**
   * Get issues
   * @param options Options to get issues
   */
  public async getIssues(options: ApiGetIssuesOptions = {}): Promise<Issue[]> {
    // start to build request url
    let requestUrl = 'issues';

    // set initial divider for filter params
    let divider = '?';

    // request issues only for specific group, if group ID is set
    if (typeof options.groupId === 'number') {
      requestUrl = `groups/${options.groupId}/${requestUrl}`;
    }

    if (typeof options.milestone === 'string') {
      // add milestone to request url
      requestUrl += `${divider}milestone=${options.milestone}`;
      divider = '&';
    }

    // request issues only for specific state, if state is set
    if (typeof options.state === 'string') {
      requestUrl += `${divider}state=${options.state}`;
      // divider = '&';
    }

    return this.makeGitLabAPIRequest<Issue[]>(requestUrl);
  }

  /**
   * Get labels of a project
   * @param projectId ID of the project to get the labels for
   */
  public async getLabels(projectId: number): Promise<Label[]> {
    return this.makeGitLabAPIRequest<Label[]>(`projects/${projectId}/labels`);
  }

  /**
   * Get members of a group or a project
   * @param scope MembershipScope of the ID
   * @param id ID of the group or project
   */
  public async getMembers(scope: MembershipScope, id: number): Promise<Member[]> {
    return this.makeGitLabAPIRequest<Member[]>(`${scope}/${id}/members`);
  }

  /**
   * Get a merge request approval
   * @param projectId ID of the project the merge request belongs to
   * @param mergeRequestIid IID of the merge request
   */
  public async getMergeRequestApproval(
    projectId: number,
    mergeRequestIid: number,
  ): Promise<MergeRequestApproval> {
    return this.makeGitLabAPIRequest<MergeRequestApproval>(
      `/projects/${projectId}/merge_requests/${mergeRequestIid}/approvals`,
    );
  }

  /**
   * Get discussions of a merge request
   * @param projectId ID of the project the merge request belongs to
   * @param mergeRequestIid IID of the merge request
   */
  public async getMergeRequestDiscussions(projectId: number, mergeRequestIid: number): Promise<Discussion[]> {
    return this.makeGitLabAPIRequest<Discussion[]>(
      `projects/${projectId}/merge_requests/${mergeRequestIid}/discussions`,
    );
  }

  /**
   * Get merge requests of a group or a project
   * @param scope MembershipScope of the ID
   * @param id ID of the group or project
   * @param state State to filter the merge requests by
   */
  public async getMergeRequests(
    scope: MembershipScope,
    id: number,
    state: MergeRequestState | MergeRequestState[],
  ): Promise<MergeRequest[]> {
    let _state = state;

    // join a list of states with commas
    if (Array.isArray(state)) {
      _state = state.join(',') as MergeRequestState;
    }

    return this.makeGitLabAPIRequest<MergeRequest[]>(`${scope}/${id}/merge_requests?state=${_state}`);
  }

  /**
   * Get milestones for a project
   * @param projectId Project ID to get milestones for
   */
  public async getMilestonesForProject(projectId: number): Promise<Milestone[]> {
    return this.makeGitLabAPIRequest<Milestone[]>(`projects/${projectId}/milestones`);
  }

  /**
   * Get notes for issue
   * @param projectId Project ID of issue to get notes for
   * @param issue Issue to get notes for
   */
  public async getNotes(projectId: number, issue: Issue) {
    return this.makeGitLabAPIRequest<Note[]>(`/projects/${projectId}/issues/${issue.iid}/notes?sort=asc`);
  }

  /**
   * Get projects for a group
   * @param groupId Group ID to get projects for
   */
  public async getProjectsForGroup(groupId: number): Promise<Project[]> {
    return this.makeGitLabAPIRequest<Project[]>(`groups/${groupId}/projects`);
  }

  /**
   * Get sub groups of a group
   * @param groupId Group ID to get subgroups for
   */
  public async getSubGroupsForGroup(groupId: number): Promise<Group[]> {
    return this.makeGitLabAPIRequest<Group[]>(`/groups/${groupId}/subgroups`);
  }

  /**
   * Get tags of a project
   * @param projectId ID of the project to get the tags for
   */
  public async getTags(projectId: number): Promise<Tag[]> {
    return this.makeGitLabAPIRequest<Tag[]>(`projects/${projectId}/repository/tags`);
  }

  /**
   * Query a GitLab API URL
   * @param url GitLab API URL to query
   * @param options HTTP method/verb
   */
  public async makeGitLabAPIRequest<T = unknown>(url: string, options?: ApiRequestOptions): Promise<T> {
    // remove leading slash
    const _url = url.replaceAll(/^\/+/g, '');

    const _options: Required<ApiRequestOptions> = {
      // eslint-disable-next-line unicorn/no-null
      data: null,
      method: 'GET',
      retryOnAnyError: false,
      tries: 5,
      ...options,
    };

    if (!['DELETE', 'GET', 'POST', 'PUT'].includes(_options.method)) {
      _options.method = 'GET';
    }

    let concatenator = '&';
    if (!_url.includes('?')) {
      concatenator = '?';
    }

    let apiResult: unknown;
    let totalPages = 1;
    let currentPage = 0;

    while (++currentPage <= totalPages) {
      if (currentPage > 1) {
        Logger.info(
          `Automatically paging call to '${_url}'... Getting page ${currentPage} of ${totalPages}.`,
        );
      }

      const requestUrl = `${_url}${concatenator}page=${currentPage}&per_page=100`;

      const body = await got(`${this.rootUrl}${requestUrl}`, {
        form: _options.data === null ? undefined : _options.data,
        headers: {'PRIVATE-TOKEN': this.privateToken},
        method: _options.method,
        retry: {
          limit: _options.tries,
        },
      })
        .on('response', response => {
          const xTotalPages = response.headers['x-total-pages'];

          if (typeof xTotalPages === 'string') {
            totalPages = Number.parseInt(xTotalPages, 10);
          }
        })
        .json<unknown[]>();

      if (_options.method === 'DELETE') {
        return void 0 as unknown as T;
      }

      apiResult =
        apiResult !== undefined && Array.isArray(apiResult) && currentPage > 1
          ? (apiResult = [...apiResult, ...body])
          : (apiResult = body);
    }

    return apiResult as T;
  }

  /**
   * Protect a branch
   * @param projectId ID of the project the branch belongs to
   * @param branch Branch to protect
   */
  public async protectBranch(projectId: number, branch: string): Promise<Branch> {
    return this.makeGitLabAPIRequest<Branch>(
      `projects/${projectId}/repository/branches/${branch}/protect?developers_can_push=false&developers_can_merge=false`,
      {
        method: 'PUT',
      },
    );
  }

  /**
   * Set assignee for an issue
   * @param issue Issue to set assignee for
   * @param userId ID of the assignee to set for the issue
   */
  public async setAssigneeForIssue(issue: Issue, userId: number): Promise<Issue> {
    return this.makeGitLabAPIRequest<Issue>(
      `projects/${issue.project_id}/issues/${issue.iid}?assignee_ids=${userId}`,
      {
        method: 'PUT',
      },
    );
  }

  /**
   * Set assignee for an merge request
   * @param mergeRequest Merge request to set assignee for
   * @param userId ID of the assignee to set for the merge request
   */
  public async setAssigneeForMergeRequest(mergeRequest: MergeRequest, userId: number): Promise<MergeRequest> {
    return this.makeGitLabAPIRequest<MergeRequest>(
      `projects/${mergeRequest.project_id}/merge_requests/${mergeRequest.iid}?assignee_ids=${userId}`,
      {
        method: 'PUT',
      },
    );
  }

  /**
   * Set milestone for an issue
   * @param issue Issue to set milestone for
   * @param milestoneId ID of the milestone to set for the issue
   */
  public async setMilestoneForIssue(issue: Issue, milestoneId: number): Promise<Issue> {
    if (milestoneId === null) {
      return this.makeGitLabAPIRequest<Issue>(
        `projects/${issue.project_id}/issues/${issue.iid}?milestone_id=`,
        {
          method: 'PUT',
        },
      );
    }

    return this.makeGitLabAPIRequest<Issue>(
      `projects/${issue.project_id}/issues/${issue.iid}?milestone_id=${milestoneId}`,
      {
        method: 'PUT',
      },
    );
  }
}
