/*
 * SonarQube Cucumber Gherkin Analyzer
 * Copyright (C) 2016-2017 David RACODON
 * [email protected]
 *
 * 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.gherkin.checks.verifier;

import com.google.common.base.Charsets;
import com.google.common.base.Function;
import com.google.common.base.Splitter;
import com.google.common.collect.ImmutableList;
import com.google.common.collect.Ordering;
import org.sonar.gherkin.parser.GherkinDialectProvider;
import org.sonar.gherkin.parser.GherkinParserBuilder;
import org.sonar.gherkin.visitors.CharsetAwareVisitor;
import org.sonar.gherkin.visitors.GherkinVisitorContext;
import org.sonar.plugins.gherkin.api.GherkinCheck;
import org.sonar.plugins.gherkin.api.tree.GherkinDocumentTree;
import org.sonar.plugins.gherkin.api.tree.SyntaxToken;
import org.sonar.plugins.gherkin.api.tree.SyntaxTrivia;
import org.sonar.plugins.gherkin.api.tree.Tree;
import org.sonar.plugins.gherkin.api.visitors.SubscriptionVisitorCheck;
import org.sonar.plugins.gherkin.api.visitors.issue.*;
import org.sonar.squidbridge.checks.CheckMessagesVerifier;

import javax.annotation.Nullable;
import java.io.File;
import java.nio.charset.Charset;
import java.util.ArrayList;
import java.util.Iterator;
import java.util.List;
import java.util.stream.Collectors;

import static org.fest.assertions.Assertions.assertThat;
import static org.junit.Assert.fail;

/**
 * To unit test checks.
 */
public class GherkinCheckVerifier extends SubscriptionVisitorCheck {

  private final List<TestIssue> expectedIssues = new ArrayList<>();

  /**
   * Check issues
   * Example:
   * <pre>
   * GherkinCheckVerifier.issues(new MyCheck(), myFile)
   *   .next().atLine(2).withMessage("This is message for line 2.")
   *   .next().atLine(3).withMessage("This is message for line 3.").withCost(2.)
   *   .next().atLine(8)
   *   .noMore();
   * </pre>
   *
   * @param check Check to test
   * @param file  File to test
   */
  public static CheckMessagesVerifier issues(GherkinCheck check, File file) {
    return issues(check, file, Charsets.UTF_8);
  }

  /**
   * See {@link GherkinCheckVerifier#issues(GherkinCheck, File)}
   *
   * @param charset Charset of the file to test.
   */
  public static CheckMessagesVerifier issues(GherkinCheck check, File file, Charset charset) {
    if (check instanceof CharsetAwareVisitor) {
      ((CharsetAwareVisitor) check).setCharset(charset);
    }
    return CheckMessagesVerifier.verify(TreeCheckTest.getIssues(file.getAbsolutePath(), check, charset, GherkinDialectProvider.DEFAULT_LANGUAGE));
  }

  /**
   * See {@link GherkinCheckVerifier#issues(GherkinCheck, File)}
   *
   * @param language Language of the file to test.
   */
  public static CheckMessagesVerifier issues(GherkinCheck check, File file, String language) {
    if (check instanceof CharsetAwareVisitor) {
      ((CharsetAwareVisitor) check).setCharset(Charsets.UTF_8);
    }
    return CheckMessagesVerifier.verify(TreeCheckTest.getIssues(file.getAbsolutePath(), check, Charsets.UTF_8, language));
  }

  /**
   * To unit tests checks. Expected issues should be provided as comments in the source file.
   * Expected issue details should be provided the line prior to the actual issue.
   * For example:
   * <pre>
   * # Noncompliant {{Error message for the issue on the next line}}
   * MyProperty=abc
   *
   * # Noncompliant [[sc=2;ec=6;secondary=+2,+4]] {{Error message}}
   * ...
   * </pre>
   * <p>
   * How to write these comments:
   * <ul>
   * <li>Put a comment starting with "Noncompliant" if you expect an issue on the next line.</li>
   * <li>Optional - In double brackets provide the precise issue location <code>sl, sc, ec, el</code> keywords respectively for start line, start column, end column and end line. <code>sl=+1</code> by default.</li>
   * <li>Optional - In double brackets provide secondary locations with the <code>secondary</code> keyword.</li>
   * <li>Optional - In double brackets provide expected effort to fix (cost) with the <code>effortToFix</code> keyword.</li>
   * <li>Optional - In double curly braces <code>{{MESSAGE}}</code> provide the expected message.</li>
   * <li>To specify the line you can use relative location by putting <code>+</code> or <code>-</code>.</li>
   * <li>Note that the order matters: Noncompliant => Parameters => Error message</li>
   * </ul>
   * <p>
   * Example of call:
   * <pre>
   * GherkinCheckVerifier.verify(new MyCheck(), myFile));
   * </pre>
   */
  public static void verify(GherkinCheck check, File file) {
    verify(check, file, Charsets.UTF_8, GherkinDialectProvider.DEFAULT_LANGUAGE);
  }

