// 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.chromium;

import static java.util.Collections.unmodifiableList;
import static java.util.Collections.unmodifiableMap;
import static java.util.stream.Collectors.toList;

import java.io.File;
import java.io.IOException;
import java.nio.file.Files;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Base64;
import java.util.Collections;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.Set;
import java.util.TreeMap;
import java.util.stream.Stream;
import org.openqa.selenium.Capabilities;
import org.openqa.selenium.SessionNotCreatedException;
import org.openqa.selenium.internal.Require;
import org.openqa.selenium.remote.AbstractDriverOptions;

/**
 * Class to manage options specific to {@link ChromiumDriver}.
 *
 * <p>Example usage:
 *
 * <pre><code>
 * ChromeOptions options = new ChromeOptions()
 * options.addExtensions(new File("/path/to/extension.crx"))
 * options.setBinary(new File("/path/to/chrome"));
 *
 * // For use with ChromeDriver:
 * ChromeDriver driver = new ChromeDriver(options);
 *
 * // For use with RemoteWebDriver:
 * RemoteWebDriver driver = new RemoteWebDriver(
 *     new URL("http://localhost:4444/"),
 *     new ChromeOptions());
 * </code></pre>
 *
 * @since Since chromedriver v17.0.963.0
 */
public class ChromiumOptions<T extends ChromiumOptions<?>>
    extends AbstractDriverOptions<ChromiumOptions<?>> {

  private String binary;
  private final List<String> args = new ArrayList<>();
  private final List<File> extensionFiles = new ArrayList<>();
  private final List<String> extensions = new ArrayList<>();
  private final Map<String, Object> experimentalOptions = new HashMap<>();
  private Map<String, Object> androidOptions = new HashMap<>();

  private final String capabilityName;

  public ChromiumOptions(String capabilityType, String browserType, String capability) {
    this.capabilityName = capability;
    setCapability(capabilityType, browserType);
    if (!"jdk-http-client".equalsIgnoreCase(System.getProperty("webdriver.http.factory", ""))) {
      // Allowing any origin "*" might sound risky but an attacker would need to know
      // the port used to start DevTools to establish a connection. Given these sessions
      // are relatively short-lived, the risk is reduced. Only set when the Java 11 client
      // is not used.
      addArguments("--remote-allow-origins=*");
    }
  }

  /**
   * Sets the path to the Chrome executable. This path should exist on the machine which will launch
   * Chrome. The path should either be absolute or relative to the location of running ChromeDriver
   * server.
   *
   * @param path Path to Chrome executable.
   */
  public T setBinary(File path) {
    binary = Require.nonNull("Path to the chrome executable", path).getPath();
    return (T) this;
  }

  /**
   * Sets the path to the Chrome executable. This path should exist on the machine which will launch
   * Chrome. The path should either be absolute or relative to the location of running ChromeDriver
   * server.
   *
   * @param path Path to Chrome executable.
   */
  public T setBinary(String path) {
    binary = Require.nonNull("Path to the chrome executable", path);
    return (T) this;
  }

  /**
   * @param arguments The arguments to use when starting Chrome.
   * @see #addArguments(List)
   */
  public T addArguments(String... arguments) {
    addArguments(Arrays.asList(arguments));
    return (T) this;
  }

  /**
   * Adds additional command line arguments to be used when starting Chrome. For example:
   *
   * <pre><code>
   *   options.addArguments(
   *       "load-extension=/path/to/unpacked_extension",
   *       "allow-outdated-plugins");
   * </code></pre>
   *
   * <p>Each argument may contain an option "--" prefix: "--foo" or "foo". Arguments with an
   * associated value should be delimited with an "=": "foo=bar".
   *
   * @param arguments The arguments to use when starting Chrome.
   */
  public T addArguments(List<String> arguments) {
    /*
     --remote-allow-origins is being added by default since Chrome 111. We need to check
     if the argument already exists and then remove it.
    */
    String remoteAllowOrigins = "remote-allow-origins";
    Optional<String> newArg =
        arguments.stream().filter(arg -> arg.contains(remoteAllowOrigins)).findFirst();
    Optional<String> existingArg =
        args.stream().filter(arg -> arg.contains(remoteAllowOrigins)).findFirst();
    if (newArg.isPresent() && existingArg.isPresent()) {
      args.remove(existingArg.get());
    }
    args.addAll(arguments);
    return (T) this;
  }

  /**
   * @param paths Paths to the extensions to install.
   * @see #addExtensions(List)
   */
  public T addExtensions(File... paths) {
    addExtensions(Arrays.asList(paths));
    return (T) this;
  }

  /**
   * Adds a new Chrome extension to install on browser startup. Each path should specify a packed
   * Chrome extension (CRX file).
   *
   * @param paths Paths to the extensions to install.
   */
  public T addExtensions(List<File> paths) {
    paths.forEach(path -> Require.argument("Extension", path).isFile());
    extensionFiles.addAll(paths);
    return (T) this;
  }

  /**
   * @param encoded Base64 encoded data of the extensions to install.
   * @see #addEncodedExtensions(List)
   */
  public T addEncodedExtensions(String... encoded) {
    addEncodedExtensions(Arrays.asList(encoded));
    return (T) this;
  }

  /**
   * Adds a new Chrome extension to install on browser startup. Each string data should specify a
   * Base64 encoded string of packed Chrome extension (CRX file).
   *
   * @param encoded Base64 encoded data of the extensions to install.
   */
  public T addEncodedExtensions(List<String> encoded) {
    for (String extension : encoded) {
      Require.nonNull("Encoded extension", extension);
    }
    extensions.addAll(encoded);
    return (T) this;
  }

  /**
   * Sets an experimental option. Useful for new ChromeDriver options not yet exposed through the
   * {@link ChromiumOptions} API.
   *
   * @param name Name of the experimental option.
   * @param value Value of the experimental option, which must be convertible to JSON.
   */
  public T setExperimentalOption(String name, Object value) {
    experimentalOptions.put(Require.nonNull("Option name", name), value);
    return (T) this;
  }

  /**
   * @deprecated Use {@link #addArguments(String...)}. Recommended to use '--headless=chrome' as
   *     argument for browsers v94-108. Recommended to use '--headless=new' as argument for browsers
   *     v109+. Example: `addArguments("--headless=new")`.
   */
  @Deprecated
  public T setHeadless(boolean headless) {
    args.remove("--headless");
    if (headless) {
      args.add("--headless");
    }
    return (T) this;
  }

  public T setAndroidPackage(String androidPackage) {
    Require.nonNull("Android package", androidPackage);
    return setAndroidCapability("androidPackage", androidPackage);
  }

  public T setAndroidActivity(String activity) {
    Require.nonNull("Android activity", activity);
    return setAndroidCapability("androidActivity", activity);
  }

  public T setAndroidDeviceSerialNumber(String serial) {
    Require.nonNull("Android device serial number", serial);
    return setAndroidCapability("androidDeviceSerial", serial);
  }

  public T setUseRunningAndroidApp(boolean useIt) {
    return setAndroidCapability("androidUseRunningApp", useIt);
  }

  /**
   * Process name of the Activity hosting the WebView (as given by ps). If not set, the process name
   * is assumed to be the same as androidPackage.
   */
  public T setAndroidProcess(String processName) {
    Require.nonNull("Android process name", processName);
    return setAndroidCapability("androidProcess", processName);
  }

  private T setAndroidCapability(String name, Object value) {
    Require.nonNull("Name", name);
    Require.nonNull("Value", value);
    Map<String, Object> newOptions = new TreeMap<>(androidOptions);
    newOptions.put(name, value);
    androidOptions = Collections.unmodifiableMap(newOptions);
    return (T) this;
  }

  @Override
  protected Set<String> getExtraCapabilityNames() {
    return Collections.singleton(capabilityName);
  }

  @Override
  protected Object getExtraCapability(String capabilityName) {
    Require.nonNull("Capability name", capabilityName);
    if (!this.capabilityName.equals(capabilityName)) {
      return null;
    }

    Map<String, Object> options = new TreeMap<>();
    experimentalOptions.forEach(options::put);

    if (binary != null) {
      options.put("binary", binary);
    }

    options.put("args", unmodifiableList(new ArrayList<>(args)));

    options.put(
        "extensions",
        unmodifiableList(
            Stream.concat(
                    extensionFiles.stream()
                        .map(
                            file -> {
                              try {
                                return Base64.getEncoder()
                                    .encodeToString(Files.readAllBytes(file.toPath()));
                              } catch (IOException e) {
                                throw new SessionNotCreatedException(e.getMessage(), e);
                              }
                            }),
                    extensions.stream())
                .collect(toList())));

    options.putAll(androidOptions);

    return unmodifiableMap(options);
  }

  protected void mergeInPlace(Capabilities capabilities) {
    Require.nonNull("Capabilities to merge", capabilities);

    for (String name : capabilities.getCapabilityNames()) {
      if (!name.equals("binary") && !name.equals("extensions") && !name.equals("args")) {
        setCapability(name, capabilities.getCapability(name));
      }

      if (name.equals("args") && capabilities.getCapability(name) != null) {
        List<String> arguments = (List<String>) (capabilities.getCapability(("args")));
        arguments.forEach(
            arg -> {
              if (!args.contains(arg)) {
                addArguments(arg);
              }
            });
      }

      if (name.equals("extensions") && capabilities.getCapability(name) != null) {
        List<Object> extensionList = (List<Object>) (capabilities.getCapability(("extensions")));
        extensionList.forEach(
            extension -> {
              if (!extensions.contains(extension)) {
                if (extension instanceof File) {
                  addExtensions((File) extension);
                } else if (extension instanceof String) {
                  addEncodedExtensions((String) extension);
                }
              }
            });
      }

      if (name.equals("binary") && capabilities.getCapability(name) != null) {
        Object binary = capabilities.getCapability("binary");
        if (binary instanceof String) {
          setBinary((String) binary);
        } else if (binary instanceof File) {
          setBinary((File) binary);
        }
      }
    }

    if (capabilities instanceof ChromiumOptions) {
      ChromiumOptions<?> options = (ChromiumOptions<?>) capabilities;
      for (String arg : options.args) {
        if (!args.contains(arg)) {
          addArguments(arg);
        }
      }
      addExtensions(options.extensionFiles);
      addEncodedExtensions(options.extensions);
      if (options.binary != null) {
        setBinary(options.binary);
      }
      options.experimentalOptions.forEach(this::setExperimentalOption);
    }
  }

  protected void mergeInOptionsFromCaps(String capabilityName, Capabilities capabilities) {
    if (!(capabilities instanceof ChromiumOptions)) {
      Object object = capabilities.getCapability(capabilityName);

      if (object instanceof Map) {
        @SuppressWarnings("unchecked")
        Map<String, Object> options = (Map<String, Object>) object;

        @SuppressWarnings("unchecked")
        List<String> arguments = (List<String>) (options.getOrDefault("args", new ArrayList<>()));
        @SuppressWarnings("unchecked")
        List<Object> extensionList =
            (List<Object>) (options.getOrDefault("extensions", new ArrayList<>()));

        arguments.forEach(
            arg -> {
              if (!args.contains(arg)) {
                addArguments(arg);
              }
            });

        extensionList.forEach(
            extension -> {
              if (!extensions.contains(extension)) {
                if (extension instanceof File) {
                  addExtensions((File) extension);
                } else if (extension instanceof String) {
                  addEncodedExtensions((String) extension);
                }
              }
            });

        Object binary = options.get("binary");
        if (binary instanceof String) {
          setBinary((String) binary);
        } else if (binary instanceof File) {
          setBinary((File) binary);
        }

        options.forEach(
            (k, v) -> {
              if (!k.equals("binary") && !k.equals("extensions") && !k.equals("args")) {
                setExperimentalOption(k, v);
              }
            });
      }
    }
  }
}
