// 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.remote.codec;

import static com.google.common.net.HttpHeaders.CACHE_CONTROL;
import static com.google.common.net.HttpHeaders.CONTENT_LENGTH;
import static com.google.common.net.HttpHeaders.CONTENT_TYPE;
import static com.google.common.net.MediaType.JSON_UTF_8;
import static java.nio.charset.StandardCharsets.UTF_8;
import static org.openqa.selenium.json.Json.MAP_TYPE;
import static org.openqa.selenium.remote.DriverCommand.ADD_COOKIE;
import static org.openqa.selenium.remote.DriverCommand.ADD_CREDENTIAL;
import static org.openqa.selenium.remote.DriverCommand.ADD_VIRTUAL_AUTHENTICATOR;
import static org.openqa.selenium.remote.DriverCommand.CLEAR_ELEMENT;
import static org.openqa.selenium.remote.DriverCommand.CLICK_ELEMENT;
import static org.openqa.selenium.remote.DriverCommand.CLOSE;
import static org.openqa.selenium.remote.DriverCommand.DELETE_ALL_COOKIES;
import static org.openqa.selenium.remote.DriverCommand.DELETE_COOKIE;
import static org.openqa.selenium.remote.DriverCommand.ELEMENT_EQUALS;
import static org.openqa.selenium.remote.DriverCommand.ELEMENT_SCREENSHOT;
import static org.openqa.selenium.remote.DriverCommand.FIND_CHILD_ELEMENT;
import static org.openqa.selenium.remote.DriverCommand.FIND_CHILD_ELEMENTS;
import static org.openqa.selenium.remote.DriverCommand.FIND_ELEMENT;
import static org.openqa.selenium.remote.DriverCommand.FIND_ELEMENTS;
import static org.openqa.selenium.remote.DriverCommand.FULLSCREEN_CURRENT_WINDOW;
import static org.openqa.selenium.remote.DriverCommand.GET;
import static org.openqa.selenium.remote.DriverCommand.GET_ALL_COOKIES;
import static org.openqa.selenium.remote.DriverCommand.GET_ALL_SESSIONS;
import static org.openqa.selenium.remote.DriverCommand.GET_APP_CACHE_STATUS;
import static org.openqa.selenium.remote.DriverCommand.GET_AVAILABLE_LOG_TYPES;
import static org.openqa.selenium.remote.DriverCommand.GET_CAPABILITIES;
import static org.openqa.selenium.remote.DriverCommand.GET_CONTEXT_HANDLES;
import static org.openqa.selenium.remote.DriverCommand.GET_COOKIE;
import static org.openqa.selenium.remote.DriverCommand.GET_CREDENTIALS;
import static org.openqa.selenium.remote.DriverCommand.GET_CURRENT_CONTEXT_HANDLE;
import static org.openqa.selenium.remote.DriverCommand.GET_CURRENT_URL;
import static org.openqa.selenium.remote.DriverCommand.GET_ELEMENT_LOCATION;
import static org.openqa.selenium.remote.DriverCommand.GET_ELEMENT_RECT;
import static org.openqa.selenium.remote.DriverCommand.GET_ELEMENT_SIZE;
import static org.openqa.selenium.remote.DriverCommand.GET_ELEMENT_TAG_NAME;
import static org.openqa.selenium.remote.DriverCommand.GET_ELEMENT_TEXT;
import static org.openqa.selenium.remote.DriverCommand.GET_ELEMENT_VALUE_OF_CSS_PROPERTY;
import static org.openqa.selenium.remote.DriverCommand.GET_LOCATION;
import static org.openqa.selenium.remote.DriverCommand.GET_LOG;
import static org.openqa.selenium.remote.DriverCommand.GET_NETWORK_CONNECTION;
import static org.openqa.selenium.remote.DriverCommand.GET_SCREEN_ORIENTATION;
import static org.openqa.selenium.remote.DriverCommand.GET_SCREEN_ROTATION;
import static org.openqa.selenium.remote.DriverCommand.GET_SESSION_LOGS;
import static org.openqa.selenium.remote.DriverCommand.GET_TIMEOUTS;
import static org.openqa.selenium.remote.DriverCommand.GET_TITLE;
import static org.openqa.selenium.remote.DriverCommand.GO_BACK;
import static org.openqa.selenium.remote.DriverCommand.GO_FORWARD;
import static org.openqa.selenium.remote.DriverCommand.IMPLICITLY_WAIT;
import static org.openqa.selenium.remote.DriverCommand.IS_BROWSER_ONLINE;
import static org.openqa.selenium.remote.DriverCommand.IS_ELEMENT_ENABLED;
import static org.openqa.selenium.remote.DriverCommand.IS_ELEMENT_SELECTED;
import static org.openqa.selenium.remote.DriverCommand.NEW_SESSION;
import static org.openqa.selenium.remote.DriverCommand.QUIT;
import static org.openqa.selenium.remote.DriverCommand.REFRESH;
import static org.openqa.selenium.remote.DriverCommand.REMOVE_ALL_CREDENTIALS;
import static org.openqa.selenium.remote.DriverCommand.REMOVE_CREDENTIAL;
import static org.openqa.selenium.remote.DriverCommand.REMOVE_VIRTUAL_AUTHENTICATOR;
import static org.openqa.selenium.remote.DriverCommand.SCREENSHOT;
import static org.openqa.selenium.remote.DriverCommand.SEND_KEYS_TO_ELEMENT;
import static org.openqa.selenium.remote.DriverCommand.SET_ALERT_CREDENTIALS;
import static org.openqa.selenium.remote.DriverCommand.SET_BROWSER_ONLINE;
import static org.openqa.selenium.remote.DriverCommand.SET_LOCATION;
import static org.openqa.selenium.remote.DriverCommand.SET_NETWORK_CONNECTION;
import static org.openqa.selenium.remote.DriverCommand.SET_SCREEN_ORIENTATION;
import static org.openqa.selenium.remote.DriverCommand.SET_SCREEN_ROTATION;
import static org.openqa.selenium.remote.DriverCommand.SET_SCRIPT_TIMEOUT;
import static org.openqa.selenium.remote.DriverCommand.SET_TIMEOUT;
import static org.openqa.selenium.remote.DriverCommand.SET_USER_VERIFIED;
import static org.openqa.selenium.remote.DriverCommand.STATUS;
import static org.openqa.selenium.remote.DriverCommand.SWITCH_TO_CONTEXT;
import static org.openqa.selenium.remote.DriverCommand.SWITCH_TO_FRAME;
import static org.openqa.selenium.remote.DriverCommand.SWITCH_TO_NEW_WINDOW;
import static org.openqa.selenium.remote.DriverCommand.SWITCH_TO_PARENT_FRAME;
import static org.openqa.selenium.remote.DriverCommand.SWITCH_TO_WINDOW;
import static org.openqa.selenium.remote.http.Contents.bytes;
import static org.openqa.selenium.remote.http.Contents.string;

