import { URL } from 'url';
import { GotResponse } from '..';
import { partial } from '../../../test/util';
import { PR_STATE_CLOSED } from '../../constants/pull-requests';
import { GiteaGotApi, GiteaGotOptions } from './gitea-got-wrapper';
import * as ght from './gitea-helper';
import { PRSearchParams } from './gitea-helper';

describe('platform/gitea/gitea-helper', () => {
  let helper: typeof import('./gitea-helper');
  let api: jest.Mocked<GiteaGotApi>;

  const baseURL = 'https://gitea.renovatebot.com/api/v1';

  const mockCommitHash = '0d9c7726c3d628b7e28af234595cfd20febdbf8e';

  const mockUser: ght.User = {
    id: 1,
    username: 'admin',
    full_name: 'The Administrator',
    email: 'admin@example.com',
  };

  const otherMockUser: ght.User = {
    ...mockUser,
    username: 'renovate',
    full_name: 'Renovate Bot',
    email: 'renovate@example.com',
  };

  const mockRepo: ght.Repo = {
    allow_rebase: true,
    allow_rebase_explicit: true,
    allow_merge_commits: true,
    allow_squash_merge: true,
    clone_url: 'https://gitea.renovatebot.com/some/repo.git',
    default_branch: 'master',
    full_name: 'some/repo',
    archived: false,
    mirror: false,
    empty: false,
    fork: false,
    owner: mockUser,
    permissions: {
      pull: true,
      push: true,
      admin: false,
    },
  };

  const otherMockRepo: ght.Repo = {
    ...mockRepo,
    full_name: 'other/repo',
    clone_url: 'https://gitea.renovatebot.com/other/repo.git',
  };

  const mockLabel: ght.Label = {
    id: 100,
    name: 'some-label',
    description: 'just a label',
    color: '#000000',
  };

  const otherMockLabel: ght.Label = {
    ...mockLabel,
    id: 200,
    name: 'other-label',
  };

  const mockPR: ght.PR = {
    number: 13,
    state: 'open',
    title: 'Some PR',
    body: 'Lorem ipsum dolor sit amet',
    mergeable: true,
    diff_url: `https://gitea.renovatebot.com/${mockRepo.full_name}/pulls/13.diff`,
    base: { ref: mockRepo.default_branch },
    head: {
      ref: 'pull-req-13',
      sha: mockCommitHash,
      repo: mockRepo,
    },
    created_at: '2018-08-13T20:45:37Z',
    closed_at: '2020-04-01T19:19:22Z',
  };

  const mockIssue: ght.Issue = {
    number: 7,
    state: 'open',
    title: 'Some Issue',
    body: 'just some issue',
    assignees: [mockUser],
  };

  const mockComment: ght.Comment = {
    id: 31,
    body: 'some-comment',
  };

  const mockCommitStatus: ght.CommitStatus = {
    id: 121,
    status: 'success',
    context: 'some-context',
    description: 'some-description',
    target_url: 'https://gitea.renovatebot.com/commit-status',
    created_at: '2020-03-25T00:00:00Z',
  };

  const otherMockCommitStatus: ght.CommitStatus = {
    ...mockCommitStatus,
    id: 242,
    status: 'error',
    context: 'other-context',
  };

  const mockCommit: ght.Commit = {
    id: mockCommitHash,
    author: {
      name: otherMockUser.full_name,
      email: otherMockUser.email,
      username: otherMockUser.username,
    },
  };

  const mockBranch: ght.Branch = {
    name: 'some-branch',
    commit: mockCommit,
  };

  const otherMockBranch: ght.Branch = {
    ...mockBranch,
    name: 'other/branch/with/slashes',
  };

  const mockContents: ght.RepoContents = {
    path: 'dummy.txt',
    content: Buffer.from('top secret').toString('base64'),
    contentString: 'top secret',
  };

  const otherMockContents: ght.RepoContents = {
    ...mockContents,
    path: 'nested/path/dummy.txt',
  };

  const mockAPI = <B extends object = undefined, P extends object = {}>(
    userOptions: {
      method?: 'get' | 'post' | 'put' | 'patch' | 'head' | 'delete';
      urlPattern?: string | RegExp;
      queryParams?: Record<string, string[]>;
      postParams?: P;
    },
    body: B = undefined
  ) => {
    // Merge default options with user options
    const options = {
      method: 'get',
      ...userOptions,
    };

    // Mock request implementation once and verify request
    api[options.method].mockImplementationOnce(
      (rawUrl: string, apiOpts?: GiteaGotOptions): Promise<GotResponse<B>> => {
        // Construct and parse absolute URL
        const absoluteUrl = rawUrl.includes('://')
          ? rawUrl
          : `${baseURL}/${rawUrl}`;
        const url = new URL(absoluteUrl);

        // Check optional URL pattern matcher
        if (options.urlPattern !== undefined) {
          const regex =
            options.urlPattern instanceof RegExp
              ? options.urlPattern
              : new RegExp(`^${options.urlPattern}$`);

          if (!regex.exec(url.pathname)) {
            throw new Error(
              `expected url [${url.pathname}] to match pattern: ${options.urlPattern}`
            );
          }
        }

        // Check optional query params
        if (options.queryParams !== undefined) {
          for (const [key, expected] of Object.entries(options.queryParams)) {
            expect(url.searchParams.getAll(key)).toEqual(expected);
          }
        }

        // Check optional post parameters
        if (options.postParams !== undefined) {
          expect(apiOpts.body).toEqual(options.postParams);
        }

        return Promise.resolve(
          partial<GotResponse<B>>({ body })
        );
      }
    );
  };

  beforeEach(async () => {
    jest.resetAllMocks();
    jest.mock('./gitea-got-wrapper');

    helper = (await import('./gitea-helper')) as any;
    api = (await import('./gitea-got-wrapper')).api as any;
  });

  describe('getCurrentUser', () => {
    it('should call /api/v1/user endpoint', async () => {
      mockAPI<ght.User>({ urlPattern: '/api/v1/user' }, mockUser);

      const res = await helper.getCurrentUser();
      expect(res).toEqual(mockUser);
    });
  });

  describe('searchRepos', () => {
    it('should call /api/v1/repos/search endpoint', async () => {
      mockAPI<ght.RepoSearchResults>(
        { urlPattern: '/api/v1/repos/search' },
        {
          ok: true,
          data: [mockRepo, otherMockRepo],
        }
      );

      const res = await helper.searchRepos({});
      expect(res).toEqual([mockRepo, otherMockRepo]);
    });

    it('should construct proper query parameters', async () => {
      mockAPI<ght.RepoSearchResults>(
        {
          urlPattern: '/api/v1/repos/search',
          queryParams: {
            uid: ['13'],
          },
        },
        {
          ok: true,
          data: [otherMockRepo],
        }
      );

      const res = await helper.searchRepos({
        uid: 13,
      });
      expect(res).toEqual([otherMockRepo]);
    });

    it('should abort if ok flag was not set', async () => {
      mockAPI<ght.RepoSearchResults>(
        { urlPattern: '/api/v1/repos/search' },
        {
          ok: false,
          data: [],
        }
      );

      await expect(helper.searchRepos({})).rejects.toThrow();
    });
  });

  describe('getRepo', () => {
    it('should call /api/v1/repos/[repo] endpoint', async () => {
      mockAPI<ght.Repo>(
        { urlPattern: `/api/v1/repos/${mockRepo.full_name}` },
        mockRepo
      );

      const res = await helper.getRepo(mockRepo.full_name);
      expect(res).toEqual(mockRepo);
    });
  });

  describe('getRepoContents', () => {
    it('should call /api/v1/repos/[repo]/contents/[file] endpoint', async () => {
      // The official API only returns the base64-encoded content, so we strip `contentString`
      // from our mock to verify base64 decoding.
      mockAPI<ght.RepoContents>(
        {
          urlPattern: `/api/v1/repos/${mockRepo.full_name}/contents/${mockContents.path}`,
        },
        { ...mockContents, contentString: undefined }
      );

      const res = await helper.getRepoContents(
        mockRepo.full_name,
        mockContents.path
      );
      expect(res).toEqual(mockContents);
    });

    it('should support passing reference by query', async () => {
      mockAPI<ght.RepoContents>(
        {
          urlPattern: `/api/v1/repos/${mockRepo.full_name}/contents/${mockContents.path}`,
          queryParams: {
            ref: [mockCommitHash],
          },
        },
        { ...mockContents, contentString: undefined }
      );

      const res = await helper.getRepoContents(
        mockRepo.full_name,
        mockContents.path,
        mockCommitHash
      );
      expect(res).toEqual(mockContents);
    });

    it('should properly escape paths', async () => {
      const escapedPath = encodeURIComponent(otherMockContents.path);

      mockAPI<ght.RepoContents>(
        {
          urlPattern: `/api/v1/repos/${mockRepo.full_name}/contents/${escapedPath}`,
        },
        otherMockContents
      );

      const res = await helper.getRepoContents(
        mockRepo.full_name,
        otherMockContents.path
      );
      expect(res).toEqual(otherMockContents);
    });

    it('should not fail if no content is returned', async () => {
      mockAPI<ght.RepoContents>(
        {
          urlPattern: `/api/v1/repos/${mockRepo.full_name}/contents/${mockContents.path}`,
        },
        { ...mockContents, content: undefined, contentString: undefined }
      );

      const res = await helper.getRepoContents(
        mockRepo.full_name,
        mockContents.path
      );
      expect(res).toEqual({
        ...mockContents,
        content: undefined,
        contentString: undefined,
      });
    });
  });

  describe('createPR', () => {
    it('should call /api/v1/repos/[repo]/pulls endpoint', async () => {
      mockAPI<ght.PR, Required<ght.PRCreateParams>>(
        {
          method: 'post',
          urlPattern: `/api/v1/repos/${mockRepo.full_name}/pulls`,
          postParams: {
            state: mockPR.state,
            title: mockPR.title,
            body: mockPR.body,
            base: mockPR.base.ref,
            head: mockPR.head.ref,
            assignees: [mockUser.username],
            labels: [mockLabel.id],
          },
        },
        mockPR
      );

      const res = await helper.createPR(mockRepo.full_name, {
        state: mockPR.state,
        title: mockPR.title,
        body: mockPR.body,
        base: mockPR.base.ref,
        head: mockPR.head.ref,
        assignees: [mockUser.username],
        labels: [mockLabel.id],
      });
      expect(res).toEqual(mockPR);
    });
  });

  describe('updatePR', () => {
    it('should call /api/v1/repos/[repo]/pulls/[pull] endpoint', async () => {
      const updatedMockPR: ght.PR = {
        ...mockPR,
        state: 'closed',
        title: 'new-title',
        body: 'new-body',
      };

      mockAPI<ght.PR, Required<ght.PRUpdateParams>>(
        {
          method: 'patch',
          urlPattern: `/api/v1/repos/${mockRepo.full_name}/pulls/${mockPR.number}`,
          postParams: {
            state: 'closed',
            title: 'new-title',
            body: 'new-body',
            assignees: [otherMockUser.username],
            labels: [otherMockLabel.id],
          },
        },
        updatedMockPR
      );

      const res = await helper.updatePR(mockRepo.full_name, mockPR.number, {
        state: PR_STATE_CLOSED,
        title: 'new-title',
        body: 'new-body',
        assignees: [otherMockUser.username],
        labels: [otherMockLabel.id],
      });
      expect(res).toEqual(updatedMockPR);
    });
  });

  describe('closePR', () => {
    it('should call /api/v1/repos/[repo]/pulls/[pull] endpoint', async () => {
      mockAPI<undefined, ght.PRUpdateParams>({
        method: 'patch',
        urlPattern: `/api/v1/repos/${mockRepo.full_name}/pulls/${mockPR.number}`,
        postParams: {
          state: 'closed',
        },
      });

      const res = await helper.closePR(mockRepo.full_name, mockPR.number);
      expect(res).toBeUndefined();
    });
  });

  describe('mergePR', () => {
    it('should call /api/v1/repos/[repo]/pulls/[pull]/merge endpoint', async () => {
      mockAPI<undefined, ght.PRMergeParams>({
        method: 'patch',
        urlPattern: `/api/v1/repos/${mockRepo.full_name}/pulls/${mockPR.number}/merge`,
        postParams: {
          Do: 'rebase',
        },
      });

      const res = await helper.mergePR(
        mockRepo.full_name,
        mockPR.number,
        'rebase'
      );
      expect(res).toBeUndefined();
    });
  });

  describe('getPR', () => {
    it('should call /api/v1/repos/[repo]/pulls/[pull] endpoint', async () => {
      mockAPI<ght.PR>(
        {
          urlPattern: `/api/v1/repos/${mockRepo.full_name}/pulls/${mockPR.number}`,
        },
        mockPR
      );

      const res = await helper.getPR(mockRepo.full_name, mockPR.number);
      expect(res).toEqual(mockPR);
    });
  });

  describe('searchPRs', () => {
    it('should call /api/v1/repos/[repo]/pulls endpoint', async () => {
      mockAPI<ght.PR[]>(
        {
          urlPattern: `/api/v1/repos/${mockRepo.full_name}/pulls`,
        },
        [mockPR]
      );

      const res = await helper.searchPRs(mockRepo.full_name, {});
      expect(res).toEqual([mockPR]);
    });

    it('should construct proper query parameters', async () => {
      mockAPI<ght.PR[], Required<PRSearchParams>>(
        {
          urlPattern: `/api/v1/repos/${mockRepo.full_name}/pulls`,
          queryParams: {
            state: ['open'],
            labels: [`${mockLabel.id}`, `${otherMockLabel.id}`],
          },
        },
        [mockPR]
      );

      const res = await helper.searchPRs(mockRepo.full_name, {
        state: 'open',
        labels: [mockLabel.id, otherMockLabel.id],
      });
      expect(res).toEqual([mockPR]);
    });
  });

  describe('createIssue', () => {
    it('should call /api/v1/repos/[repo]/issues endpoint', async () => {
      mockAPI<ght.Issue, Required<ght.IssueCreateParams>>(
        {
          method: 'post',
          urlPattern: `/api/v1/repos/${mockRepo.full_name}/issues`,
          postParams: {
            state: mockIssue.state,
            title: mockIssue.title,
            body: mockIssue.body,
            assignees: [mockUser.username],
          },
        },
        mockIssue
      );

      const res = await helper.createIssue(mockRepo.full_name, {
        state: mockIssue.state,
        title: mockIssue.title,
        body: mockIssue.body,
        assignees: [mockUser.username],
      });
      expect(res).toEqual(mockIssue);
    });
  });

  describe('updateIssue', () => {
    it('should call /api/v1/repos/[repo]/issues/[issue] endpoint', async () => {
      const updatedMockIssue: ght.Issue = {
        ...mockIssue,
        state: 'closed',
        title: 'new-title',
        body: 'new-body',
        assignees: [otherMockUser],
      };

      mockAPI<ght.Issue, Required<ght.IssueUpdateParams>>(
        {
          method: 'patch',
          urlPattern: `/api/v1/repos/${mockRepo.full_name}/issues/${mockIssue.number}`,
          postParams: {
            state: 'closed',
            title: 'new-title',
            body: 'new-body',
            assignees: [otherMockUser.username],
          },
        },
        updatedMockIssue
      );

      const res = await helper.updateIssue(
        mockRepo.full_name,
        mockIssue.number,
        {
          state: 'closed',
          title: 'new-title',
          body: 'new-body',
          assignees: [otherMockUser.username],
        }
      );
      expect(res).toEqual(updatedMockIssue);
    });
  });

  describe('closeIssue', () => {
    it('should call /api/v1/repos/[repo]/issues/[issue] endpoint', async () => {
      mockAPI<ght.IssueUpdateParams>({
        method: 'patch',
        urlPattern: `/api/v1/repos/${mockRepo.full_name}/issues/${mockIssue.number}`,
        postParams: {
          state: 'closed',
        },
      });

      const res = await helper.closeIssue(mockRepo.full_name, mockIssue.number);
      expect(res).toBeUndefined();
    });
  });

  describe('searchIssues', () => {
    it('should call /api/v1/repos/[repo]/issues endpoint', async () => {
      mockAPI<ght.Issue[]>(
        {
          urlPattern: `/api/v1/repos/${mockRepo.full_name}/issues`,
        },
        [mockIssue]
      );

      const res = await helper.searchIssues(mockRepo.full_name, {});
      expect(res).toEqual([mockIssue]);
    });

    it('should construct proper query parameters', async () => {
      mockAPI<ght.Issue[], Required<PRSearchParams>>(
        {
          urlPattern: `/api/v1/repos/${mockRepo.full_name}/issues`,
          queryParams: {
            state: ['open'],
          },
        },
        [mockIssue]
      );

      const res = await helper.searchIssues(mockRepo.full_name, {
        state: 'open',
      });
      expect(res).toEqual([mockIssue]);
    });
  });

  describe('getRepoLabels', () => {
    it('should call /api/v1/repos/[repo]/labels endpoint', async () => {
      mockAPI<ght.Label[]>(
        {
          urlPattern: `/api/v1/repos/${mockRepo.full_name}/labels`,
        },
        [mockLabel, otherMockLabel]
      );

      const res = await helper.getRepoLabels(mockRepo.full_name);
      expect(res).toEqual([mockLabel, otherMockLabel]);
    });
  });

  describe('unassignLabel', () => {
    it('should call /api/v1/repos/[repo]/issues/[issue]/labels/[label] endpoint', async () => {
      mockAPI({
        method: 'delete',
        urlPattern: `/api/v1/repos/${mockRepo.full_name}/issues/${mockIssue.number}/labels/${mockLabel.id}`,
      });

      const res = await helper.unassignLabel(
        mockRepo.full_name,
        mockIssue.number,
        mockLabel.id
      );
      expect(res).toBeUndefined();
    });
  });

  describe('createComment', () => {
    it('should call /api/v1/repos/[repo]/issues/[issue]/comments endpoint', async () => {
      mockAPI<ght.Comment, Required<ght.CommentCreateParams>>(
        {
          method: 'post',
          urlPattern: `/api/v1/repos/${mockRepo.full_name}/issues/${mockIssue.number}/comments`,
          postParams: {
            body: mockComment.body,
          },
        },
        mockComment
      );

      const res = await helper.createComment(
        mockRepo.full_name,
        mockIssue.number,
        mockComment.body
      );
      expect(res).toEqual(mockComment);
    });
  });

  describe('updateComment', () => {
    it('should call /api/v1/repos/[repo]/issues/comments/[comment] endpoint', async () => {
      const updatedMockComment: ght.Comment = {
        ...mockComment,
        body: 'new-body',
      };

      mockAPI<ght.Comment, Required<ght.CommentUpdateParams>>(
        {
          method: 'patch',
          urlPattern: `/api/v1/repos/${mockRepo.full_name}/issues/comments/${mockComment.id}`,
          postParams: {
            body: 'new-body',
          },
        },
        updatedMockComment
      );

      const res = await helper.updateComment(
        mockRepo.full_name,
        mockComment.id,
        'new-body'
      );
      expect(res).toEqual(updatedMockComment);
    });
  });

  describe('deleteComment', () => {
    it('should call /api/v1/repos/[repo]/issues/comments/[comment] endpoint', async () => {
      mockAPI({
        method: 'delete',
        urlPattern: `/api/v1/repos/${mockRepo.full_name}/issues/comments/${mockComment.id}`,
      });

      const res = await helper.deleteComment(
        mockRepo.full_name,
        mockComment.id
      );
      expect(res).toBeUndefined();
    });
  });

  describe('getComments', () => {
    it('should call /api/v1/repos/[repo]/issues/[issue]/comments endpoint', async () => {
      mockAPI<ght.Comment[]>(
        {
          urlPattern: `/api/v1/repos/${mockRepo.full_name}/issues/${mockIssue.number}/comments`,
        },
        [mockComment]
      );

      const res = await helper.getComments(
        mockRepo.full_name,
        mockIssue.number
      );
      expect(res).toEqual([mockComment]);
    });
  });

  describe('createCommitStatus', () => {
    it('should call /api/v1/repos/[repo]/statuses/[commit] endpoint', async () => {
      mockAPI<ght.CommitStatus, Required<ght.CommitStatusCreateParams>>(
        {
          method: 'post',
          urlPattern: `/api/v1/repos/${mockRepo.full_name}/statuses/${mockCommitHash}`,
          postParams: {
            state: mockCommitStatus.status,
            context: mockCommitStatus.context,
            description: mockCommitStatus.description,
            target_url: mockCommitStatus.target_url,
          },
        },
        mockCommitStatus
      );

      const res = await helper.createCommitStatus(
        mockRepo.full_name,
        mockCommitHash,
        {
          state: mockCommitStatus.status,
          context: mockCommitStatus.context,
          description: mockCommitStatus.description,
          target_url: mockCommitStatus.target_url,
        }
      );
      expect(res).toEqual(mockCommitStatus);
    });
  });

  describe('getCombinedCommitStatus', () => {
    it('should call /api/v1/repos/[repo]/commits/[branch]/statuses endpoint', async () => {
      mockAPI<ght.CommitStatus[]>(
        {
          urlPattern: `/api/v1/repos/${mockRepo.full_name}/commits/${mockBranch.name}/statuses`,
        },
        [mockCommitStatus, otherMockCommitStatus]
      );

      const res = await helper.getCombinedCommitStatus(
        mockRepo.full_name,
        mockBranch.name
      );
      expect(res.worstStatus).not.toEqual('unknown');
      expect(res.statuses).toEqual([mockCommitStatus, otherMockCommitStatus]);
    });

    it('should properly determine worst commit status', async () => {
      const statuses: {
        status: ght.CommitStatusType;
        created_at: string;
        expected: ght.CommitStatusType;
      }[] = [
        {
          status: 'unknown',
          created_at: '2020-03-25T01:00:00Z',
          expected: 'unknown',
        },
        {
          status: 'pending',
          created_at: '2020-03-25T03:00:00Z',
          expected: 'pending',
        },
        {
          status: 'warning',
          created_at: '2020-03-25T04:00:00Z',
          expected: 'warning',
        },
        {
          status: 'failure',
          created_at: '2020-03-25T05:00:00Z',
          expected: 'failure',
        },
        {
          status: 'success',
          created_at: '2020-03-25T02:00:00Z',
          expected: 'failure',
        },
        {
          status: 'success',
          created_at: '2020-03-25T06:00:00Z',
          expected: 'success',
        },
      ];

      const commitStatuses: ght.CommitStatus[] = [
        { ...mockCommitStatus, status: 'unknown' },
      ];

      for (const { status, created_at, expected } of statuses) {
        // Add current status ot list of commit statuses, then mock the API to return the whole list
        commitStatuses.push({
          ...mockCommitStatus,
          status,
          created_at,
        });
        mockAPI<ght.CommitStatus[]>(
          {
            urlPattern: `/api/v1/repos/${mockRepo.full_name}/commits/${mockBranch.name}/statuses`,
          },
          commitStatuses
        );

        // Expect to get the current state back as the worst status, as all previous commit statuses
        // should be less important than the one which just got added
        const res = await helper.getCombinedCommitStatus(
          mockRepo.full_name,
          mockBranch.name
        );
        expect(res.worstStatus).toEqual(expected);
      }
    });
  });

  describe('getBranch', () => {
    it('should call /api/v1/repos/[repo]/branches/[branch] endpoint', async () => {
      mockAPI<ght.Branch>(
        {
          urlPattern: `/api/v1/repos/${mockRepo.full_name}/branches/${mockBranch.name}`,
        },
        mockBranch
      );

      const res = await helper.getBranch(mockRepo.full_name, mockBranch.name);
      expect(res).toEqual(mockBranch);
    });

    it('should properly escape branch names', async () => {
      const escapedBranchName = encodeURIComponent(otherMockBranch.name);

      mockAPI<ght.Branch>(
        {
          urlPattern: `/api/v1/repos/${mockRepo.full_name}/branches/${escapedBranchName}`,
        },
        otherMockBranch
      );

      const res = await helper.getBranch(
        mockRepo.full_name,
        otherMockBranch.name
      );
      expect(res).toEqual(otherMockBranch);
    });
  });
});