/**
 * Copyright (c) Microsoft Corporation.
 *
 * Licensed under the Apache License, Version 2.0 (the "License");
 * you may not use this file except in compliance with the License.
 * You may obtain a copy of the License at
 *
 * http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */

import fs from 'fs';
import path from 'path';

import type { MetadataWithCommitInfo } from '@testIsomorphic/types';
import type { IssueCommentEdge, Repository } from '@octokit/graphql-schema';
import type { FullConfig, FullResult, Reporter, Suite, TestCase } from '@playwright/test/reporter';

type MarkdownReporterOptions = {
  configDir: string, // TODO: make it public?
  outputFile?: string;
};

class MarkdownReporter implements Reporter {
  private _options: MarkdownReporterOptions;
  private _fatalErrors: TestError[] = [];
  protected _config!: FullConfig;
  private _suite!: Suite;

  constructor(options: MarkdownReporterOptions) {
    this._options = options;
  }

  printsToStdio() {
    return false;
  }

  onBegin(config: FullConfig, suite: Suite) {
    this._config = config;
    this._suite = suite;
  }

  onError(error: TestError) {
    this._fatalErrors.push(error);
  }

  async onEnd(result: FullResult) {
    const summary = this._generateSummary();
    const lines: string[] = [];
    const incompleteWarning = this._incompleteRunWarning();
    if (incompleteWarning) {
      lines.push(incompleteWarning);
      lines.push(``);
    }
    if (this._fatalErrors.length)
      lines.push(`**${this._fatalErrors.length} fatal errors, not part of any test**`);
    if (summary.unexpected.length) {
      lines.push(`**${summary.unexpected.length} failed**`);
      this._printTestList(':x:', summary.unexpected, lines);
    }
    if (summary.flaky.length) {
      lines.push(`<details>`);
      lines.push(`<summary><b>${summary.flaky.length} flaky</b></summary>`);
      this._printTestList(':warning:', summary.flaky, lines, ' <br/>');
      lines.push(`</details>`);
      lines.push(``);
    }
    if (summary.interrupted.length) {
      lines.push(`<details>`);
      lines.push(`<summary><b>${summary.interrupted.length} interrupted</b></summary>`);
      this._printTestList(':warning:', summary.interrupted, lines, ' <br/>');
      lines.push(`</details>`);
      lines.push(``);
    }
    const skipped = summary.skipped ? `, ${summary.skipped} skipped` : '';
    const didNotRun = summary.didNotRun ? `, ${summary.didNotRun} did not run` : '';
    lines.push(`**${summary.expected} passed${skipped}${didNotRun}**`);
    lines.push(``);

    await this.publishReport(lines.join('\n'));
  }

  protected async publishReport(report: string): Promise<void> {
    const maybeRelativeFile = this._options.outputFile || 'report.md';
    const reportFile = path.resolve(this._options.configDir, maybeRelativeFile);
    await fs.promises.mkdir(path.dirname(reportFile), { recursive: true });
    await fs.promises.writeFile(reportFile, report);
  }

  protected _incompleteRunWarning(): string | undefined {
    const conclusion = process.env.WORKFLOW_RUN_CONCLUSION;
    if (!conclusion || conclusion === 'success' || conclusion === 'failure')
      return undefined;
    return `> [!WARNING]\n> The triggering workflow run ended with status \`${conclusion}\`. Results below may be incomplete — blob reports from cancelled or timed-out shards are missing, so passing/failing counts do not reflect the full test suite.`;
  }

  protected _generateSummary() {
    let didNotRun = 0;
    let skipped = 0;
    let expected = 0;
    const interrupted: TestCase[] = [];
    const interruptedToPrint: TestCase[] = [];
    const unexpected: TestCase[] = [];
    const flaky: TestCase[] = [];

    this._suite.allTests().forEach(test => {
      switch (test.outcome()) {
        case 'skipped': {
          if (test.results.some(result => result.status === 'interrupted')) {
            if (test.results.some(result => !!result.error))
              interruptedToPrint.push(test);
            interrupted.push(test);
          } else if (!test.results.length || test.expectedStatus !== 'skipped') {
            ++didNotRun;
          } else {
            ++skipped;
          }
          break;
        }
        case 'expected': ++expected; break;
        case 'unexpected': unexpected.push(test); break;
        case 'flaky': flaky.push(test); break;
      }
    });

    return {
      didNotRun,
      skipped,
      expected,
      interrupted,
      unexpected,
      flaky,
    };
  }

  private _printTestList(prefix: string, tests: TestCase[], lines: string[], suffix?: string) {
    for (const test of tests)
      lines.push(`${prefix} ${formatTestTitle(this._config.rootDir, test)}${suffix || ''}`);
    lines.push(``);
  }
}

