/*
 * RiskScape™ Copyright New Zealand Institute for Earth Science Limited
 * (Earth Sciences New Zealand) is distributed for research purposes only
 * under the terms of AGPLv3.
 *
 * RiskScape™ Copyright 2026 New Zealand Institute for Earth Science
 * Limited (Earth Sciences New Zealand). All rights reserved. Source code
 * available under the AGPLv3.
 * 
 * This program is free software: you can redistribute it and/or modify it under
 *  the terms of the GNU Affero General Public License as published by the Free
 *  Software Foundation, either version 3 of the License, or (at your option) any
 *  later version.
 * 
 * This program is distributed for RESEARCH PURPOSES ONLY, in the hope that it will
 * be useful for research and education initiatives.
 * 
 * If you are not a researcher, or you are a researcher who wishes to use this
 * program on terms other than AGPLv3 (including those who wish to restrict the
 * distribution of any source code created using this program), please contact:
 * https://riskscape.org.nz
 * 
 * This program is distributed WITHOUT ANY WARRANTY; without even the implied
 * warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
 * Affero General Public License for more details.  You should have received a copy
 * of the GNU Affero General Public License along with this program.  If not, see
 * <http://www.gnu.org/licenses/>.
 * 
 * By way of summary only, under the AGPLv3:
 *     • Permissions of this strongest copyleft license are conditioned
 *       on making available complete source code of licensed works and
 *       modifications, which include larger works using a licensed work,
 *       under the same license.
 *     • Copyright and license notices must be preserved.
 *     • Contributors provide an express grant of patent rights.
 *     • When a modified version is used to provide a service over a
 *       network, the complete source code of the modified version must be made
 *       available.
 */
/*
 * 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 nz.org.riskscape.engine.plugin;

import java.io.File;
import java.io.IOException;
import java.net.MalformedURLException;
import java.net.URL;
import java.nio.file.Path;
import java.util.Arrays;
import java.util.List;
import java.util.Optional;
import java.util.Set;
import java.util.function.Consumer;
import java.util.jar.Manifest;
import java.util.stream.Collectors;

import com.google.common.collect.ImmutableList;
import com.google.common.collect.Lists;

import lombok.extern.slf4j.Slf4j;
import nz.org.riskscape.engine.Engine;
import nz.org.riskscape.engine.RiskscapeException;

/**
 * The {@link PluginRepository} is a registry of plugins.
 *
 * {@link Engine} building code can use a {@link PluginRepository} to assemble a set of plugins to use
 * to populate an engine.  A {@link PluginRepository} can in theory be used to assemble multiple engine instances.
 * While this isn't very interesting for a CLI, where engine construction is per invocation, this might come in use
 * with a webapp or API.
 *
 * Each plugin is given its own classloader, {@link PluginClassLoader},  which isolates it from other plugins and from
 * the Engine itself.
 *
 * See comments on {@link PluginRepository#addPath(File)} for notes on Plugin structure.
 *
 * Derived from the Nutch project's plugin system
 *
 */
@Slf4j
public class PluginRepository {

  public static final PluginRepository INSTANCE = new PluginRepository();

  /**
   * Collect all the {@link ExtensionPoint}s and {@link PluginFeature}s from the given list of plugins
   * and add them to the given {@link ExtensionPoints}.
   */
  public static void collectFeatures(ExtensionPoints extensionPoints, List<Plugin> plugins) {
    PluginRepository dummy = new PluginRepository();
    plugins.forEach(dummy::addActivated);

    dummy.collectFeatures(extensionPoints);
  }

  /**
   * Collect all the {@link ExtensionPoint}s and {@link PluginFeature}s from the given plugins
   * and add them to the given {@link ExtensionPoints}.
   */
  public static void collectFeatures(ExtensionPoints extensionPoints, Plugin... plugins) {
    collectFeatures(extensionPoints, Arrays.asList(plugins));
  }

  // package scoped for test futzing
  final List<PluginDescriptor> registeredPlugins = Lists.newArrayList();
  final List<Plugin> activatedPlugins = Lists.newArrayList();

  PluginRepository() {
  }

