/*
 * SonarQube
 * Copyright (C) 2009-2024 SonarSource SA
 * mailto:info AT sonarsource DOT com
 *
 * This program is free software; you can redistribute it and/or
 * modify it under the terms of the GNU Lesser General Public
 * License as published by the Free Software Foundation; either
 * version 3 of the License, or (at your option) any later version.
 *
 * 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
 * Lesser General Public License for more details.
 *
 * You should have received a copy of the GNU Lesser General Public License
 * along with this program; if not, write to the Free Software Foundation,
 * Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
 */
package org.sonar.scanner.externalissue;

import com.google.gson.Gson;
import java.io.IOException;
import java.io.Reader;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import org.junit.Before;
import org.junit.Rule;
import org.junit.Test;
import org.slf4j.event.Level;
import org.sonar.api.rules.RuleType;
import org.sonar.api.testfixtures.log.LogAndArguments;
import org.sonar.api.testfixtures.log.LogTester;
import org.sonar.core.documentation.DocumentationLinkGenerator;

import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatCode;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;

public class ExternalIssueReportValidatorTest {

  private static final String DEPRECATED_REPORTS_LOCATION = "src/test/resources/org/sonar/scanner/externalissue/";
  private static final String REPORTS_LOCATION = "src/test/resources/org/sonar/scanner/externalissue/cct/";
  private static final String URL = "/analyzing-source-code/importing-external-issues/generic-issue-import-format/";
  private static final String TEST_URL = "test-url";

  private final Gson gson = new Gson();
  private final Path reportPath = Paths.get("report-path");
  private final DocumentationLinkGenerator documentationLinkGenerator = mock(DocumentationLinkGenerator.class);
  private final ExternalIssueReportValidator validator = new ExternalIssueReportValidator(documentationLinkGenerator);

  @Rule
  public LogTester logTester = new LogTester();

  @Before
  public void setUp() {
    when(documentationLinkGenerator.getDocumentationLink(URL)).thenReturn(TEST_URL);
  }

  @Test
  public void validate_whenInvalidReport_shouldThrowException() throws IOException {
    ExternalIssueReport report = readInvalidReport(DEPRECATED_REPORTS_LOCATION);
    assertThatThrownBy(() -> validator.validate(report, reportPath))
      .isInstanceOf(IllegalStateException.class)
      .hasMessage("Failed to parse report 'report-path': invalid report detected.");
  }

  @Test
  public void validate_whenCorrect_shouldNotThrowException() throws IOException {
    ExternalIssueReport report = read(REPORTS_LOCATION);
    assertThatCode(() -> validator.validate(report, reportPath)).doesNotThrowAnyException();
  }

  @Test
  public void validate_whenDuplicateRuleIdFound_shouldThrowException() throws IOException {
    ExternalIssueReport report = read(REPORTS_LOCATION);
    report.rules[0].id = "rule2";

    assertThatThrownBy(() -> validator.validate(report, reportPath))
      .isInstanceOf(IllegalStateException.class)
      .hasMessage("Failed to parse report 'report-path': found duplicate rule ID 'rule2'.");
  }

  @Test
  public void validate_whenMissingEngineIdField_shouldThrowException() throws IOException {
    ExternalIssueReport report = read(REPORTS_LOCATION);
    report.rules[0].engineId = null;

    assertThatThrownBy(() -> validator.validate(report, reportPath))
      .isInstanceOf(IllegalStateException.class)
      .hasMessage("Failed to parse report 'report-path': missing mandatory field 'engineId'.");
  }

  @Test
  public void validate_whenMissingFilepathFieldForPrimaryLocation_shouldThrowException() throws IOException {
    ExternalIssueReport report = read(REPORTS_LOCATION);
    report.issues[0].primaryLocation.filePath = null;

    assertThatThrownBy(() -> validator.validate(report, reportPath))
      .isInstanceOf(IllegalStateException.class)
      .hasMessage("Failed to parse report 'report-path': missing mandatory field 'filePath' in the primary location of the issue.");
  }

