// Licensed to the Software Freedom Conservancy (SFC) under one
// or more contributor license agreements.  See the NOTICE file
// distributed with this work for additional information
// regarding copyright ownership.  The SFC licenses this file
// to you 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.

package org.openqa.selenium;

import static java.time.Instant.ofEpochMilli;
import static java.time.ZoneId.systemDefault;
import static java.util.concurrent.TimeUnit.SECONDS;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.openqa.selenium.support.ui.ExpectedConditions.visibilityOf;

import java.time.Duration;
import java.time.format.DateTimeFormatter;
import java.util.List;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.CopyOnWriteArrayList;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.ExecutionException;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.TimeoutException;
import java.util.function.Consumer;
import org.junit.jupiter.api.Test;
import org.openqa.selenium.bidi.log.ConsoleLogEntry;
import org.openqa.selenium.bidi.log.JavascriptLogEntry;
import org.openqa.selenium.bidi.log.LogLevel;
import org.openqa.selenium.remote.DomMutation;
import org.openqa.selenium.remote.RemoteWebDriver;
import org.openqa.selenium.remote.Script;
import org.openqa.selenium.support.ui.WebDriverWait;
import org.openqa.selenium.testing.JupiterTestBase;
import org.openqa.selenium.testing.NeedsFreshDriver;

class WebScriptTest extends JupiterTestBase {

  private String page;

  @Test
  @NeedsFreshDriver
  void canAddConsoleMessageHandler()
      throws ExecutionException, InterruptedException, TimeoutException {
    CompletableFuture<ConsoleLogEntry> future = new CompletableFuture<>();

    String id = ((RemoteWebDriver) driver).script().addConsoleMessageHandler(future::complete);

    page = appServer.whereIs("/bidi/logEntryAdded.html");
    driver.get(page);
    driver.findElement(By.id("consoleLog")).click();

    ConsoleLogEntry logEntry = future.get(5, TimeUnit.SECONDS);

    assertThat(logEntry.getText()).isEqualTo("Hello, world!");
    assertThat(logEntry.getArgs()).hasSize(1);
    assertThat(logEntry.getArgs().get(0).getType()).isEqualTo("string");
    assertThat(logEntry.getType()).isEqualTo("console");
    assertThat(logEntry.getLevel()).isEqualTo(LogLevel.INFO);
    assertThat(logEntry.getMethod()).isEqualTo("log");

    ((RemoteWebDriver) driver).script().removeConsoleMessageHandler(id);
  }

  @Test
  @NeedsFreshDriver
  void canRemoveConsoleMessageHandler()
      throws ExecutionException, InterruptedException, TimeoutException {
    CompletableFuture<ConsoleLogEntry> future1 = new CompletableFuture<>();
    CompletableFuture<ConsoleLogEntry> future2 = new CompletableFuture<>();

    // Adding two consumers
    Consumer<ConsoleLogEntry> consumer1 = future1::complete;
    Consumer<ConsoleLogEntry> consumer2 = future2::complete;

    String id1 = ((RemoteWebDriver) driver).script().addConsoleMessageHandler(consumer1);
    String id2 = ((RemoteWebDriver) driver).script().addConsoleMessageHandler(consumer2);

    // Removing the second consumer, so it will no longer get the console message.
    ((RemoteWebDriver) driver).script().removeConsoleMessageHandler(id2);

    page = appServer.whereIs("/bidi/logEntryAdded.html");
    driver.get(page);
    driver.findElement(By.id("consoleLog")).click();

    ConsoleLogEntry logEntry = future1.get(5, TimeUnit.SECONDS);
    assertThat(logEntry.getText()).isEqualTo("Hello, world!");

    assertThatThrownBy(() -> future2.get(5, TimeUnit.SECONDS))
        .as("Should be able to read the console messages")
        .isInstanceOf(TimeoutException.class);

    ((RemoteWebDriver) driver).script().removeConsoleMessageHandler(id1);
  }

