/*
 * junixsocket
 *
 * Copyright 2009-2024 Christian Kohlschütter
 *
 * Licensed 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.newsclub.net.unix.rmi;

import java.io.Closeable;
import java.io.IOException;
import java.net.MalformedURLException;
import java.rmi.AccessException;
import java.rmi.AlreadyBoundException;
import java.rmi.ConnectIOException;
import java.rmi.Naming;
import java.rmi.NoSuchObjectException;
import java.rmi.NotBoundException;
import java.rmi.Remote;
import java.rmi.RemoteException;
import java.rmi.ServerException;
import java.rmi.registry.LocateRegistry;
import java.rmi.registry.Registry;
import java.rmi.server.RMISocketFactory;
import java.rmi.server.UnicastRemoteObject;
import java.util.HashMap;
import java.util.Map;
import java.util.Objects;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicBoolean;

import org.eclipse.jdt.annotation.NonNull;
import org.newsclub.net.unix.AFSocket;
import org.newsclub.net.unix.rmi.ShutdownHookSupport.ShutdownHook;

import com.kohlschutter.annotations.compiletime.SuppressFBWarnings;

/**
 * The {@link AFSocket}-compatible equivalent of {@link Naming}. Use this class for accessing RMI
 * registries that are reachable by {@link AFSocket}s.
 *
 * @author Christian Kohlschütter
 */
public abstract class AFNaming extends AFRegistryAccess {
  private static final String RMI_SERVICE_NAME = AFRMIService.class.getName();

  private static final Map<AFNamingRef, AFNaming> INSTANCES = new HashMap<>();

  private AFRegistry registry = null;
  private AFRMIService rmiService = null;
  private final int registryPort;
  private final int servicePort;
  AFRMISocketFactory socketFactory;
  private final AtomicBoolean remoteShutdownAllowed = new AtomicBoolean(true);
  private final AtomicBoolean shutdownInProgress = new AtomicBoolean(false);
  private final AtomicBoolean addedShutdownHook = new AtomicBoolean(false);

  /**
   * Creates a new naming instance with the given ports.
   *
   * @param registryPort The registry port.
   * @param servicePort The port for AFRMIService.
   */
  protected AFNaming(final int registryPort, final int servicePort) {
    super();
    this.registryPort = registryPort;
    this.servicePort = servicePort;
  }

  /**
   * Creates a new {@link AFRegistry} given a {@link Registry} implementation.
   *
   * @param impl The implementation.
   * @return The new {@link AFRegistry} instance.
   * @throws RemoteException on error.
   */
  protected abstract AFRegistry newAFRegistry(Registry impl) throws RemoteException;

  /**
   * Creates or returns the {@link AFRMISocketFactory} to be used with this instance.
   *
   * @return The socket factory.
   * @throws IOException on error.
   */
  protected abstract AFRMISocketFactory initSocketFactory() throws IOException;

  @SuppressWarnings("unchecked")
  static <T extends AFNaming> T getInstance(final int registryPort,
      @NonNull AFNamingProvider<T> provider) throws RemoteException {
    Objects.requireNonNull(provider);
    final AFNamingRef sap = new AFNamingRef(provider, registryPort);
    T instance;
    synchronized (AFNaming.class) {
      instance = (T) INSTANCES.get(sap);
      if (instance == null) {
        try {
          instance = provider.newInstance(registryPort);
          Objects.requireNonNull(instance);
          synchronized (instance) {
            instance.socketFactory = instance.initSocketFactory();
          }
        } catch (RemoteException e) {
          throw e;
        } catch (IOException e) {
          throw new RemoteException(e.getMessage(), e);
        }
        INSTANCES.put(sap, instance);
      }
    }
    return instance;
  }

  /**
   * Returns the {@link AFRMISocketFactory} associated with this instance.
   *
   * @return The {@link AFRMISocketFactory}.
   */
  @SuppressFBWarnings("EI_EXPOSE_REP")
  public synchronized AFRMISocketFactory getSocketFactory() {
    return socketFactory;
  }

  /**
   * Returns the registry port.
   *
   * @return The port.
   */
  public final int getRegistryPort() {
    return registryPort;
  }

  AFRMIService getRMIService() throws RemoteException, NotBoundException {
    return getRMIService(getRegistry());
  }

  synchronized AFRMIService getRMIService(AFRegistry reg) throws RemoteException,
      NotBoundException {
    if (rmiService == null) {
      this.rmiService = getRMIServiceFromRegistry(reg);
    }
    return rmiService;
  }

  AFRMIService getRMIServiceFromRegistry(AFRegistry reg) throws RemoteException, NotBoundException {
    AFRMIService service;
    service = (AFRMIService) reg.lookup(RMI_SERVICE_NAME, 5, TimeUnit.SECONDS);
    this.remoteShutdownAllowed.set(service.isShutdownAllowed());
    return service;
  }