  @Test
  public void validate_whenMissingImpactsAndTypeField_shouldThrowException() throws IOException {
    ExternalIssueReport report = read(REPORTS_LOCATION);
    report.rules[0].impacts = null;
    report.rules[0].type = null;

    assertThatThrownBy(() -> validator.validate(report, reportPath))
      .isInstanceOf(IllegalStateException.class)
      .hasMessage("Failed to parse report 'report-path': either type, impacts or both should be provided.");
  }

  @Test
  public void validate_whenImpactIsProvided_shouldNotBeEmpty() throws IOException {
    ExternalIssueReport report = read(REPORTS_LOCATION);
    report.rules[0].impacts = new ExternalIssueReport.Impact[0];
    report.rules[0].type = null;

    assertThatThrownBy(() -> validator.validate(report, reportPath))
      .isInstanceOf(IllegalStateException.class)
      .hasMessage("Failed to parse report 'report-path': mandatory array 'impacts' not populated.");
  }

  @Test
  public void validate_whenSecurityHotspotAndImpactIsProvided_shouldThrowException() throws IOException {
    ExternalIssueReport report = read(REPORTS_LOCATION);
    report.rules[0].impacts = new ExternalIssueReport.Impact[] {
      new ExternalIssueReport.Impact()
    };
    report.rules[0].type = RuleType.SECURITY_HOTSPOT.name();

    assertThatThrownBy(() -> validator.validate(report, reportPath))
      .isInstanceOf(IllegalStateException.class)
      .hasMessage("Failed to parse report 'report-path': impacts should not be provided for rule type 'SECURITY_HOTSPOT'.");
  }

  @Test
  public void validate_whenTypeIsProvidedAndNotSeverity_shouldThrowException() throws IOException {
    ExternalIssueReport report = read(REPORTS_LOCATION);
    report.rules[0].impacts = new ExternalIssueReport.Impact[] {
      new ExternalIssueReport.Impact()
    };
    report.rules[0].type = null;
    report.rules[0].severity = "MAJOR";

    assertThatThrownBy(() -> validator.validate(report, reportPath))
      .isInstanceOf(IllegalStateException.class)
      .hasMessage("Failed to parse report 'report-path': missing mandatory field 'type'.");
  }

  @Test
  public void validate_whenSeverityIsProvidedAndNotType_shouldThrowException() throws IOException {
    ExternalIssueReport report = read(REPORTS_LOCATION);
    report.rules[0].impacts = new ExternalIssueReport.Impact[] {
      new ExternalIssueReport.Impact()
    };
    report.rules[0].type = "CODE_SMELL";
    report.rules[0].severity = null;

    assertThatThrownBy(() -> validator.validate(report, reportPath))
      .isInstanceOf(IllegalStateException.class)
      .hasMessage("Failed to parse report 'report-path': missing mandatory field 'severity'.");
  }

  @Test
  public void validate_whenMissingMessageFieldForPrimaryLocation_shouldThrowException() throws IOException {
    ExternalIssueReport report = read(REPORTS_LOCATION);
    report.issues[0].primaryLocation.message = null;

    assertThatThrownBy(() -> validator.validate(report, reportPath))
      .isInstanceOf(IllegalStateException.class)
      .hasMessage("Failed to parse report 'report-path': missing mandatory field 'message' in the primary location of the issue.");
  }

  @Test
  public void validate_whenMissingStartLineFieldForPrimaryLocation_shouldThrowException() throws IOException {
    ExternalIssueReport report = read(REPORTS_LOCATION);
    report.issues[0].primaryLocation.textRange.startLine = null;

    assertThatThrownBy(() -> validator.validate(report, reportPath))
      .isInstanceOf(IllegalStateException.class)
      .hasMessage("Failed to parse report 'report-path': missing mandatory field 'startLine of the text range' in the primary location of the issue.");
  }