  public void scanDirectory(Path directory) {
    log.info("Plugins: looking in: " + directory.toAbsolutePath().toString());
    for (File inPluginDir : directory.toFile().listFiles()) {
      if (inPluginDir.getAbsolutePath().endsWith(".jar") || inPluginDir.isDirectory()) {
        addPath(inPluginDir);
      }
    }
  }

  /**
   * Register an already 'activated' plugin with this repository.  This allows a plugin to be used that has been
   * constructed outside of the usual code-loading mechanism
   */
  public void addActivated(Plugin plugin) {
    activatedPlugins.add(plugin);
  }

  /**
   * Register a plugin, but without activating it.  It will get activated in due course by the activateAll method
   * instead.
   */
  public void register(PluginDescriptor descriptor) {
    this.registeredPlugins.add(descriptor);
  }

  /**
   * Constructs a {@link PluginDescriptor} from either a single jar file or a directory containing jars and adds it to
   * this {@link PluginRepository}.
   *
   * If plugin
   * points to a directory, that directory must contain at least a jar named `plugin.jar` which must contain a manifest
   * that the PluginDescriptor can read.  All other jars will be loaded from that directory, with plugin.jar always
   * being loaded first.
   *
   * If plugin points to a jar file, then this is loaded by itself like `plugin.jar` from the directory example
   * before
   *
   * @param plugin either a jar file or a directory
   */
  public void addPath(File plugin) {
    PluginDescriptor p;
    try {
      p = parseManifestFile(plugin);
    } catch (RuntimeException | IOException e) {
      log.warn("Could not register plugin from " + plugin, e);
      return;
    }

    Optional<PluginDescriptor> existing = registeredPlugins
        .stream().filter(pd -> pd.getPluginId().equals(p.getPluginId())).findFirst();

    if (existing.isPresent()) {
      log.warn(String.format("Not registering plugin from %s, a plugin is already registered with that id: %s",
          plugin, existing));
    }
    registeredPlugins.add(p);
  }

  public List<Plugin> activateAll(Consumer<Plugin> visitor) {
    // load each plugin
    for (PluginDescriptor descriptor : registeredPlugins) {
      activatePlugin(descriptor, visitor);
    }

    return getActivated();
  }

  /**
   * Collects all the {@link ExtensionPoint}s and {@link PluginFeature}s advertised by activated plugins and returns
   * them in a new {@link ExtensionPoints} object.
   */
  public ExtensionPoints collectFeatures(ExtensionPoints extensionPoints) {

    // find out about all extension points from each plugin
    for (Plugin plugin : activatedPlugins) {
      plugin.getExtensionPoints().forEach(clz -> extensionPoints.addExtensionPoint(clz));
    }

    // now link up all the features to the extension points
    for (Plugin plugin : activatedPlugins) {
      for (PluginFeature feature : plugin.getFeatures()) {
        boolean used = extensionPoints.addFeature(feature);

        if (!used) {
          // NB this is possibly worthy of more than just a log - it might want to go out as an error to the problem
          // sink?  Or maybe it should fail engine bootstrapping?
          log.error("Feature {} from plugin {} was not collected by any registered extension points", feature, plugin);
        }
      }
    }

    // dump out info about feature discovery
    log.info("Plugin features initialized");
    if (log.isDebugEnabled()) {
      log.info("Feature registration summary:");
      for (ExtensionPoint<?> extensionPoint : extensionPoints.getAll()) {
        log.debug("  ExtensionPoint: {}", extensionPoint.getId());
        for (Object feature : extensionPoint.getFeatures()) {
          log.debug("   - {}", feature);
        }
      }
    }

    return extensionPoints;
  }