import com.google.common.base.Objects;
import com.google.common.base.Splitter;
import com.google.common.base.Strings;
import com.google.common.collect.ImmutableList;
import java.util.HashMap;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
import org.openqa.selenium.UnsupportedCommandException;
import org.openqa.selenium.internal.Require;
import org.openqa.selenium.json.Json;
import org.openqa.selenium.net.Urls;
import org.openqa.selenium.remote.Command;
import org.openqa.selenium.remote.CommandCodec;
import org.openqa.selenium.remote.SessionId;
import org.openqa.selenium.remote.http.HttpMethod;
import org.openqa.selenium.remote.http.HttpRequest;

/**
 * A command codec that adheres to the W3C's WebDriver wire protocol.
 *
 * @see <a href="https://w3.org/tr/webdriver">W3C WebDriver spec</a>
 */
public abstract class AbstractHttpCommandCodec implements CommandCodec<HttpRequest> {
  private static final Splitter PATH_SPLITTER = Splitter.on('/').omitEmptyStrings();
  private static final String SESSION_ID_PARAM = "sessionId";

  private final ConcurrentHashMap<String, CommandSpec> nameToSpec = new ConcurrentHashMap<>();
  private final Map<String, String> aliases = new HashMap<>();
  private final Json json = new Json();

  public AbstractHttpCommandCodec() {
    defineCommand(STATUS, get("/status"));

    String sessionId = "/session/:sessionId";

    defineCommand(GET_ALL_SESSIONS, get("/sessions"));
    defineCommand(NEW_SESSION, post("/session"));
    defineCommand(GET_CAPABILITIES, get(sessionId));
    defineCommand(QUIT, delete(sessionId));

    defineCommand(GET_SESSION_LOGS, post("/logs"));
    defineCommand(GET_LOG, post(sessionId + "/log"));
    defineCommand(GET_AVAILABLE_LOG_TYPES, get(sessionId + "/log/types"));

    defineCommand(SWITCH_TO_FRAME, post(sessionId + "/frame"));
    defineCommand(SWITCH_TO_PARENT_FRAME, post(sessionId + "/frame/parent"));

    String window = sessionId + "/window";
    defineCommand(CLOSE, delete(window));
    defineCommand(SWITCH_TO_WINDOW, post(window));
    defineCommand(SWITCH_TO_NEW_WINDOW, post(window + "/new"));
    defineCommand(FULLSCREEN_CURRENT_WINDOW, post(window + "/fullscreen"));

    defineCommand(GET_CURRENT_URL, get(sessionId + "/url"));
    defineCommand(GET, post(sessionId + "/url"));
    defineCommand(GO_BACK, post(sessionId + "/back"));
    defineCommand(GO_FORWARD, post(sessionId + "/forward"));
    defineCommand(REFRESH, post(sessionId + "/refresh"));

    defineCommand(SET_ALERT_CREDENTIALS, post(sessionId + "/alert/credentials"));

    defineCommand(SCREENSHOT, get(sessionId + "/screenshot"));
    defineCommand(ELEMENT_SCREENSHOT, get(sessionId + "/element/:id/screenshot"));
    defineCommand(GET_TITLE, get(sessionId + "/title"));

    defineCommand(FIND_ELEMENT, post(sessionId + "/element"));
    defineCommand(FIND_ELEMENTS, post(sessionId + "/elements"));

    String elementId = sessionId + "/element/:id";
    defineCommand(CLICK_ELEMENT, post(elementId + "/click"));
    defineCommand(CLEAR_ELEMENT, post(elementId + "/clear"));
    defineCommand(GET_ELEMENT_VALUE_OF_CSS_PROPERTY, get(elementId + "/css/:propertyName"));
    defineCommand(FIND_CHILD_ELEMENT, post(elementId + "/element"));
    defineCommand(FIND_CHILD_ELEMENTS, post(elementId + "/elements"));
    defineCommand(IS_ELEMENT_ENABLED, get(elementId + "/enabled"));
    defineCommand(ELEMENT_EQUALS, get(elementId + "/equals/:other"));
    defineCommand(GET_ELEMENT_RECT, get(elementId + "/rect"));
    defineCommand(GET_ELEMENT_LOCATION, get(elementId + "/location"));
    defineCommand(GET_ELEMENT_TAG_NAME, get(elementId + "/name"));
    defineCommand(IS_ELEMENT_SELECTED, get(elementId + "/selected"));
    defineCommand(GET_ELEMENT_SIZE, get(elementId + "/size"));
    defineCommand(GET_ELEMENT_TEXT, get(elementId + "/text"));
    defineCommand(SEND_KEYS_TO_ELEMENT, post(elementId + "/value"));

    String cookie = sessionId + "/cookie";
    defineCommand(GET_ALL_COOKIES, get(cookie));
    defineCommand(GET_COOKIE, get(cookie + "/:name"));
    defineCommand(ADD_COOKIE, post(cookie));
    defineCommand(DELETE_ALL_COOKIES, delete(cookie));
    defineCommand(DELETE_COOKIE, delete(cookie + "/:name"));

    String timeouts = sessionId + "/timeouts";
    defineCommand(GET_TIMEOUTS, get(timeouts));
    defineCommand(SET_TIMEOUT, post(timeouts));
    defineCommand(SET_SCRIPT_TIMEOUT, post(timeouts + "/async_script"));
    defineCommand(IMPLICITLY_WAIT, post(timeouts + "/implicit_wait"));

    defineCommand(GET_APP_CACHE_STATUS, get(sessionId + "/application_cache/status"));
    defineCommand(IS_BROWSER_ONLINE, get(sessionId + "/browser_connection"));
    defineCommand(SET_BROWSER_ONLINE, post(sessionId + "/browser_connection"));
    defineCommand(GET_LOCATION, get(sessionId + "/location"));
    defineCommand(SET_LOCATION, post(sessionId + "/location"));

    defineCommand(GET_SCREEN_ORIENTATION, get(sessionId + "/orientation"));
    defineCommand(SET_SCREEN_ORIENTATION, post(sessionId + "/orientation"));
    defineCommand(GET_SCREEN_ROTATION, get(sessionId + "/rotation"));
    defineCommand(SET_SCREEN_ROTATION, post(sessionId + "/rotation"));

    // Mobile Spec
    defineCommand(GET_NETWORK_CONNECTION, get(sessionId + "/network_connection"));
    defineCommand(SET_NETWORK_CONNECTION, post(sessionId + "/network_connection"));
    defineCommand(SWITCH_TO_CONTEXT, post(sessionId + "/context"));
    defineCommand(GET_CURRENT_CONTEXT_HANDLE, get(sessionId + "/context"));
    defineCommand(GET_CONTEXT_HANDLES, get(sessionId + "/contexts"));

    // Virtual Authenticator API
    String webauthn = sessionId + "/webauthn/authenticator";
    String webauthnId = webauthn + "/:authenticatorId";
    defineCommand(ADD_VIRTUAL_AUTHENTICATOR, post(webauthn));
    defineCommand(REMOVE_VIRTUAL_AUTHENTICATOR, delete(webauthnId));
    defineCommand(ADD_CREDENTIAL, post(webauthnId + "/credential"));
    defineCommand(GET_CREDENTIALS, get(webauthnId + "/credentials"));
    defineCommand(REMOVE_CREDENTIAL, delete(webauthnId + "/credentials/:credentialId"));
    defineCommand(REMOVE_ALL_CREDENTIALS, delete(webauthnId + "/credentials"));
    defineCommand(SET_USER_VERIFIED, post(webauthnId + "/uv"));
  }

