/*
 * 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.
 */

// @see https://playwright.dev/docs/test-reporters#custom-reporters
import { promises as fs } from 'fs';
import { join } from 'path';
import { FullResult, Reporter, TestCase, TestResult } from '@playwright/test/reporter';
import { flatMap, sortBy } from 'lodash';
import { scanDirectory, Results } from 'scandirectory';
import cfg from './reporter.coverage.config';

const TEST_STATUS = {
  PASSED: 'passed',
  SKIPPED: 'skipped',
  FAILED: 'failed'
} as const;

interface ResultType {
  path: string;
  total: number;
  success: number;
  failed: number;
  skipped: number;
  rate: number;
}
type ResultsType = ResultType[];
type TestStatusType = (typeof TEST_STATUS)[keyof typeof TEST_STATUS];
interface TestedPathType {
  success: number;
  skipped: number;
  failed: number;
}

const OUTPUT_FILE_NAME = 'coverage.log';

class CoverageReporter implements Reporter {
  testedPaths = new Map<string, TestedPathType>();
  testedIds = new Map<string, TestStatusType>();
  targetPaths: string[] = [];

  async onBegin() {
    console.log('Coverage reporter starting...');
    console.log('Root path:', cfg.rootPath);

    const results = await scanDirectory({
      directory: cfg.rootPath
    });

    this.targetPaths = this.processScannedFiles(results);
    console.log('Target paths:', this.targetPaths.length);
  }

  onTestEnd(test: TestCase, result: TestResult) {
    const status =
      result.status === TEST_STATUS.PASSED || result.status === TEST_STATUS.SKIPPED
        ? result.status
        : TEST_STATUS.FAILED;
    const pages = this.extractPageAnnotations(test);
    const prevTestStatus = this.testedIds.get(test.id);

    pages.forEach(page => {
      this.updateTestedPath(page, status, prevTestStatus);
    });

    this.testedIds.set(test.id, status);
  }

  async onEnd(result: FullResult) {
    const results = this.getResults();

    console.log(this.formatTable(results));
    console.log(this.getTestedPagesResult(results));
    console.log(this.getTestCasesResult(results));
    console.log(`Finished the run: ${result.status}`);

    await this.saveResultsToFile(results, result.status);
  }

  processScannedFiles(results: Results): string[] {
    return Object.keys(results)
      .filter(key => !results[key].directory)
      .map(key => this.normalizeFilePath(key, results))
      .filter(key => key !== '.')
      .filter(key => this.shouldIncludeFile(key));
  }

  normalizeFilePath(key: string, results: Results): string {
    if (/index\.tsx?$/.test(key)) {
      return results[key].parent?.relativePath || '.';
    }
    return key.replace(/\.tsx?$/, '');
  }

  shouldIncludeFile(key: string): boolean {
    if (cfg.testMatch?.length) {
      const matchesTest = cfg.testMatch.some(rule => (rule instanceof RegExp ? rule.test(key) : rule === key));
      if (!matchesTest) {
        return false;
      }
    }

    if (cfg.excludes?.length) {
      const isExcluded = cfg.excludes.some(rule => (rule instanceof RegExp ? rule.test(key) : rule === key));
      if (isExcluded) {
        return false;
      }
    }

    return true;
  }

  extractPageAnnotations(test: TestCase): string[] {
    const annotations = test.annotations
      .filter(({ type }) => type === 'page')
      .map(({ description }) => description)
      .filter((desc): desc is string => desc !== undefined);

    return Array.from(new Set(annotations));
  }

  updateTestedPath(page: string, status: TestStatusType, prevStatus?: TestStatusType) {
    if (this.testedPaths.has(page)) {
      const currentTest = this.testedPaths.get(page)!;
      const newTest = { ...currentTest };
      this.decrementPreviousStatus(newTest, prevStatus);
      this.incrementCurrentStatus(newTest, status);
      this.testedPaths.set(page, newTest);
      return;
    }
    this.testedPaths.set(page, {
      success: status === TEST_STATUS.PASSED ? 1 : 0,
      failed: status === TEST_STATUS.FAILED ? 1 : 0,
      skipped: status === TEST_STATUS.SKIPPED ? 1 : 0
    });
  }

  decrementPreviousStatus(draftState: TestedPathType, prevStatus?: TestStatusType) {
    if (!prevStatus) {
      return;
    }

    if (prevStatus === TEST_STATUS.PASSED && draftState.success > 0) {
      draftState.success -= 1;
    } else if (prevStatus === TEST_STATUS.SKIPPED && draftState.skipped > 0) {
      draftState.skipped -= 1;
    } else if (prevStatus === TEST_STATUS.FAILED && draftState.failed > 0) {
      draftState.failed -= 1;
    }
  }