function formatTestTitle(rootDir: string, test: TestCase): string {
  // root, project, file, ...describes, test
  const [, projectName, , ...titles] = test.titlePath();
  const relativeTestPath = path.relative(rootDir, test.location.file);
  // intentionally leave out column to prevent writing test.spec.ts:100:5 - GitHub turns that into 💯
  const location = `${relativeTestPath}:${test.location.line}`;
  const projectTitle = projectName ? `[${projectName}] › ` : '';
  const testTitle = `${projectTitle}${location} › ${titles.join(' › ')}`;
  const extraTags = test.tags.filter(t => !testTitle.includes(t));
  const formattedTags = extraTags.map(t => `\`${t}\``).join(' ');
  return `${testTitle}${extraTags.length ? ' ' + formattedTags : ''}`;
}

class GHAMarkdownReporter extends MarkdownReporter {
  private octokit: ReturnType<typeof import('@actions/github').getOctokit>;
  private context: typeof import('@actions/github').context;
  private core: typeof import('@actions/core');

  override async publishReport(report: string) {
    this.core = await import('@actions/core');
    const token = process.env.GITHUB_TOKEN || this.core.getInput('github-token');
    if (!token) {
      this.core.setFailed('Missing "github-token" input');
      throw new Error('Missing "github-token" input');
    }
    const { context, getOctokit } = await import('@actions/github');
    this.context = context;
    this.octokit = getOctokit(token);

    this.core.info('Publishing report to PR.');
    const { prNumber, prHref } = this.pullRequestFromMetadata();
    if (!prNumber) {
      this.core.info(`No PR number found, skipping GHA comment. PR href: ${prHref}`);
      return;
    }
    this.core.info(`Posting comment to PR ${prHref}`);

    const prNodeId = await this.collapsePreviousComments(prNumber);
    if (!prNodeId) {
      this.core.warning(`No PR node ID found, skipping GHA comment. PR href: ${prHref}`);
      return;
    }
    await this.addNewReportComment(prNodeId, report);
  }

  private async collapsePreviousComments(prNumber: number) {
    const { owner, repo } = this.context.repo;
    const data = await this.octokit.graphql<{ repository: Repository }>(`
      query {
        repository(owner: "${owner}", name: "${repo}") {
          pullRequest(number: ${prNumber}) {
            id
            comments(last: 100) {
              nodes {
                id
                body
                author {
                  __typename
                  login
                }
              }
            }
          }
        }
      }
    `);
    const comments = data.repository.pullRequest?.comments.nodes?.filter(comment =>
      comment?.author?.__typename === 'Bot' &&
      comment?.author?.login === 'github-actions' &&
      comment.body?.includes(this._magicComment()));
    const prId = data.repository.pullRequest?.id;
    if (!comments?.length)
      return prId;
    const mutations = comments.map((comment, i) =>
      `m${i}: minimizeComment(input: { subjectId: "${comment!.id}", classifier: OUTDATED }) { clientMutationId }`);
    await this.octokit.graphql(`
      mutation {
        ${mutations.join('\n')}
      }
    `);
    return prId;
  }

  private _magicComment() {
    return `<!-- Generated by Playwright markdown reporter for ${this._workflowRunName()} in job ${process.env.GITHUB_JOB} -->`;
  }

  private _workflowRunName() {
    // When used via 'workflow_run' event.
    const workflowRunName = this.context.payload.workflow_run?.name;
    if (workflowRunName)
      return workflowRunName;
    // When used via 'pull_request'/'push' event.
    // This is the name of the workflow file, e.g. 'ci.yml' or name if set.
    return process.env.GITHUB_WORKFLOW;
  }

  private async addNewReportComment(prNodeId: string, report: string) {
    const reportUrl = process.env.HTML_REPORT_URL;
    const mergeWorkflowUrl = `${this.context.serverUrl}/${this.context.repo.owner}/${this.context.repo.repo}/actions/runs/${this.context.runId}`;

    const body = formatComment([
      this._magicComment(),
      `### ${reportUrl ? `[Test results](${reportUrl})` : 'Test results'} for "${this._workflowRunName()}"`,
      report,
      '',
      '---',
      '',
      `Merge [workflow run](${mergeWorkflowUrl}).`
    ]);

    const response = await this.octokit.graphql<{ addComment: { commentEdge: IssueCommentEdge } }>(`
      mutation {
        addComment(input: {subjectId: "${prNodeId}", body: """${body}"""}) {
          commentEdge {
            node {
              ... on IssueComment {
                url
              }
            }
          }
        }
      }
    `);
    this.core.info(`Posted comment:  ${response.addComment.commentEdge.node?.url}`);
  }

  private pullRequestFromMetadata() {
    const metadata = this._config.metadata as MetadataWithCommitInfo;
    const prHref = metadata.ci?.prHref;
    return { prNumber: parseInt(prHref?.split('/').pop() ?? '', 10), prHref };
  }
}

function formatComment(lines: string[]) {
  let body = lines.join('\n');
  if (body.length > 65535)
    body = body.substring(0, 65000) + `... ${body.length - 65000} more characters`;
  return body;
}

export default GHAMarkdownReporter;