  @Test
  public void validate_whenReportMissingFilePathForSecondaryLocation_shouldThrowException() throws IOException {
    ExternalIssueReport report = read(REPORTS_LOCATION);
    report.issues[3].secondaryLocations[0].filePath = null;

    assertThatThrownBy(() -> validator.validate(report, reportPath))
      .isInstanceOf(IllegalStateException.class)
      .hasMessage("Failed to parse report 'report-path': missing mandatory field 'filePath' in a secondary location of the issue.");
  }

  @Test
  public void validate_whenReportMissingTextRangeForSecondaryLocation_shouldThrowException() throws IOException {
    ExternalIssueReport report = read(REPORTS_LOCATION);
    report.issues[3].secondaryLocations[0].textRange = null;

    assertThatThrownBy(() -> validator.validate(report, reportPath))
      .isInstanceOf(IllegalStateException.class)
      .hasMessage("Failed to parse report 'report-path': missing mandatory field 'textRange' in a secondary location of the issue.");
  }

  @Test
  public void validate_whenReportMissingTextRangeStartLineForSecondaryLocation_shouldThrowException() throws IOException {
    ExternalIssueReport report = read(REPORTS_LOCATION);
    report.issues[3].secondaryLocations[0].textRange.startLine = null;

    assertThatThrownBy(() -> validator.validate(report, reportPath))
      .isInstanceOf(IllegalStateException.class)
      .hasMessage("Failed to parse report 'report-path': missing mandatory field 'startLine of the text range' in a secondary location of the issue.");
  }

  @Test
  public void validate_whenMissingNameField_shouldThrowException() throws IOException {
    ExternalIssueReport report = read(REPORTS_LOCATION);
    report.rules[0].name = null;

    assertThatThrownBy(() -> validator.validate(report, reportPath))
      .isInstanceOf(IllegalStateException.class)
      .hasMessage("Failed to parse report 'report-path': missing mandatory field 'name'.");
  }

  @Test
  public void validate_whenMissingPrimaryLocationField_shouldThrowException() throws IOException {
    ExternalIssueReport report = read(REPORTS_LOCATION);
    report.issues[0].primaryLocation = null;

    assertThatThrownBy(() -> validator.validate(report, reportPath))
      .isInstanceOf(IllegalStateException.class)
      .hasMessage("Failed to parse report 'report-path': missing mandatory field 'primaryLocation'.");
  }

  @Test
  public void validate_whenMissingOrEmptyRuleIdField_shouldThrowException() throws IOException {
    String errorMessage = "Failed to parse report 'report-path': missing mandatory field 'id'.";

    ExternalIssueReport report = read(REPORTS_LOCATION);
    report.rules[0].id = null;
    assertThatThrownBy(() -> validator.validate(report, reportPath))
      .isInstanceOf(IllegalStateException.class)
      .hasMessage(errorMessage);

    report.rules[0].id = "";
    assertThatThrownBy(() -> validator.validate(report, reportPath))
      .isInstanceOf(IllegalStateException.class)
      .hasMessage(errorMessage);
  }

  @Test
  public void validate_whenIssueContainsRuleIdNotPresentInReport_shouldThrowException() throws IOException {
    ExternalIssueReport report = read(REPORTS_LOCATION);
    report.issues[0].ruleId = "rule-id-not-present";

    assertThatThrownBy(() -> validator.validate(report, reportPath))
      .isInstanceOf(IllegalStateException.class)
      .hasMessage("Failed to parse report 'report-path': rule with 'rule-id-not-present' not present.");
  }

  @Test
  public void validate_whenIssueRuleIdNotPresentInReport_shouldThrowException() throws IOException {
    ExternalIssueReport report = read(REPORTS_LOCATION);
    report.issues[0].ruleId = null;

    assertThatThrownBy(() -> validator.validate(report, reportPath))
      .isInstanceOf(IllegalStateException.class)
      .hasMessage("Failed to parse report 'report-path': missing mandatory field 'ruleId'.");
  }

  @Test
  public void validate_whenContainsDeprecatedSeverityEntry_shouldThrowException() throws IOException {
    ExternalIssueReport report = read(REPORTS_LOCATION);
    report.issues[0].severity = "MAJOR";

    assertThatThrownBy(() -> validator.validate(report, reportPath))
      .isInstanceOf(IllegalStateException.class)
      .hasMessage("Deprecated 'severity' field found in the following report: 'report-path'.");
  }