  @Test
  @NeedsFreshDriver
  void canAddJsErrorHandler() throws ExecutionException, InterruptedException, TimeoutException {
    CompletableFuture<JavascriptLogEntry> future = new CompletableFuture<>();

    String id = ((RemoteWebDriver) driver).script().addJavaScriptErrorHandler(future::complete);

    page = appServer.whereIs("/bidi/logEntryAdded.html");
    driver.get(page);
    driver.findElement(By.id("jsException")).click();

    JavascriptLogEntry logEntry = future.get(5, TimeUnit.SECONDS);

    assertThat(logEntry.getText()).isEqualTo("Error: Not working");
    assertThat(logEntry.getType()).isEqualTo("javascript");
    assertThat(logEntry.getLevel()).isEqualTo(LogLevel.ERROR);

    ((RemoteWebDriver) driver).script().removeJavaScriptErrorHandler(id);
  }

  @Test
  @NeedsFreshDriver
  void canRemoveJsErrorHandler() throws ExecutionException, InterruptedException, TimeoutException {
    CompletableFuture<JavascriptLogEntry> future1 = new CompletableFuture<>();
    CompletableFuture<JavascriptLogEntry> future2 = new CompletableFuture<>();

    // Adding two consumers
    Consumer<JavascriptLogEntry> consumer1 = future1::complete;
    Consumer<JavascriptLogEntry> consumer2 = future2::complete;

    String id1 = ((RemoteWebDriver) driver).script().addJavaScriptErrorHandler(consumer1);
    String id2 = ((RemoteWebDriver) driver).script().addJavaScriptErrorHandler(consumer2);

    // Removing the second consumer, so it will no longer get the JS error.
    ((RemoteWebDriver) driver).script().removeJavaScriptErrorHandler(id2);

    page = appServer.whereIs("/bidi/logEntryAdded.html");
    driver.get(page);
    driver.findElement(By.id("jsException")).click();

    JavascriptLogEntry logEntry = future1.get(5, TimeUnit.SECONDS);
    assertThat(logEntry.getText()).isEqualTo("Error: Not working");
    assertThat(logEntry.getType()).isEqualTo("javascript");
    assertThat(logEntry.getLevel()).isEqualTo(LogLevel.ERROR);

    assertThatThrownBy(() -> future2.get(5, TimeUnit.SECONDS))
        .as("Should be able to read the JS errors")
        .isInstanceOf(TimeoutException.class);

    ((RemoteWebDriver) driver).script().removeConsoleMessageHandler(id1);
  }

  @Test
  @NeedsFreshDriver
  void canAddMultipleHandlers() throws ExecutionException, InterruptedException, TimeoutException {
    CompletableFuture<JavascriptLogEntry> future1 = new CompletableFuture<>();
    CompletableFuture<JavascriptLogEntry> future2 = new CompletableFuture<>();

    // Adding two consumers
    Consumer<JavascriptLogEntry> consumer1 = future1::complete;
    Consumer<JavascriptLogEntry> consumer2 = future2::complete;

    String id1 = ((RemoteWebDriver) driver).script().addJavaScriptErrorHandler(consumer1);
    String id2 = ((RemoteWebDriver) driver).script().addJavaScriptErrorHandler(consumer2);

    page = appServer.whereIs("/bidi/logEntryAdded.html");
    driver.get(page);
    driver.findElement(By.id("jsException")).click();

    JavascriptLogEntry logEntry1 = future1.get(5, TimeUnit.SECONDS);
    assertThat(logEntry1.getText()).isEqualTo("Error: Not working");
    assertThat(logEntry1.getType()).isEqualTo("javascript");
    assertThat(logEntry1.getLevel()).isEqualTo(LogLevel.ERROR);

    JavascriptLogEntry logEntry2 = future2.get(5, TimeUnit.SECONDS);
    assertThat(logEntry2.getText()).isEqualTo("Error: Not working");
    assertThat(logEntry2.getType()).isEqualTo("javascript");
    assertThat(logEntry2.getLevel()).isEqualTo(LogLevel.ERROR);
  }