  protected static CommandSpec delete(String path) {
    return new CommandSpec(HttpMethod.DELETE, path);
  }

  protected static CommandSpec get(String path) {
    return new CommandSpec(HttpMethod.GET, path);
  }

  protected static CommandSpec post(String path) {
    return new CommandSpec(HttpMethod.POST, path);
  }

  @Override
  public boolean isSupported(String commandName) {
    Require.nonNull("Command name", commandName);
    return nameToSpec.containsKey(commandName) || aliases.containsKey(commandName);
  }

  @Override
  public HttpRequest encode(Command command) {
    String name = aliases.getOrDefault(command.getName(), command.getName());
    CommandSpec spec = nameToSpec.get(name);
    if (spec == null) {
      throw new UnsupportedCommandException(command.getName());
    }
    Map<String, ?> parameters = amendParameters(command.getName(), command.getParameters());
    String uri = buildUri(name, command.getSessionId(), parameters, spec);

    HttpRequest request = new HttpRequest(spec.method, uri);

    if (HttpMethod.POST == spec.method) {
      String content = json.toJson(parameters);
      byte[] data = content.getBytes(UTF_8);

      request.setHeader(CONTENT_LENGTH, String.valueOf(data.length));
      request.setHeader(CONTENT_TYPE, JSON_UTF_8.toString());
      request.setContent(bytes(data));
    }

    if (HttpMethod.GET == spec.method) {
      request.setHeader(CACHE_CONTROL, "no-cache");
    }

    return request;
  }