  /**
   * See {@link GherkinCheckVerifier#verify(GherkinCheck, File)}
   *
   * @param charset Charset of the file to test.
   */
  public static void verify(GherkinCheck check, File file, Charset charset) {
    verify(check, file, charset, GherkinDialectProvider.DEFAULT_LANGUAGE);
  }

  /**
   * See {@link GherkinCheckVerifier#verify(GherkinCheck, File)}
   *
   * @param charset  Charset of the file to test.
   * @param language Language of the file to test.
   */
  public static void verify(GherkinCheck check, File file, Charset charset, String language) {
    GherkinDocumentTree propertiesTree = (GherkinDocumentTree) GherkinParserBuilder.createTestParser(charset, language).parse(file);
    GherkinVisitorContext context = new GherkinVisitorContext(propertiesTree, file);

    GherkinCheckVerifier checkVerifier = new GherkinCheckVerifier();
    checkVerifier.scanFile(context);

    List<TestIssue> expectedIssues = checkVerifier.expectedIssues
      .stream()
      .sorted((i1, i2) -> Integer.compare(i1.line(), i2.line()))
      .collect(Collectors.toList());

    if (check instanceof CharsetAwareVisitor) {
      ((CharsetAwareVisitor) check).setCharset(charset);
    }
    Iterator<Issue> actualIssues = getActualIssues(check, context);

    for (TestIssue expected : expectedIssues) {
      if (actualIssues.hasNext()) {
        verifyIssue(expected, actualIssues.next());
      } else {
        throw new AssertionError("Missing issue at line " + expected.line());
      }
    }

    if (actualIssues.hasNext()) {
      Issue issue = actualIssues.next();
      throw new AssertionError("Unexpected issue at line " + line(issue) + ": \"" + message(issue) + "\"");
    }
  }

  /**
   * See {@link GherkinCheckVerifier#verify(GherkinCheck, File)}
   *
   * @param language Language of the file to test.
   */
  public static void verify(GherkinCheck check, File file, String language) {
    verify(check, file, Charsets.UTF_8, language);
  }

  private static Iterator<Issue> getActualIssues(GherkinCheck check, GherkinVisitorContext context) {
    List<Issue> issues = check.scanFile(context);
    List<Issue> sortedIssues = Ordering.natural().onResultOf(new IssueToLine()).sortedCopy(issues);
    return sortedIssues.iterator();
  }

  private static void verifyIssue(TestIssue expected, Issue actual) {
    if (line(actual) > expected.line()) {
      fail("Missing issue at line " + expected.line());
    }
    if (line(actual) < expected.line()) {
      fail("Unexpected issue at line " + line(actual) + ": \"" + message(actual) + "\"");
    }
    if (expected.message() != null) {
      assertThat(message(actual)).as("Bad message at line " + expected.line()).isEqualTo(expected.message());
    }
    if (expected.effortToFix() != null) {
      assertThat(actual.cost()).as("Bad effortToFix at line " + expected.line()).isEqualTo(expected.effortToFix());
    }
    if (expected.startColumn() != null) {
      assertThat(((PreciseIssue) actual).primaryLocation().startLineOffset() + 1).as("Bad start column at line " + expected.line()).isEqualTo(expected.startColumn());
    }
    if (expected.endColumn() != null) {
      assertThat(((PreciseIssue) actual).primaryLocation().endLineOffset() + 1).as("Bad end column at line " + expected.line()).isEqualTo(expected.endColumn());
    }
    if (expected.endLine() != null) {
      assertThat(((PreciseIssue) actual).primaryLocation().endLine()).as("Bad end line at line " + expected.line()).isEqualTo(expected.endLine());
    }
    if (expected.secondaryLines() != null) {
      assertThat(secondary(actual)).as("Bad secondary locations at line " + expected.line()).isEqualTo(expected.secondaryLines());
    }
  }

  @Override
  public List<Tree.Kind> nodesToVisit() {
    return ImmutableList.of(Tree.Kind.TOKEN);
  }

