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

import static java.util.stream.Collectors.toMap;
import static org.openqa.selenium.remote.Browser.FIREFOX;

import java.io.File;
import java.io.IOException;
import java.io.UncheckedIOException;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collections;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Optional;
import java.util.Set;
import java.util.TreeMap;
import java.util.TreeSet;
import org.openqa.selenium.Capabilities;
import org.openqa.selenium.WebDriverException;
import org.openqa.selenium.internal.Require;
import org.openqa.selenium.remote.AbstractDriverOptions;
import org.openqa.selenium.remote.CapabilityType;

/**
 * Manage firefox specific settings in a way that geckodriver can understand.
 *
 * <p>An example of usage:
 *
 * <pre>
 *    FirefoxOptions options = new FirefoxOptions()
 *      .addPreference("browser.startup.page", 1)
 *      .addPreference("browser.startup.homepage", "https://www.google.co.uk");
 *    WebDriver driver = new FirefoxDriver(options);
 * </pre>
 */
public class FirefoxOptions extends AbstractDriverOptions<FirefoxOptions> {

  public static final String FIREFOX_OPTIONS = "moz:firefoxOptions";

  private Map<String, Object> firefoxOptions = Collections.unmodifiableMap(new TreeMap<>());

  public FirefoxOptions() {
    setCapability(CapabilityType.BROWSER_NAME, FIREFOX.browserName());
    setAcceptInsecureCerts(true);
    // https://fxdx.dev/deprecating-cdp-support-in-firefox-embracing-the-future-with-webdriver-bidi/.
    // Enable BiDi only
    addPreference("remote.active-protocols", 1);
  }

  public FirefoxOptions(Capabilities source) {
    // We need to initialize all our own fields before calling.
    this();

    source.getCapabilityNames().stream()
        .filter(name -> !FIREFOX_OPTIONS.equals(name))
        .forEach(
            name -> {
              Object value = source.getCapability(name);
              if (value != null) {
                setCapability(name, value);
              }
            });

    // If `source` is an instance of FirefoxOptions, we need to mirror those into this instance.
    if (source instanceof FirefoxOptions) {
      mirror((FirefoxOptions) source);
    } else {
      Object rawOptions = source.getCapability(FIREFOX_OPTIONS);
      if (rawOptions != null) {
        // If `source` contains the keys we care about, then make sure they're good.
        Require.stateCondition(
            rawOptions instanceof Map, "Expected options to be a map: %s", rawOptions);
        @SuppressWarnings("unchecked")
        Map<String, Object> sourceOptions = (Map<String, Object>) rawOptions;
        Map<String, Object> options = new TreeMap<>();
        for (Keys key : Keys.values()) {
          key.amend(sourceOptions, options);
        }

        this.firefoxOptions = Collections.unmodifiableMap(options);
      }
    }
  }

  private void mirror(FirefoxOptions that) {
    Map<String, Object> newOptions = new TreeMap<>(firefoxOptions);

    for (Keys key : Keys.values()) {
      Object value = key.mirror(firefoxOptions, that.firefoxOptions);
      if (value != null) {
        newOptions.put(key.key(), value);
      }
    }

    this.firefoxOptions = Collections.unmodifiableMap(newOptions);
  }

  /**
   * Configures the following:
   *
   * <dl>
   *   <dt>Binary
   *   <dd>{@code webdriver.firefox.bin} - the path to the firefox binary
   *   <dt>Firefox profile
   *   <dd>{@code webdriver.firefox.profile} - a named firefox profile
   * </dl>
   */
  public FirefoxOptions configureFromEnv() {
    // Read system properties and use those if they are set, allowing users to override them later
    // should they want to.

    String binary = System.getProperty(FirefoxDriver.SystemProperty.BROWSER_BINARY);
    if (binary != null) {
      setBinary(binary);
    }

    String profileName = System.getProperty(FirefoxDriver.SystemProperty.BROWSER_PROFILE);
    if (profileName != null) {
      FirefoxProfile profile = new ProfilesIni().getProfile(profileName);
      if (profile == null) {
        throw new WebDriverException(
            String.format(
                "Firefox profile '%s' named in system property '%s' not found",
                profileName, FirefoxDriver.SystemProperty.BROWSER_PROFILE));
      }
      setProfile(profile);
    }

    return this;
  }