  protected Plugin activatePlugin(PluginDescriptor descriptor, Consumer<Plugin> visitor) {
    log.info("Attempting to activate plugin {}", descriptor);

    // only activate each plugin once
    Optional<Plugin> alreadyActivated = getActivatedPlugin(descriptor.getPluginId());
    if (alreadyActivated.isPresent()) {
      if (alreadyActivated.get().getDescriptor().equals(descriptor)) {
        log.info("Not activating {} - already activated", descriptor);
        return alreadyActivated.get();
      } else {
        throw new RiskscapeException(String.format(
            "Plugin '%s' has already been activated from a different source : %s",
            descriptor, alreadyActivated.get().getDescriptor()));
      }
    }

    if (descriptor.hasPluginDependencies()) {
      Set<String> dependencies = descriptor.getPluginDependencies();
      for (String dependencyId : dependencies) {
        PluginDescriptor dependencyDescriptor = getPluginById(dependencyId)
            .orElseThrow(() -> new PluginRuntimeException(descriptor, null,
                "Plugin %s depends on plugin %s, but it has not been registered",
                descriptor.getPluginId(),
                dependencyId
            ));

        log.info("Activating plugin dependency {}", dependencyDescriptor);
        // this might not be necessary, but it makes sense to me that a dependency is
        // activated before the dependent
        activatePlugin(dependencyDescriptor, visitor);

        // link them up - this allows the plugin to use another plugin's classloader for class loading
        descriptor.addDependency(dependencyDescriptor);
      }
    }

    Plugin constructed = descriptor.newPluginInstance();

    visitor.accept(constructed);
    activatedPlugins.add(constructed);

    return constructed;
  }

  /**
   * @return a {@link PluginDescriptor} from the list of registered plugins with the given dependencyId, or empty if
   * there is no plugin registered with that id
   */
  public Optional<PluginDescriptor> getPluginById(String dependencyId) {
    return this.registeredPlugins.stream().filter(pd -> pd.getPluginId().equals(dependencyId)).findAny();
  }

  private PluginDescriptor parseManifestFile(File pluginFile) throws MalformedURLException, IOException {

    URL jarUrl;
    URL sourceUrl;

    URL[] dependencies;
    if (pluginFile.isDirectory()) {
      Path pluginPath = pluginFile.toPath();
      sourceUrl = pluginPath.toUri().toURL();
      List<File> jars = Arrays.asList(pluginFile.listFiles())
          .stream()
          .filter(f -> !f.getName().equals("plugin.jar") && f.getName().endsWith(".jar"))
          .collect(Collectors.toList());


      Path jarPath = pluginPath.resolve("plugin.jar");

      if (!jarPath.toFile().canRead()) {
        throw new PluginRuntimeException("Plugin directory '%s' does not contain a plugin.jar file ", pluginFile);
      }

      jarUrl = jarPath.toUri().toURL();

      dependencies = jars.stream().map(this::fileToUrl).toArray(URL[]::new);

    } else {
      jarUrl = pluginFile.toURI().toURL();
      sourceUrl = jarUrl;
      dependencies = new URL[0];
    }
    PluginClassLoader classLoader = new PluginClassLoader(Engine.class.getClassLoader(), jarUrl, dependencies);

    URL manifestUrl = classLoader.findResource("META-INF/MANIFEST.MF");

    if (manifestUrl == null) {
      classLoader.close();
      throw new PluginRuntimeException("Plugin from %s does not have a manifest", jarUrl);
    }

    Manifest manifest = new Manifest(manifestUrl.openStream());

    return new DefaultPluginDescriptor(sourceUrl, classLoader, manifest);
  }

  private URL fileToUrl(File file) {
    try {
      return file.toURI().toURL();
    } catch (MalformedURLException e) {
      throw new IllegalArgumentException(e);
    }
  }
  /*
   * (non-Javadoc)
   *
   * @see java.lang.Object#finalize()
   */
  @Override
  public void finalize() throws Throwable {
    shutDownActivatedPlugins();
  }

  /**
   * Shuts down all plugins
   *
   * @throws PluginRuntimeException
   */
  private void shutDownActivatedPlugins() throws PluginRuntimeException {
    for (Plugin plugin : activatedPlugins) {
      plugin.shutDown();
    }
  }

  public Optional<Plugin> getActivatedPlugin(String pluginId) {
    return this.activatedPlugins.stream()
        .filter(pd -> pd.getId().equals(pluginId))
        .findAny();
  }

  public <T extends Plugin> Optional<T> getActivatedPlugin(Class<T> pluginClass) {
    return activatedPlugins.stream()
        .filter(p -> p.getClass().equals(pluginClass))
        .map(p -> pluginClass.cast(p))
        .findFirst();
  }

  /**
   * @return a set of all the activated plugins in this repository
   */
  public List<Plugin> getActivated() {
    return ImmutableList.copyOf(activatedPlugins);
  }
}