  @Test
  public void validate_whenContainsDeprecatedTypeEntry_shouldThrowException() throws IOException {
    ExternalIssueReport report = read(REPORTS_LOCATION);
    report.issues[0].type = "BUG";

    assertThatThrownBy(() -> validator.validate(report, reportPath))
      .isInstanceOf(IllegalStateException.class)
      .hasMessage("Deprecated 'type' field found in the following report: 'report-path'.");
  }

  @Test
  public void validate_whenContainsEmptyImpacts_shouldThrowException() throws IOException {
    ExternalIssueReport report = read(REPORTS_LOCATION);
    report.rules[0].impacts = new ExternalIssueReport.Impact[0];

    assertThatThrownBy(() -> validator.validate(report, reportPath))
      .isInstanceOf(IllegalStateException.class)
      .hasMessage("Failed to parse report 'report-path': mandatory array 'impacts' not populated.");
  }

  @Test
  public void validate_whenDeprecatedReportFormat_shouldValidateWithWarningLog() throws IOException {
    ExternalIssueReport report = read(DEPRECATED_REPORTS_LOCATION);
    assertThatCode(() -> validator.validate(report, reportPath)).doesNotThrowAnyException();
    assertWarningLog();
  }

  @Test
  public void validate_whenDeprecatedReportMissingEngineId_shouldThrowException() throws IOException {
    ExternalIssueReport report = read(DEPRECATED_REPORTS_LOCATION);
    report.issues[0].engineId = null;

    assertThatThrownBy(() -> validator.validate(report, reportPath))
      .isInstanceOf(IllegalStateException.class)
      .hasMessage("Failed to parse report 'report-path': missing mandatory field 'engineId'.");
    assertWarningLog();
  }

  @Test
  public void validate_whenDeprecatedReportMissingFilepathForPrimaryLocation_shouldThrowException() throws IOException {
    ExternalIssueReport report = read(DEPRECATED_REPORTS_LOCATION);
    report.issues[0].primaryLocation.filePath = null;

    assertThatThrownBy(() -> validator.validate(report, reportPath))
      .isInstanceOf(IllegalStateException.class)
      .hasMessage("Failed to parse report 'report-path': missing mandatory field 'filePath' in the primary location of the issue.");
    assertWarningLog();
  }

  @Test
  public void validate_whenDeprecatedReportMissingMessageForPrimaryLocation_shouldThrowException() throws IOException {
    ExternalIssueReport report = read(DEPRECATED_REPORTS_LOCATION);
    report.issues[0].primaryLocation.message = null;

    assertThatThrownBy(() -> validator.validate(report, reportPath))
      .isInstanceOf(IllegalStateException.class)
      .hasMessage("Failed to parse report 'report-path': missing mandatory field 'message' in the primary location of the issue.");
    assertWarningLog();
  }

  @Test
  public void validate_whenDeprecatedReportMissingPrimaryLocation_shouldThrowException() throws IOException {
    ExternalIssueReport report = read(DEPRECATED_REPORTS_LOCATION);
    report.issues[0].primaryLocation = null;

    assertThatThrownBy(() -> validator.validate(report, reportPath))
      .isInstanceOf(IllegalStateException.class)
      .hasMessage("Failed to parse report 'report-path': missing mandatory field 'primaryLocation'.");
    assertWarningLog();
  }

  @Test
  public void validate_whenDeprecatedReportMissingStartLineForPrimaryLocation_shouldThrowException() throws IOException {
    ExternalIssueReport report = read(DEPRECATED_REPORTS_LOCATION);
    report.issues[0].primaryLocation.textRange.startLine = null;

    assertThatThrownBy(() -> validator.validate(report, reportPath))
      .isInstanceOf(IllegalStateException.class)
      .hasMessage("Failed to parse report 'report-path': missing mandatory field 'startLine of the text range' in the primary location of the issue.");
    assertWarningLog();
  }