  protected abstract Map<String, ?> amendParameters(String name, Map<String, ?> parameters);

  @Override
  public Command decode(final HttpRequest encodedCommand) {
    final String path =
        Strings.isNullOrEmpty(encodedCommand.getUri()) ? "/" : encodedCommand.getUri();
    final ImmutableList<String> parts = ImmutableList.copyOf(PATH_SPLITTER.split(path));
    int minPathLength = Integer.MAX_VALUE;
    CommandSpec spec = null;
    String name = null;
    for (Map.Entry<String, CommandSpec> nameValue : nameToSpec.entrySet()) {
      if ((nameValue.getValue().pathSegments.size() < minPathLength)
          && nameValue.getValue().isFor(encodedCommand.getMethod(), parts)) {
        name = nameValue.getKey();
        spec = nameValue.getValue();
      }
    }
    if (name == null) {
      throw new UnsupportedCommandException(
          encodedCommand.getMethod() + " " + encodedCommand.getUri());
    }
    Map<String, Object> parameters = new HashMap<>();
    spec.parsePathParameters(parts, parameters);

    String content = string(encodedCommand);
    if (!content.isEmpty()) {
      Map<String, Object> tmp = json.toType(content, MAP_TYPE);
      parameters.putAll(tmp);
    }

    SessionId sessionId = null;
    if (parameters.containsKey(SESSION_ID_PARAM)) {
      String id = (String) parameters.remove(SESSION_ID_PARAM);
      if (id != null) {
        sessionId = new SessionId(id);
      }
    }

    return new Command(sessionId, name, parameters);
  }