  private void closeUponRuntimeShutdown() {
    if (addedShutdownHook.compareAndSet(false, true)) {
      ShutdownHookSupport.addWeakShutdownHook(new ShutdownHook() {

        @Override
        @SuppressWarnings("LockOnNonEnclosingClassLiteral" /* errorprone */)
        public synchronized void onRuntimeShutdown(Thread thread) throws IOException {
          if (registry != null && registry.isLocal()) {
            shutdownRegistry();
          }
        }
      });
    }
  }

  private synchronized void rebindRMIService(final AFRMIService assigner) throws RemoteException {
    rmiService = assigner;
    getRegistry().rebind(RMI_SERVICE_NAME, assigner);
  }

  @Override
  public AFRegistry getRegistry() throws RemoteException {
    return getRegistry(0, TimeUnit.SECONDS);
  }

  /**
   * Returns a reference to the existing RMI registry.
   *
   * If there's no registry running at this port after waiting for up to the given time, an
   * exception is thrown.
   *
   * @param timeout The timeout value.
   * @param unit The timeout unit.
   * @return The registry.
   * @throws RemoteException If there was a problem.
   */
  public AFRegistry getRegistry(long timeout, TimeUnit unit) throws RemoteException {
    if (shutdownInProgress.get()) {
      throw new ShutdownException();
    }
    synchronized (this) {
      AFRegistry reg = getRegistry(false);
      if (reg == null) {
        reg = openRegistry(timeout, unit);
      }
      return reg;
    }
  }

  /**
   * Tries to access the registry, waiting some time if necessary.
   *
   * @param timeout The timeout.
   * @param unit The unit for the timeout.
   * @return The registry instance.
   * @throws RemoteException on error.
   */
  protected abstract AFRegistry openRegistry(long timeout, TimeUnit unit) throws RemoteException;

  /**
   * Returns a reference to the RMI registry, or {@code null}.
   *
   * If there's no registry running at this port, and {@code create} is set to {@code true}, a new
   * one is created; when {@code create} is set to {@code false}, {@code null} is returned.
   *
   * @param create {@code true} if a new register may be created if necessary.
   * @return The registry, or {@code null}
   * @throws RemoteException If there was a problem.
   */
  public AFRegistry getRegistry(boolean create) throws RemoteException {
    if (shutdownInProgress.get()) {
      throw new ShutdownException();
    }
    synchronized (this) {
      if (registry != null) {
        return registry;
      } else if (!socketFactory.hasRegisteredPort(registryPort)) {
        return create ? createRegistry() : null;
      }

      AFRegistry reg = locateRegistry();
      setRegistry(reg);

      try {
        getRMIService(reg);
      } catch (NotBoundException | NoSuchObjectException | ConnectIOException e) {
        if (create) {
          setRegistry(null);
          return createRegistry();
        } else {
          throw new ServerException("Could not access " + AFRMIService.class.getName(), e);
        }
      }

      return registry;
    }
  }

  private AFRegistry locateRegistry() throws RemoteException {
    Registry regImpl = LocateRegistry.getRegistry(null, registryPort, socketFactory);
    return regImpl == null ? null : newAFRegistry(regImpl);
  }

  /**
   * Shuts this RMI Registry down.
   *
   * @throws RemoteException if the operation fails.
   */
  public void shutdownRegistry() throws RemoteException {
    synchronized (this) {
      if (registry == null) {
        return;
      }

      AFRegistry registryToBeClosed = registry;
      AFRMIService rmiServiceToBeClosed = rmiService;

      if (!registryToBeClosed.isLocal()) {
        if (!isRemoteShutdownAllowed()) {
          throw new ServerException("The server refuses to be shutdown remotely");
        }
        setRegistry(null);

        try {
          shutdownViaRMIService(registryToBeClosed, rmiServiceToBeClosed);
        } catch (Exception e) {
          // ignore
        }
        return;
      }

      setRegistry(null);

      if (!shutdownInProgress.compareAndSet(false, true)) {
        return;
      }
      try {
        unexportRMIService(registryToBeClosed, (AFRMIServiceImpl) rmiServiceToBeClosed);
        forceUnexportBound(registryToBeClosed);
        closeSocketFactory();
        shutdownRegistryFinishingTouches();
      } finally {
        shutdownInProgress.set(false);
      }
    }
  }

  /**
   * Called by {@link #shutdownRegistry()} as the final step.
   */
  protected abstract void shutdownRegistryFinishingTouches();

  private synchronized void unexportRMIService(AFRegistry reg, AFRMIServiceImpl serv)
      throws AccessException, RemoteException {
    if (serv != null) {
      serv.shutdownRegisteredCloseables();
    }

    try {
      if (serv != null) {
        unexportObject(serv);
      }
      reg.unbind(RMI_SERVICE_NAME);
    } catch (ShutdownException | NotBoundException e) {
      // ignore
    }
    this.rmiService = null;
  }

  private void forceUnexportBound(AFRegistry reg) {
    try {
      reg.forceUnexportBound();
    } catch (Exception e) {
      // ignore
    }
  }

  private void closeSocketFactory() {
    if (socketFactory != null) {
      try {
        socketFactory.close();
      } catch (IOException e) {
        // ignore
      }
    }
  }