  /**
   * Constructs a {@link FirefoxBinary} and returns that to be used, and because of this is only
   * useful when actually starting firefox.
   *
   * @deprecated This method is deprecated and will be removed in a future version. Selenium Manager
   *     handles this for you.
   */
  @Deprecated
  public FirefoxBinary getBinary() {
    return getBinaryOrNull().orElseGet(FirefoxBinary::new);
  }

  /**
   * Sets the path to the Firefox binary to use. This is useful when you have multiple versions of
   * Firefox installed on your machine.
   *
   * @deprecated This method is deprecated and will be removed in a future version. Use {@link
   *     #setBinary(Path)} or {@link #setBinary(String)} instead.
   */
  @Deprecated
  public FirefoxOptions setBinary(FirefoxBinary binary) {
    Require.nonNull("Binary", binary);
    addArguments(binary.getExtraOptions());
    return setFirefoxOption(Keys.BINARY, binary.getPath());
  }

  public FirefoxOptions setBinary(Path path) {
    Require.nonNull("Binary", path);
    return setFirefoxOption(Keys.BINARY, path.toString());
  }

  public FirefoxOptions setBinary(String path) {
    Require.nonNull("Binary", path);
    return setFirefoxOption(Keys.BINARY, path);
  }

  /**
   * Returns the binary as a {@link FirefoxBinary} if it was set, or an empty {@link Optional} if
   * not.
   *
   * @deprecated This method is deprecated and will be removed in a future version. Selenium Manager
   *     handles this for you.}
   */
  @Deprecated
  public Optional<FirefoxBinary> getBinaryOrNull() {
    Object binary = firefoxOptions.get(Keys.BINARY.key());
    if (!(binary instanceof String)) {
      return Optional.empty();
    }

    FirefoxBinary toReturn = new FirefoxBinary(new File((String) binary));
    Object rawArgs = firefoxOptions.getOrDefault(Keys.ARGS.key(), new ArrayList<>());
    Require.stateCondition(rawArgs instanceof List, "Arguments are not a list: %s", rawArgs);

    ((List<?>) rawArgs)
        .stream()
            .filter(Objects::nonNull)
            .map(String::valueOf)
            .forEach(toReturn::addCommandLineOptions);

    return Optional.of(toReturn);
  }

  public FirefoxProfile getProfile() {
    Object rawProfile = firefoxOptions.get(Keys.PROFILE.key());
    if (rawProfile == null) {
      return new FirefoxProfile();
    }

    if (rawProfile instanceof FirefoxProfile) {
      return (FirefoxProfile) rawProfile;
    }

    try {
      return FirefoxProfile.fromJson((String) rawProfile);
    } catch (IOException e) {
      throw new UncheckedIOException(e);
    }
  }

  public FirefoxOptions setProfile(FirefoxProfile profile) {
    Require.nonNull("Profile", profile);

    try {
      return setFirefoxOption(Keys.PROFILE, profile.toJson());
    } catch (IOException e) {
      throw new UncheckedIOException(e);
    }
  }

  public FirefoxOptions addArguments(String... arguments) {
    addArguments(Arrays.asList(arguments));
    return this;
  }

  public FirefoxOptions addArguments(List<String> arguments) {
    Require.nonNull("Arguments", arguments);

    Object rawList = firefoxOptions.getOrDefault(Keys.ARGS.key(), new ArrayList<>());
    Require.stateCondition(rawList instanceof List, "Arg list of unexpected type: %s", rawList);

    List<String> newArgs = new ArrayList<>();
    ((List<?>) rawList).stream().map(String::valueOf).forEach(newArgs::add);
    newArgs.addAll(arguments);

    return setFirefoxOption(Keys.ARGS, Collections.unmodifiableList(newArgs));
  }

  public FirefoxOptions addPreference(String key, Object value) {
    Require.nonNull("Key", key);
    Require.nonNull("Value", value);

    Object rawPrefs = firefoxOptions.getOrDefault(Keys.PREFS.key(), new HashMap<>());
    Require.stateCondition(rawPrefs instanceof Map, "Prefs are of unexpected type: %s", rawPrefs);

    @SuppressWarnings("unchecked")
    Map<String, Object> prefs = (Map<String, Object>) rawPrefs;
    Map<String, Object> newPrefs = new TreeMap<>(prefs);
    newPrefs.put(key, value);

    return setFirefoxOption(Keys.PREFS, Collections.unmodifiableMap(newPrefs));
  }