  @Override
  public void visitNode(Tree tree) {
    SyntaxToken token = (SyntaxToken) tree;
    for (SyntaxTrivia trivia : token.trivias()) {

      String text = trivia.text().substring(2).trim();
      String marker = "Noncompliant";

      if (text.startsWith(marker)) {
        TestIssue issue = issue(null, trivia.line() + 1);
        String paramsAndMessage = text.substring(marker.length()).trim();
        if (paramsAndMessage.startsWith("[[")) {
          int endIndex = paramsAndMessage.indexOf("]]");
          addParams(issue, paramsAndMessage.substring(2, endIndex));
          paramsAndMessage = paramsAndMessage.substring(endIndex + 2).trim();
        }
        if (paramsAndMessage.startsWith("{{")) {
          int endIndex = paramsAndMessage.indexOf("}}");
          String message = paramsAndMessage.substring(2, endIndex);
          issue.message(message);
        }
        expectedIssues.add(issue);
      }
    }
  }

  private static void addParams(TestIssue issue, String params) {
    for (String param : Splitter.on(';').split(params)) {
      int equalIndex = param.indexOf('=');
      if (equalIndex == -1) {
        throw new IllegalStateException("Invalid param at line 1: " + param);
      }
      String name = param.substring(0, equalIndex);
      String value = param.substring(equalIndex + 1);

      if ("effortToFix".equalsIgnoreCase(name)) {
        issue.effortToFix(Integer.valueOf(value));

      } else if ("sc".equalsIgnoreCase(name)) {
        issue.startColumn(Integer.valueOf(value));

      } else if ("sl".equalsIgnoreCase(name)) {
        issue.startLine(lineValue(issue.line() - 1, value));

      } else if ("ec".equalsIgnoreCase(name)) {
        issue.endColumn(Integer.valueOf(value));

      } else if ("el".equalsIgnoreCase(name)) {
        issue.endLine(lineValue(issue.line(), value));

      } else if ("secondary".equalsIgnoreCase(name)) {
        addSecondaryLines(issue, value);

      } else {
        throw new IllegalStateException("Invalid param at line 1: " + name);
      }
    }
  }

  private static void addSecondaryLines(TestIssue issue, String value) {
    List<Integer> secondaryLines = new ArrayList<>();
    if (!"".equals(value)) {
      for (String secondary : Splitter.on(',').split(value)) {
        secondaryLines.add(lineValue(issue.line(), secondary));
      }
    }
    issue.secondary(secondaryLines);
  }

  private static int lineValue(int baseLine, String shift) {
    if (shift.startsWith("+")) {
      return baseLine + Integer.valueOf(shift.substring(1));
    }
    if (shift.startsWith("-")) {
      return baseLine - Integer.valueOf(shift.substring(1));
    }
    return Integer.valueOf(shift);
  }

  private static TestIssue issue(@Nullable String message, int lineNumber) {
    return TestIssue.create(message, lineNumber);
  }

  private static class IssueToLine implements Function<Issue, Integer> {
    @Override
    public Integer apply(Issue issue) {
      return line(issue);
    }
  }

  private static int line(Issue issue) {
    if (issue instanceof PreciseIssue) {
      return ((PreciseIssue) issue).primaryLocation().startLine();
    } else if (issue instanceof FileIssue) {
      return 0;
    } else if (issue instanceof LineIssue) {
      return ((LineIssue) issue).line();
    } else {
      throw new IllegalStateException("Unknown type of issue.");
    }
  }

  private static String message(Issue issue) {
    if (issue instanceof PreciseIssue) {
      return ((PreciseIssue) issue).primaryLocation().message();
    } else if (issue instanceof FileIssue) {
      return ((FileIssue) issue).message();
    } else if (issue instanceof LineIssue) {
      return ((LineIssue) issue).message();
    } else {
      throw new IllegalStateException("Unknown type of issue.");
    }
  }

  private static List<Integer> secondary(Issue issue) {
    List<Integer> result = new ArrayList<>();

    if (issue instanceof PreciseIssue) {
      result.addAll(((PreciseIssue) issue).secondaryLocations()
        .stream()
        .map(IssueLocation::startLine)
        .collect(Collectors.toList()));
    } else if (issue instanceof FileIssue) {
      result.addAll(((FileIssue) issue).secondaryLocations()
        .stream()
        .map(IssueLocation::startLine)
        .collect(Collectors.toList()));
    }
    return Ordering.natural().sortedCopy(result);
  }

}