  @Test
  @NeedsFreshDriver
  void canAddDomMutationHandler() {
    List<String> mutations = new CopyOnWriteArrayList<>();

    Script script = ((RemoteWebDriver) driver).script();
    script.addDomMutationHandler(mutationHandler(mutations));

    driver.get(pages.dynamicPage);
    triggerDomMutation();

    assertThat(mutations).isNotEmpty();
    assertThat(lastOf(mutations)).isEqualTo("style: 'display:none;' -> ''");
  }

  @Test
  @NeedsFreshDriver
  void canRemoveDomMutationHandler() {
    List<String> mutations = new CopyOnWriteArrayList<>();
    Script script = ((RemoteWebDriver) driver).script();
    String id = script.addDomMutationHandler(mutationHandler(mutations));

    driver.get(pages.dynamicPage);
    triggerDomMutation();
    assertThat(mutations).isNotEmpty();

    script.removeDomMutationHandler(id);

    mutations.clear();
    driver.get(pages.dynamicPage);
    triggerDomMutation();
    assertThat(mutations).isEmpty();
  }

  private void triggerDomMutation() {
    WebElement reveal = driver.findElement(By.id("reveal"));
    reveal.click();
    WebElement revealed = driver.findElement(By.id("revealed"));
    new WebDriverWait(driver, Duration.ofSeconds(10)).until(visibilityOf(revealed));
  }

  private static Consumer<DomMutation> mutationHandler(List<String> mutations) {
    return mutation -> {
      mutations.add(
          String.format(
              "%s: '%s' -> '%s'",
              mutation.getAttributeName(), mutation.getOldValue(), mutation.getCurrentValue()));
    };
  }

  @Test
  @NeedsFreshDriver
  void canPinScript() throws ExecutionException, InterruptedException, TimeoutException {
    CompletableFuture<ConsoleLogEntry> future = new CompletableFuture<>();

    ((RemoteWebDriver) driver).script().pin("() => { console.log('Hello!'); }");

    String id = ((RemoteWebDriver) driver).script().addConsoleMessageHandler(future::complete);

    page = appServer.whereIs("/bidi/logEntryAdded.html");
    driver.get(page);

    ConsoleLogEntry logEntry = future.get(5, TimeUnit.SECONDS);

    assertThat(logEntry.getText()).isEqualTo("Hello!");

    ((RemoteWebDriver) driver).script().removeConsoleMessageHandler(id);
  }

  @Test
  @NeedsFreshDriver
  void canUnpinScript() throws InterruptedException {
    List<String> logs = new CopyOnWriteArrayList<>();
    CountDownLatch latch = new CountDownLatch(1);

    Script script = ((RemoteWebDriver) driver).script();
    String pinnedScript = script.pin("() => { console.log('Hello!'); }");

    DateTimeFormatter formatter =
        DateTimeFormatter.ofPattern("HH:mm:ss:SSS").withZone(systemDefault());

    String id =
        script.addConsoleMessageHandler(
            log -> {
              String time = formatter.format(ofEpochMilli(log.getTimestamp()));
              String message = String.format("%s %s", log.getText(), time);
              logs.add(message);
              latch.countDown();
            });

    try {
      page = appServer.whereIs("/bidi/logEntryAdded.html");
      assertThat(logs).hasSize(0);

      driver.get(page);
      assertThat(latch.await(10, SECONDS)).isTrue();

      assertThat(logs).as("Chrome logs once, FireFox logs twice").isNotEmpty();
      assertThat(logs.get(0)).startsWith("Hello!");

      script.unpin(pinnedScript);

      logs.clear();
      driver.get(page);
      assertThat(logs).as("Script has been unpinned, no logs anymore.").isEmpty();
    } finally {
      script.removeConsoleMessageHandler(id);
    }
  }

  private static <T> T lastOf(List<T> list) {
    return list.get(list.size() - 1);
  }
}