  incrementCurrentStatus(draftState: TestedPathType, status: TestStatusType) {
    if (status === TEST_STATUS.PASSED) {
      draftState.success += 1;
    } else if (status === TEST_STATUS.SKIPPED) {
      draftState.skipped += 1;
    } else if (status === TEST_STATUS.FAILED) {
      draftState.failed += 1;
    }
  }

  getResults(): ResultsType {
    const testedPaths = Array.from(this.testedPaths.keys());
    const results = flatMap(this.targetPaths, path => this.processTargetPath(path, testedPaths));

    return sortBy(results, ['rate', 'total', 'path']).reverse();
  }

  processTargetPath(targetPath: string, testedPaths: string[]): ResultsType {
    const matchingPaths = this.findMatchingPaths(targetPath, testedPaths);

    if (matchingPaths.length > 0) {
      return matchingPaths.map(path => this.createResultEntry(path));
    }

    return [{ path: targetPath, total: 0, success: 0, failed: 0, skipped: 0, rate: 0 }];
  }

  findMatchingPaths(targetPath: string, testedPaths: string[]): string[] {
    const regExp = new RegExp(`^${targetPath.replace(/\[.*?\]/g, '[^/]+?')}$`);

    return testedPaths.filter(key => {
      if (!this.targetPaths.includes(key)) {
        return regExp.test(key);
      }
      return targetPath === key;
    });
  }

  createResultEntry(path: string): ResultType {
    const testData = this.testedPaths.get(path);
    if (!testData) {
      return { path, total: 0, success: 0, failed: 0, skipped: 0, rate: 0 };
    }

    const { success, skipped, failed } = testData;
    const total = success + failed + skipped;
    const rate = this.toRate(success, total - skipped);

    return { path, total, success, failed, skipped, rate };
  }

  getTestedPagesResult(results: ResultsType) {
    const testedList = results.filter(item => !!item.total);
    const failedList = testedList.filter(item => !!item.failed);
    const skippedList = testedList.filter(item => item.skipped === item.total);

    const failed = failedList.length;
    const tested = testedList.length;
    const skipped = skippedList.length;
    const success = tested - failed - skipped;
    const testedRate = this.toRate(testedList.length, results.length).toFixed(2);

    return `Tested pages: ${tested}/${results.length} (${testedRate}%) (${this.toRate(success, tested).toFixed(
      2
    )}%, success: ${success}, failed: ${failed}, skipped: ${skipped})`;
  }

  getTestCasesResult(results: ResultsType) {
    const stats = results.reduce(
      (acc, item) => ({
        total: acc.total + item.total,
        failed: acc.failed + item.failed,
        skipped: acc.skipped + item.skipped,
        success: acc.success + item.success
      }),
      { total: 0, failed: 0, skipped: 0, success: 0 }
    );
    const rate = this.toRate(stats.success, stats.total - stats.skipped).toFixed(2);

    return `Test cases: ${stats.total} (${rate}%, success: ${stats.success}, failed: ${stats.failed}, skipped: ${stats.skipped})`;
  }

  getTableData(results: ResultsType): Array<Array<string | number>> {
    const header = ['No.', 'Path', 'Test cases', 'Successes', 'Failures', 'Skipped', 'Success rate'];
    const rows = results.map((item, index) => {
      const { path, total, success, failed, skipped, rate } = item;
      return [index + 1, path, total, success, failed, skipped, `${rate.toFixed(2)}%`];
    });

    return [header, ...rows];
  }

  formatTable(results: ResultsType): string {
    const tableData = this.getTableData(results);
    if (tableData.length === 0) {
      return '';
    }

    const colWidths = tableData[0].map((_, colIndex) => {
      let maxWidth = 0;
      for (const row of tableData) {
        const width = String(row[colIndex]).length;
        if (width > maxWidth) {
          maxWidth = width;
        }
      }
      return maxWidth;
    });

    const lines: string[] = [];

    const header = tableData[0].map((cell, i) => String(cell).padEnd(colWidths[i])).join(' │ ');
    lines.push(header);

    const separator = colWidths.map(width => '─'.repeat(width)).join('─┼─');
    lines.push(separator);

    for (let i = 1; i < tableData.length; i++) {
      const row = tableData[i].map((cell, j) => String(cell).padEnd(colWidths[j])).join(' │ ');
      lines.push(row);
    }

    return lines.join('\n');
  }

  async saveResultsToFile(results: ResultsType, status: string) {
    const contents = [
      this.formatTable(results),
      this.getTestedPagesResult(results),
      this.getTestCasesResult(results),
      `Finished the run: ${status}`
    ].join('\n');

    try {
      await fs.mkdir(cfg.outputPath, { recursive: true });
      await fs.writeFile(join(cfg.outputPath, OUTPUT_FILE_NAME), contents, 'utf8');
      console.log('The file has been saved!');
    } catch (e) {
      console.error('Error saving coverage report:', e);
    }
  }

  toRate(count: number, total: number) {
    return total ? (count / total) * 100 : 0;
  }
}

export default CoverageReporter;
