/*
 * Licensed to the Apache Software Foundation (ASF) under one or more
 * contributor license agreements.  See the NOTICE file distributed with
 * this work for additional information regarding copyright ownership.
 * The ASF 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.apache.solr.handler.admin;

import static org.apache.solr.common.SolrException.ErrorCode.SERVER_ERROR;

import java.io.IOException;
import java.io.InputStream;
import java.lang.invoke.MethodHandles;
import java.util.ArrayList;
import java.util.Collection;
import java.util.Collections;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import org.apache.solr.api.AnnotatedApi;
import org.apache.solr.api.Api;
import org.apache.solr.api.ApiBag;
import org.apache.solr.api.ApiBag.ReqHandlerToApi;
import org.apache.solr.common.SolrException;
import org.apache.solr.common.SpecProvider;
import org.apache.solr.common.params.CommonParams;
import org.apache.solr.common.util.CommandOperation;
import org.apache.solr.common.util.JsonSchemaValidator;
import org.apache.solr.common.util.Utils;
import org.apache.solr.core.CoreContainer;
import org.apache.solr.handler.RequestHandlerBase;
import org.apache.solr.handler.RequestHandlerUtils;
import org.apache.solr.handler.admin.api.GetAuthenticationConfigAPI;
import org.apache.solr.handler.admin.api.GetAuthorizationConfigAPI;
import org.apache.solr.handler.admin.api.ModifyNoAuthPluginSecurityConfigAPI;
import org.apache.solr.handler.admin.api.ModifyNoAuthzPluginSecurityConfigAPI;
import org.apache.solr.request.SolrQueryRequest;
import org.apache.solr.response.SolrQueryResponse;
import org.apache.solr.security.AuthenticationPlugin;
import org.apache.solr.security.AuthorizationContext;
import org.apache.solr.security.AuthorizationPlugin;
import org.apache.solr.security.ConfigEditablePlugin;
import org.apache.solr.security.PermissionNameProvider;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

public abstract class SecurityConfHandler extends RequestHandlerBase
    implements PermissionNameProvider {
  private static final Logger log = LoggerFactory.getLogger(MethodHandles.lookup().lookupClass());
  protected CoreContainer cores;

  public SecurityConfHandler(CoreContainer coreContainer) {
    this.cores = coreContainer;
  }

  @Override
  public PermissionNameProvider.Name getPermissionName(AuthorizationContext ctx) {
    switch (ctx.getHttpMethod()) {
      case "GET":
        return PermissionNameProvider.Name.SECURITY_READ_PERM;
      case "POST":
        return PermissionNameProvider.Name.SECURITY_EDIT_PERM;
      default:
        return null;
    }
  }

  @Override
  public void handleRequestBody(SolrQueryRequest req, SolrQueryResponse rsp) throws Exception {
    RequestHandlerUtils.setWt(req, CommonParams.JSON);
    String httpMethod = (String) req.getContext().get("httpMethod");
    String path = (String) req.getContext().get("path");
    String key = path.substring(path.lastIndexOf('/') + 1);
    if ("GET".equals(httpMethod)) {
      getConf(rsp, key);
    } else if ("POST".equals(httpMethod)) {
      Object plugin = getPlugin(key);
      doEdit(req, rsp, path, key, plugin);
    }
  }

  private void doEdit(
      SolrQueryRequest req,
      SolrQueryResponse rsp,
      String path,
      final String key,
      final Object plugin)
      throws IOException {
    ConfigEditablePlugin configEditablePlugin = null;

    if (plugin == null) {
      throw new SolrException(
          SolrException.ErrorCode.BAD_REQUEST, "No " + key + " plugin configured");
    }
    if (plugin instanceof ConfigEditablePlugin) {
      configEditablePlugin = (ConfigEditablePlugin) plugin;
    } else {
      throw new SolrException(SolrException.ErrorCode.BAD_REQUEST, key + " plugin is not editable");
    }

    if (req.getContentStreams() == null) {
      throw new SolrException(SolrException.ErrorCode.BAD_REQUEST, "No contentStream");
    }
    List<CommandOperation> ops =
        CommandOperation.readCommands(req.getContentStreams(), rsp.getValues());
    if (ops == null) {
      throw new SolrException(SolrException.ErrorCode.BAD_REQUEST, "No commands");
    }
    for (int count = 1; count <= 3; count++) {
      SecurityConfig securityConfig = getSecurityConfig(true);
      Map<String, Object> data = securityConfig.getData();
      @SuppressWarnings("unchecked")
      Map<String, Object> latestConf = (Map<String, Object>) data.get(key);
      if (latestConf == null) {
        throw new SolrException(SERVER_ERROR, "No configuration present for " + key);
      }
      List<CommandOperation> commandsCopy = CommandOperation.clone(ops);
      @SuppressWarnings("unchecked")
      Map<String, Object> out =
          configEditablePlugin.edit(Utils.getDeepCopy(latestConf, 4), commandsCopy);
      if (out == null) {
        List<Map<String, Object>> errs = CommandOperation.captureErrors(commandsCopy);
        if (!errs.isEmpty()) {
          rsp.add(CommandOperation.ERR_MSGS, errs);
          return;
        }
        log.debug("No edits made");
        return;
      } else {
        if (!Objects.equals(latestConf.get("class"), out.get("class"))) {
          throw new SolrException(SERVER_ERROR, "class cannot be modified");
        }
        Map<String, Object> meta = getMapValue(out, "");
        meta.put("v", securityConfig.getVersion() + 1); // encode the expected zkversion
        data.put(key, out);

        if (persistConf(securityConfig)) {
          securityConfEdited();
          return;
        }
      }
      log.debug("Security edit operation failed {} time(s)", count);
    }
    throw new SolrException(
        SERVER_ERROR, "Failed to persist security config after 3 attempts. Giving up");
  }

  /** Hook where you can do stuff after a config has been edited. Defaults to NOP */
  protected void securityConfEdited() {}

  Object getPlugin(String key) {
    Object plugin = null;
    if ("authentication".equals(key)) plugin = cores.getAuthenticationPlugin();
    if ("authorization".equals(key)) plugin = cores.getAuthorizationPlugin();
    return plugin;
  }

  protected abstract void getConf(SolrQueryResponse rsp, String key);

  public static Map<String, Object> getMapValue(Map<String, Object> lookupMap, String key) {
    @SuppressWarnings({"unchecked"})
    Map<String, Object> m = (Map<String, Object>) lookupMap.get(key);
    if (m == null) lookupMap.put(key, m = new LinkedHashMap<>());
    return m;
  }

  @SuppressWarnings({"rawtypes"})
  public static List getListValue(Map<String, Object> lookupMap, String key) {
    List l = (List) lookupMap.get(key);
    if (l == null) lookupMap.put(key, l = new ArrayList<>());
    return l;
  }

  @Override
  public String getDescription() {
    return "Edit or read security configuration";
  }

  @Override
  public Category getCategory() {
    return Category.ADMIN;
  }

  /** Gets security.json from source */
  public abstract SecurityConfig getSecurityConfig(boolean getFresh);

  /** Persist security.json to the source, optionally with a version */
  protected abstract boolean persistConf(SecurityConfig securityConfig) throws IOException;

  /**
   * Object to hold security.json as nested <code>Map&lt;String,Object&gt;</code> and optionally its
   * version. The version property is optional and defaults to -1 if not initialized. The data
   * object defaults to EMPTY_MAP if not set
   */
  public static class SecurityConfig {
    private Map<String, Object> data = Collections.emptyMap();
    private int version = -1;

    public SecurityConfig() {}

    /**
     * Sets the data as a Map
     *
     * @param data a Map
     * @return SecurityConf object (builder pattern)
     */
    public SecurityConfig setData(Map<String, Object> data) {
      this.data = data;
      return this;
    }

    /**
     * Sets the data as an Object, but the object needs to be of type Map
     *
     * @param data an Object of type Map&lt;String,Object&gt;
     * @return SecurityConf object (builder pattern)
     */
    @SuppressWarnings({"unchecked"})
    public SecurityConfig setData(Object data) {
      if (data instanceof Map) {
        this.data = (Map<String, Object>) data;
        return this;
      } else {
        throw new SolrException(
            SERVER_ERROR, "Illegal format when parsing security.json, not object");
      }
    }

    /**
     * Sets version
     *
     * @param version integer for version. Depends on underlying storage
     * @return SecurityConf object (builder pattern)
     */
    public SecurityConfig setVersion(int version) {
      this.version = version;
      return this;
    }

    public Map<String, Object> getData() {
      return data;
    }

    public int getVersion() {
      return version;
    }

    /**
     * Set data from input stream
     *
     * @param securityJsonInputStream an input stream for security.json
     * @return this (builder pattern)
     */
    public SecurityConfig setData(InputStream securityJsonInputStream) {
      return setData(Utils.fromJSON(securityJsonInputStream));
    }

    @Override
    public String toString() {
      return "SecurityConfig: version=" + version + ", data=" + Utils.toJSONString(data);
    }
  }

  private Collection<Api> apis;
  private AuthenticationPlugin authcPlugin;
  private AuthorizationPlugin authzPlugin;

  @Override
  public Collection<Api> getApis() {
    if (apis == null) {
      synchronized (this) {
        if (apis == null) {
          Collection<Api> apis = new ArrayList<>();
          // GET Apis are the same regardless of which plugins are registered
          apis.addAll(AnnotatedApi.getApis(new GetAuthenticationConfigAPI(this)));
          apis.addAll(AnnotatedApi.getApis(new GetAuthorizationConfigAPI(this)));

          // POST Apis come from the specific authc/z plugin registered (with a fallback used if
          // the plugin isn't a SpecProvider).
          final Api defaultAuthcApi =
              AnnotatedApi.getApis(new ModifyNoAuthPluginSecurityConfigAPI(this)).get(0);
          final Api defaultAuthzApi =
              AnnotatedApi.getApis(new ModifyNoAuthzPluginSecurityConfigAPI(this)).get(0);

          SpecProvider authcSpecProvider =
              () -> {
                AuthenticationPlugin authcPlugin = cores.getAuthenticationPlugin();
                return authcPlugin != null && authcPlugin instanceof SpecProvider
                    ? ((SpecProvider) authcPlugin).getSpec()
                    : defaultAuthcApi.getSpec();
              };

          // TODO Can we remove this extra ReqHandlerToApi wrapping - nothing but the schema from
          // the POST authc/authz is getting used.
          apis.add(
              new ReqHandlerToApi(this, authcSpecProvider) {
                @Override
                public synchronized Map<String, JsonSchemaValidator> getCommandSchema() {
                  // it is possible that the Authentication plugin is modified since the last call.
                  // invalidate the cached commandSchema
                  if (SecurityConfHandler.this.authcPlugin != cores.getAuthenticationPlugin())
                    commandSchema = null;
                  SecurityConfHandler.this.authcPlugin = cores.getAuthenticationPlugin();
                  return super.getCommandSchema();
                }
              });

          SpecProvider authzSpecProvider =
              () -> {
                AuthorizationPlugin authzPlugin = cores.getAuthorizationPlugin();
                return authzPlugin != null && authzPlugin instanceof SpecProvider
                    ? ((SpecProvider) authzPlugin).getSpec()
                    : defaultAuthzApi.getSpec();
              };
          apis.add(
              new ApiBag.ReqHandlerToApi(this, authzSpecProvider) {
                @Override
                public synchronized Map<String, JsonSchemaValidator> getCommandSchema() {
                  // it is possible that the Authorization plugin is modified since the last call.
                  // invalidate cached commandSchema
                  if (SecurityConfHandler.this.authzPlugin != cores.getAuthorizationPlugin())
                    commandSchema = null;
                  SecurityConfHandler.this.authzPlugin = cores.getAuthorizationPlugin();
                  return super.getCommandSchema();
                }
              });

          this.apis = List.copyOf(apis);
        }
      }
    }
    return this.apis;
  }

  @Override
  public Boolean registerV2() {
    return Boolean.TRUE;
  }
}