  /**
   * Defines a new command mapping.
   *
   * @param name The command name.
   * @param method The HTTP method to use for the command.
   * @param pathPattern The URI path pattern for the command. When encoding a command, each path
   *     segment prefixed with a ":" will be replaced with the corresponding parameter from the
   *     encoded command.
   */
  @Override
  public void defineCommand(String name, HttpMethod method, String pathPattern) {
    defineCommand(name, new CommandSpec(method, pathPattern));
  }

  @Override
  public void alias(String commandName, String isAnAliasFor) {
    aliases.put(commandName, isAnAliasFor);
  }

  protected void defineCommand(String name, CommandSpec spec) {
    nameToSpec.put(Require.nonNull("Name", name), spec);
  }

  private String buildUri(
      String commandName, SessionId sessionId, Map<String, ?> parameters, CommandSpec spec) {
    StringBuilder builder = new StringBuilder();
    for (String part : spec.pathSegments) {
      if (part.isEmpty()) {
        continue;
      }

      builder.append("/");
      if (part.startsWith(":")) {
        builder.append(getParameter(part.substring(1), commandName, sessionId, parameters));
      } else {
        builder.append(part);
      }
    }
    return builder.toString();
  }

  private String getParameter(
      String parameterName, String commandName, SessionId sessionId, Map<String, ?> parameters) {
    if ("sessionId".equals(parameterName)) {
      Require.argument("Session id", sessionId)
          .nonNull("Session ID may not be null for command %s", commandName);
      return sessionId.toString();
    }

    Object value = parameters.get(parameterName);
    Require.argument("Parameter", value)
        .nonNull("Missing required parameter \"%s\" for command %s", parameterName, commandName);
    return Urls.urlEncode(String.valueOf(value));
  }

  protected static class CommandSpec {
    private final HttpMethod method;
    private final String path;
    private final ImmutableList<String> pathSegments;

    private CommandSpec(HttpMethod method, String path) {
      this.method = Require.nonNull("HTTP method", method);
      this.path = path;
      this.pathSegments = ImmutableList.copyOf(PATH_SPLITTER.split(path));
    }

    @Override
    public boolean equals(Object o) {
      if (o instanceof CommandSpec) {
        CommandSpec that = (CommandSpec) o;
        return this.method.equals(that.method) && this.path.equals(that.path);
      }
      return false;
    }

    @Override
    public int hashCode() {
      return Objects.hashCode(method, path);
    }

    /**
     * Returns whether this instance matches the provided HTTP request.
     *
     * @param method The request method.
     * @param parts The parsed request path segments.
     * @return Whether this instance matches the request.
     */
    boolean isFor(HttpMethod method, ImmutableList<String> parts) {
      if (!this.method.equals(method)) {
        return false;
      }

      if (parts.size() != this.pathSegments.size()) {
        return false;
      }

      for (int i = 0; i < parts.size(); ++i) {
        String reqPart = parts.get(i);
        String specPart = pathSegments.get(i);
        if (!(specPart.startsWith(":") || specPart.equals(reqPart))) {
          return false;
        }
      }

      return true;
    }

    void parsePathParameters(ImmutableList<String> parts, Map<String, Object> parameters) {
      for (int i = 0; i < parts.size(); ++i) {
        if (pathSegments.get(i).startsWith(":")) {
          parameters.put(pathSegments.get(i).substring(1), parts.get(i));
        }
      }
    }
  }
}