  private void shutdownViaRMIService(AFRegistry reg, AFRMIService serv) throws RemoteException {
    try {
      if (serv == null) {
        serv = getRMIService(reg);
      }
      if (serv.isShutdownAllowed()) {
        serv.shutdown();
      }
    } catch (ServerException | ConnectIOException | NotBoundException e) {
      // ignore
    }
  }

  /**
   * Creates a new RMI {@link Registry}.
   *
   * If there already was a registry created previously, it is shut down and replaced by the current
   * one.
   *
   * Use {@link #getRegistry()} to try to reuse an existing registry.
   *
   * @return The registry
   * @throws RemoteException if the operation fails.
   * @see #getRegistry()
   */
  public synchronized AFRegistry createRegistry() throws RemoteException {
    AFRegistry existingRegistry = registry;
    if (existingRegistry == null) {
      try {
        existingRegistry = getRegistry(false);
      } catch (ServerException e) {
        Throwable cause = e.getCause();
        if (cause instanceof NotBoundException || cause instanceof ConnectIOException) {
          existingRegistry = null;
        } else {
          throw e;
        }
      }
    }
    if (existingRegistry != null) {
      if (!isRemoteShutdownAllowed()) {
        throw new ServerException("The server refuses to be shutdown remotely");
      }
      shutdownRegistry();
    }

    initRegistryPrerequisites();
    AFRegistry newAFRegistry = newAFRegistry(LocateRegistry.createRegistry(registryPort,
        socketFactory, socketFactory));
    setRegistry(newAFRegistry);

    final AFRMIService service = new AFRMIServiceImpl(this);
    UnicastRemoteObject.exportObject(service, servicePort, socketFactory, socketFactory);

    rebindRMIService(service);

    return registry;
  }

  /**
   * Called by {@link #createRegistry()} right before creating/setting the registry.
   *
   * @throws ServerException on error.
   */
  protected abstract void initRegistryPrerequisites() throws ServerException;

  /**
   * Checks if this {@link AFNaming} instance can be shut down remotely.
   *
   * @return {@code true} if remote shutdown is allowed.
   */
  public boolean isRemoteShutdownAllowed() {
    return remoteShutdownAllowed.get();
  }

  /**
   * Controls whether this {@link AFNaming} instance can be shut down remotely.
   *
   * @param remoteShutdownAllowed {@code true} if remote shutdown is allowed.
   */
  public void setRemoteShutdownAllowed(boolean remoteShutdownAllowed) {
    this.remoteShutdownAllowed.set(remoteShutdownAllowed);
  }

  /**
   * Exports and binds the given Remote object to the given name, using the given {@link AFNaming}
   * setup.
   *
   * @param name The name to use to bind the object in the registry.
   * @param obj The object to export and bind.
   * @throws RemoteException if the operation fails.
   * @throws AlreadyBoundException if there already was something bound at that name
   */
  public void exportAndBind(String name, Remote obj) throws RemoteException, AlreadyBoundException {
    exportObject(obj, getSocketFactory());

    getRegistry().bind(name, obj);
  }

  /**
   * Exports and re-binds the given Remote object to the given name, using the given
   * {@link AFNaming} setup.
   *
   * @param name The name to use to bind the object in the registry.
   * @param obj The object to export and bind.
   * @throws RemoteException if the operation fails.
   */
  public void exportAndRebind(String name, Remote obj) throws RemoteException {
    exportObject(obj, getSocketFactory());

    getRegistry().rebind(name, obj);
  }

  /**
   * Forcibly un-exports the given object, if it exists, and unbinds the object from the registry
   * (otherwise returns without an error).
   *
   * @param name The name used to bind the object.
   * @param obj The object to un-export.
   * @throws RemoteException if the operation fails.
   */
  public void unexportAndUnbind(String name, Remote obj) throws RemoteException {
    unexportObject(obj);
    try {
      unbind(name);
    } catch (MalformedURLException | NotBoundException e) {
      // ignore
    }
  }

  /**
   * Exports the given Remote object, using the given socket factory and a randomly assigned port.
   *
   * NOTE: This helper function can also be used for regular RMI servers.
   *
   * @param obj The object to export.
   * @param socketFactory The socket factory to use.
   * @return The remote stub.
   * @throws RemoteException if the operation fails.
   */
  public static Remote exportObject(Remote obj, RMISocketFactory socketFactory)
      throws RemoteException {
    return UnicastRemoteObject.exportObject(obj, 0, socketFactory, socketFactory);
  }

  /**
   * Forcibly un-exports the given object, if it exists (otherwise returns without an error). This
   * should be called upon closing a {@link Closeable} {@link Remote} object.
   *
   * NOTE: This helper function can also be used for regular RMI servers.
   *
   * @param obj The object to un-export.
   */
  public static void unexportObject(Remote obj) {
    try {
      UnicastRemoteObject.unexportObject(obj, true);
    } catch (NoSuchObjectException e) {
      // ignore
    }
  }

  private synchronized void setRegistry(AFRegistry registry) {
    this.registry = registry;
    if (registry == null) {
      rmiService = null;
    } else if (registry.isLocal()) {
      closeUponRuntimeShutdown();
    }
  }
}