  public FirefoxOptions setLogLevel(FirefoxDriverLogLevel logLevel) {
    Require.nonNull("Log level", logLevel);
    return setFirefoxOption(Keys.LOG, logLevel.toJson());
  }

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

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

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

  public FirefoxOptions setAndroidIntentArguments(String[] args) {
    Require.nonNull("Android intent arguments", args);
    return setAndroidIntentArguments(Arrays.asList(args));
  }

  public FirefoxOptions setAndroidIntentArguments(List<String> args) {
    Require.nonNull("Android intent arguments", args);
    return setFirefoxOption("androidIntentArguments", args);
  }

  private FirefoxOptions setFirefoxOption(Keys key, Object value) {
    return setFirefoxOption(key.key(), value);
  }

  private FirefoxOptions setFirefoxOption(String key, Object value) {
    Require.nonNull("Key", key);
    Require.nonNull("Value", value);

    Map<String, Object> newOptions = new TreeMap<>(firefoxOptions);
    newOptions.put(key, value);
    firefoxOptions = Collections.unmodifiableMap(newOptions);
    return this;
  }

  public FirefoxOptions enableBiDi() {
    setCapability("webSocketUrl", true);
    return this;
  }

  @Override
  protected Set<String> getExtraCapabilityNames() {
    Set<String> names = new TreeSet<>();

    names.add(FIREFOX_OPTIONS);

    return Collections.unmodifiableSet(names);
  }

  @Override
  protected Object getExtraCapability(String capabilityName) {
    Require.nonNull("Capability name", capabilityName);

    if (FIREFOX_OPTIONS.equals(capabilityName)) {
      return Collections.unmodifiableMap(firefoxOptions);
    }
    return null;
  }

  @Override
  public FirefoxOptions merge(Capabilities capabilities) {
    Require.nonNull("Capabilities to merge", capabilities);
    FirefoxOptions newInstance = new FirefoxOptions();
    getCapabilityNames().forEach(name -> newInstance.setCapability(name, getCapability(name)));
    newInstance.mirror(this);

    for (String name : capabilities.getCapabilityNames()) {

      if (!name.equals(Keys.ARGS.key)
          && !name.equals(Keys.PREFS.key)
          && !name.equals(Keys.PROFILE.key)
          && !name.equals(Keys.BINARY.key)
          && !name.equals(Keys.LOG.key)) {
        newInstance.setCapability(name, capabilities.getCapability(name));
      }

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

      if (name.equals(Keys.PREFS.key) && capabilities.getCapability(name) != null) {
        Map<String, Object> prefs = (Map<String, Object>) (capabilities.getCapability(("prefs")));
        prefs.forEach(newInstance::addPreference);
      }

      if (name.equals(Keys.PROFILE.key) && capabilities.getCapability(name) != null) {
        String rawProfile = (String) capabilities.getCapability("profile");
        try {
          newInstance.setProfile(FirefoxProfile.fromJson(rawProfile));
        } catch (IOException e) {
          throw new WebDriverException(e);
        }
      }

      if (name.equals(Keys.BINARY.key) && capabilities.getCapability(name) != null) {
        Object binary = capabilities.getCapability("binary");
        if (binary instanceof String) {
          newInstance.setBinary((String) binary);
        } else if (binary instanceof Path) {
          newInstance.setBinary((Path) binary);
        } else if (binary instanceof FirefoxBinary) {
          newInstance.setBinary((FirefoxBinary) binary);
        }
      }

      if (name.equals(Keys.LOG.key) && capabilities.getCapability(name) != null) {
        Map<String, Object> logLevelMap = (Map<String, Object>) capabilities.getCapability("log");
        FirefoxDriverLogLevel logLevel =
            FirefoxDriverLogLevel.fromString((String) logLevelMap.get("level"));
        if (logLevel != null) {
          newInstance.setLogLevel(logLevel);
        }
      }
    }

    if (capabilities instanceof FirefoxOptions) {
      newInstance.mirror((FirefoxOptions) capabilities);
    } else {
      Object optionsValue = capabilities.getCapability(FIREFOX_OPTIONS);

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

        @SuppressWarnings("unchecked")
        List<String> arguments = (List<String>) (options.getOrDefault("args", new ArrayList<>()));
        @SuppressWarnings("unchecked")
        Map<String, Object> prefs =
            (Map<String, Object>) options.getOrDefault("prefs", new HashMap<>());
        String rawProfile = (String) options.get("profile");
        @SuppressWarnings("unchecked")
        Map<String, Object> logLevelMap =
            (Map<String, Object>) options.getOrDefault("log", new HashMap<>());
        FirefoxDriverLogLevel logLevel =
            FirefoxDriverLogLevel.fromString((String) logLevelMap.get("level"));

        arguments.forEach(
            arg -> {
              if (!((List<String>) newInstance.firefoxOptions.get(Keys.ARGS.key())).contains(arg)) {
                newInstance.addArguments(arg);
              }
            });

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

        prefs.forEach(newInstance::addPreference);

        if (rawProfile != null) {
          try {
            newInstance.setProfile(FirefoxProfile.fromJson(rawProfile));
          } catch (IOException e) {
            throw new WebDriverException(e);
          }
        }

        if (logLevel != null) {
          newInstance.setLogLevel(logLevel);
        }
      }
    }