  @Test
  public void validate_whenDeprecatedReportMissingFilePathForSecondaryLocation_shouldThrowException() throws IOException {
    ExternalIssueReport report = read(DEPRECATED_REPORTS_LOCATION);
    report.issues[3].secondaryLocations[0].filePath = null;

    assertThatThrownBy(() -> validator.validate(report, reportPath))
      .isInstanceOf(IllegalStateException.class)
      .hasMessage("Failed to parse report 'report-path': missing mandatory field 'filePath' in a secondary location of the issue.");
    assertWarningLog();
  }

  @Test
  public void validate_whenDeprecatedReportMissingTextRangeForSecondaryLocation_shouldThrowException() throws IOException {
    ExternalIssueReport report = read(DEPRECATED_REPORTS_LOCATION);
    report.issues[3].secondaryLocations[0].textRange = null;

    assertThatThrownBy(() -> validator.validate(report, reportPath))
      .isInstanceOf(IllegalStateException.class)
      .hasMessage("Failed to parse report 'report-path': missing mandatory field 'textRange' in a secondary location of the issue.");
    assertWarningLog();
  }

  @Test
  public void validate_whenDeprecatedReportMissingTextRangeStartLineForSecondaryLocation_shouldThrowException() throws IOException {
    ExternalIssueReport report = read(DEPRECATED_REPORTS_LOCATION);
    report.issues[3].secondaryLocations[0].textRange.startLine = null;

    assertThatThrownBy(() -> validator.validate(report, reportPath))
      .isInstanceOf(IllegalStateException.class)
      .hasMessage("Failed to parse report 'report-path': missing mandatory field 'startLine of the text range' in a secondary location of the issue.");
    assertWarningLog();
  }

  @Test
  public void validate_whenDeprecatedReportMissingRuleId_shouldThrowException() throws IOException {
    ExternalIssueReport report = read(DEPRECATED_REPORTS_LOCATION);
    report.issues[0].ruleId = null;

    assertThatThrownBy(() -> validator.validate(report, reportPath))
      .isInstanceOf(IllegalStateException.class)
      .hasMessage("Failed to parse report 'report-path': missing mandatory field 'ruleId'.");
    assertWarningLog();
  }

  @Test
  public void validate_whenDeprecatedReportMissingSeverity_shouldThrowException() throws IOException {
    ExternalIssueReport report = read(DEPRECATED_REPORTS_LOCATION);
    report.issues[0].severity = null;

    assertThatThrownBy(() -> validator.validate(report, reportPath))
      .isInstanceOf(IllegalStateException.class)
      .hasMessage("Failed to parse report 'report-path': missing mandatory field 'severity'.");
    assertWarningLog();
  }

  @Test
  public void validate_whenDeprecatedReportMissingType_shouldThrowException() throws IOException {
    ExternalIssueReport report = read(DEPRECATED_REPORTS_LOCATION);
    report.issues[0].type = null;

    assertThatThrownBy(() -> validator.validate(report, reportPath))
      .isInstanceOf(IllegalStateException.class)
      .hasMessage("Failed to parse report 'report-path': missing mandatory field 'type'.");
    assertWarningLog();
  }

  private void assertWarningLog() {
    assertThat(logTester.getLogs(Level.WARN))
      .extracting(LogAndArguments::getFormattedMsg)
      .contains("External issues were imported with a deprecated format which will be removed soon. " +
        "Please switch to the newest format to fully benefit from Clean Code: " + TEST_URL);
  }

  private ExternalIssueReport read(String location) throws IOException {
    Reader reader = Files.newBufferedReader(Paths.get(location + "report.json"), StandardCharsets.UTF_8);
    return gson.fromJson(reader, ExternalIssueReport.class);
  }

  private ExternalIssueReport readInvalidReport(String location) throws IOException {
    Reader reader = Files.newBufferedReader(Paths.get(location + "invalid_report.json"), StandardCharsets.UTF_8);
    return gson.fromJson(reader, ExternalIssueReport.class);
  }

}