    return newInstance;
  }

  private enum Keys {
    ANDROID_PACKAGE("androidPackage") {
      @Override
      public void amend(Map<String, Object> sourceOptions, Map<String, Object> toAmend) {}

      @Override
      public Object mirror(Map<String, Object> first, Map<String, Object> second) {
        return null;
      }
    },
    ARGS("args") {
      @Override
      public void amend(Map<String, Object> sourceOptions, Map<String, Object> toAmend) {
        Object o = sourceOptions.get(key());
        if (!(o instanceof List)) {
          return;
        }

        Object rawArgs = toAmend.getOrDefault(key(), new ArrayList<>());
        @SuppressWarnings("unchecked")
        List<String> existingArgs = (List<String>) rawArgs;
        @SuppressWarnings("unchecked")
        List<String> sourceArgs = (List<String>) o;

        List<String> newArgs = new ArrayList<>(existingArgs);
        newArgs.addAll(sourceArgs);

        toAmend.put(key(), Collections.unmodifiableList(new ArrayList<>(newArgs)));
      }

      @Override
      public Object mirror(Map<String, Object> first, Map<String, Object> second) {
        Object rawFirst = first.getOrDefault(key(), new ArrayList<>());
        Require.stateCondition(
            rawFirst instanceof List, "Args are of unexpected type: %s", rawFirst);
        @SuppressWarnings("unchecked")
        List<String> firstList = (List<String>) rawFirst;

        Object rawSecond = second.getOrDefault(key(), new ArrayList<>());
        Require.stateCondition(
            rawSecond instanceof List, "Args are of unexpected type: %s", rawSecond);
        @SuppressWarnings("unchecked")
        List<String> secondList = (List<String>) rawSecond;

        List<String> args = new ArrayList<>(firstList);
        args.addAll(secondList);

        return args.isEmpty() ? null : args;
      }
    },
    BINARY("binary") {
      @Override
      public void amend(Map<String, Object> sourceOptions, Map<String, Object> toAmend) {
        Object o = sourceOptions.get(key());

        if (o instanceof FirefoxBinary) {
          FirefoxBinary binary = (FirefoxBinary) o;
          toAmend.put(key(), binary.getFile().toString());
          ARGS.amend(Collections.singletonMap(ARGS.key(), binary.getExtraOptions()), toAmend);
        } else if (o instanceof String) {
          toAmend.put(key(), o);
        }
      }

      @Override
      public Object mirror(Map<String, Object> first, Map<String, Object> second) {
        Object value = second.get(key());

        if (value == null) {
          value = first.get(key());
        }

        if (value == null) {
          return null;
        }

        Require.stateCondition(value instanceof String, "Unexpected type for binary: %s", value);
        return value;
      }
    },
    ENV("env") {
      @Override
      public void amend(Map<String, Object> sourceOptions, Map<String, Object> toAmend) {
        Object o = sourceOptions.get(key());
        if (o == null) {
          return;
        }

        Require.stateCondition(o instanceof Map, "Unexpected type for env: %s", o);
        Map<String, Object> collected =
            ((Map<?, ?>) o)
                .entrySet().stream()
                    .collect(toMap(entry -> String.valueOf(entry.getKey()), Map.Entry::getValue));

        toAmend.put(key(), Collections.unmodifiableMap(collected));
      }

      @Override
      public Object mirror(Map<String, Object> first, Map<String, Object> second) {
        Object rawFirst = first.getOrDefault(key(), new TreeMap<>());
        Require.stateCondition(
            rawFirst instanceof Map, "Env vars are of unexpected type: %s", rawFirst);
        @SuppressWarnings("unchecked")
        Map<String, String> firstPrefs = (Map<String, String>) rawFirst;

        Object rawSecond = second.getOrDefault(key(), new TreeMap<>());
        Require.stateCondition(
            rawSecond instanceof Map, "Env vars are of unexpected type: %s", rawSecond);
        @SuppressWarnings("unchecked")
        Map<String, String> secondPrefs = (Map<String, String>) rawSecond;

        Map<String, String> value = new TreeMap<>(firstPrefs);
        value.putAll(secondPrefs);

        return value.isEmpty() ? null : value;
      }
    },
    LOG("log") {
      @Override
      public void amend(Map<String, Object> sourceOptions, Map<String, Object> toAmend) {
        Object o = toAmend.get(key());
        if (o == null) {
          return;
        }

        Require.stateCondition(o instanceof Map, "Unexpected type for log: %s", o);
        toAmend.put(key(), o);
      }

      @Override
      public Object mirror(Map<String, Object> first, Map<String, Object> second) {
        Object value = second.get(key());

        if (value == null) {
          value = first.get(key());
        }

        if (value == null) {
          return null;
        }

        Require.stateCondition(value instanceof Map, "Log level is of unexpected type: %s", value);
        return value;
      }
    },
    PREFS("prefs") {
      @Override
      public void amend(Map<String, Object> sourceOptions, Map<String, Object> toAmend) {
        Object o = sourceOptions.get(key());
        if (o == null) {
          return;
        }
        Require.stateCondition(o instanceof Map, "Unexpected type for preferences: %s", o);
        Map<String, Object> collected =
            ((Map<?, ?>) o)
                .entrySet().stream()
                    .collect(toMap(entry -> String.valueOf(entry.getKey()), Map.Entry::getValue));
        toAmend.put(key(), Collections.unmodifiableMap(collected));
      }

      @Override
      public Object mirror(Map<String, Object> first, Map<String, Object> second) {
        Object rawFirst = first.getOrDefault(key(), new TreeMap<>());
        Require.stateCondition(
            rawFirst instanceof Map, "Prefs are of unexpected type: " + rawFirst);
        @SuppressWarnings("unchecked")
        Map<String, Object> firstPrefs = (Map<String, Object>) rawFirst;

        Object rawSecond = second.getOrDefault(key(), new TreeMap<>());
        Require.stateCondition(
            rawSecond instanceof Map, "Prefs are of unexpected type: " + rawSecond);
        @SuppressWarnings("unchecked")
        Map<String, Object> secondPrefs = (Map<String, Object>) rawSecond;

        Map<String, Object> value = new TreeMap<>(firstPrefs);
        value.putAll(secondPrefs);

        return value.isEmpty() ? null : value;
      }
    },
    PROFILE("profile") {
      @Override
      public void amend(Map<String, Object> sourceOptions, Map<String, Object> toAmend) {
        Object o = sourceOptions.get(key());
        if (o == null) {
          return;
        }

        if (o instanceof FirefoxProfile) {
          toAmend.put(key(), o);
          return;
        }

        Require.stateCondition(o instanceof String, "Unexpected type for profile: %s", o);
        toAmend.put(key(), o);
      }

      @Override
      public Object mirror(Map<String, Object> first, Map<String, Object> second) {
        Object value = second.get(key());

        if (value == null) {
          value = first.get(key());
        }

        if (value == null) {
          return null;
        }

        Require.stateCondition(value instanceof String, "Profile is of unexpected type: %s", value);
        return value;
      }
    },
    ;

    private final String key;

    Keys(String key) {
      this.key = key;
    }

    public String key() {
      return key;
    }

    public abstract void amend(Map<String, Object> sourceOptions, Map<String, Object> toAmend);

    public abstract Object mirror(Map<String, Object> first, Map<String, Object> second);
  }
}
