/*
 * 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.camel.quarkus.test.wiremock;

import java.io.IOException;
import java.lang.reflect.Field;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.util.HashMap;
import java.util.List;
import java.util.Map;

import com.github.tomakehurst.wiremock.WireMockServer;
import com.github.tomakehurst.wiremock.common.ClasspathFileSource;
import com.github.tomakehurst.wiremock.common.FileSource;
import com.github.tomakehurst.wiremock.core.WireMockConfiguration;
import com.github.tomakehurst.wiremock.recording.RecordingStatus;
import com.github.tomakehurst.wiremock.recording.SnapshotRecordResult;
import com.github.tomakehurst.wiremock.stubbing.StubMapping;
import io.quarkus.test.common.QuarkusTestResourceLifecycleManager;
import org.apache.camel.quarkus.test.AvailablePortFinder;
import org.apache.camel.quarkus.test.mock.backend.MockBackendUtils;
import org.eclipse.microprofile.config.ConfigProvider;
import org.jboss.logging.Logger;

import static com.github.tomakehurst.wiremock.client.WireMock.recordSpec;
import static com.github.tomakehurst.wiremock.core.WireMockConfiguration.options;

public abstract class WireMockTestResourceLifecycleManager implements QuarkusTestResourceLifecycleManager {

    protected static final Logger LOG = Logger.getLogger(WireMockTestResourceLifecycleManager.class);
    protected WireMockServer server;

    /**
     * Starts the {@link WireMockServer} and configures request / response recording if it has been enabled
     */
    @Override
    public Map<String, String> start() {
        Map<String, String> properties = new HashMap<>();

        if (isMockingEnabled() || isRecordingEnabled()) {
            server = createServer();
            server.start();

            if (isRecordingEnabled()) {
                String recordTargetBaseUrl = getRecordTargetBaseUrl();
                //url can not end with slash, that would cause recording to fail
                recordTargetBaseUrl = (recordTargetBaseUrl != null && recordTargetBaseUrl.endsWith("/"))
                        ? recordTargetBaseUrl.substring(0, recordTargetBaseUrl.length() - 1) : recordTargetBaseUrl;

                if (recordTargetBaseUrl != null) {
                    LOG.infof("Enabling WireMock recording for %s", recordTargetBaseUrl);
                    server.startRecording(recordSpec()
                            .forTarget(recordTargetBaseUrl)
                            .allowNonProxied(false));
                } else {
                    throw new IllegalStateException(
                            "Must return a non-null value from getRecordTargetBaseUrl() in order to support WireMock recording");
                }
            }

            String wireMockUrl = "http://localhost:" + server.port();
            LOG.infof("WireMock started on %s", wireMockUrl);
            properties.put("wiremock.url", wireMockUrl);

            // if https enabled
            if (server.getOptions().httpsSettings().enabled()) {
                properties.put("wiremock.url.ssl", "https://localhost:" + server.httpsPort());
            }
        }

        return properties;
    }

    /**
     * Stops the {@link WireMockServer} instance if it was started and stops recording if record mode was enabled
     */
    @Override
    public void stop() {
        if (server != null) {
            LOG.info("Stopping WireMockServer");
            if (server.getRecordingStatus().getStatus().equals(RecordingStatus.Recording)) {
                LOG.info("Stopping recording");
                SnapshotRecordResult recordResult = server.stopRecording();

                List<StubMapping> stubMappings = recordResult.getStubMappings();
                processRecordedStubMappings(stubMappings);

                // Move stubs to subdirectory if configured (after custom processing)
                moveStubsToSubdirectory();

                if (isDeleteRecordedMappingsOnError()) {
                    String subdir = getStubsSubdirectory();
                    String mappingsDir = !subdir.isEmpty()
                            ? "./src/test/resources/mappings/" + subdir + "/"
                            : "./src/test/resources/mappings/";

                    for (StubMapping mapping : stubMappings) {
                        int status = mapping.getResponse().getStatus();
                        if (status >= 300 && mapping.shouldBePersisted()) {
                            try {
                                String fileName = mapping.getName() + "-" + mapping.getId() + ".json";
                                Path mappingFilePath = Paths.get(mappingsDir, fileName);
                                Files.deleteIfExists(mappingFilePath);
                                LOG.infof("Deleted mapping file %s as status code was %d", fileName, status);
                            } catch (IOException e) {
                                LOG.errorf("Failed to delete mapping file %s", e, mapping.getName());
                            }
                        }
                    }
                }
            }
            server.stop();
            AvailablePortFinder.releaseReservedPorts();
        }
    }

    /**
     * If mocking is enabled, inject an instance of {@link WireMockServer} into any fields
     * annotated with {@link MockServer}. This gives full control over creating recording rules
     * and some aspects of the server lifecycle.
     *
     * The server instance is not injected if mocking is explicitly disabled, and therefore the resulting
     * {@link MockServer} annotated field value will be null.
     */
    @Override
    public void inject(Object testInstance) {
        if (isMockingEnabled() || isRecordingEnabled()) {
            Class<?> testClass = testInstance.getClass();
            while (testClass != Object.class) {
                for (Field field : testClass.getDeclaredFields()) {
                    if (field.getAnnotation(MockServer.class) != null) {
                        if (!WireMockServer.class.isAssignableFrom(field.getType())) {
                            throw new RuntimeException("@MockServer can only be used on fields of type WireMockServer");
                        }

                        field.setAccessible(true);
                        try {
                            if (server == null) {
                                LOG.info("Starting WireMockServer");
                                server = createServer();
                                server.start();
                            }
                            LOG.infof("Injecting WireMockServer for field %s", field.getName());
                            field.set(testInstance, server);
                            return;
                        } catch (Exception e) {
                            throw new RuntimeException(e);
                        }
                    }
                }
                testClass = testClass.getSuperclass();
            }
        }
    }

    /**
     * Determines whether each of the given environment variable names is set
     */
    protected boolean envVarsPresent(String... envVarNames) {
        if (envVarNames.length == 0) {
            throw new IllegalArgumentException("envVarNames must not be empty");
        }

        boolean present = true;
        for (String envVar : envVarNames) {
            if (System.getenv(envVar) == null) {
                present = false;
                break;
            }
        }

        return present;
    }

    /**
     * Get the value of a given environment variable or a default value if it does not exist
     */
    protected String envOrDefault(String envVarName, String defaultValue) {
        return ConfigProvider.getConfig().getOptionalValue(envVarName, String.class).orElse(defaultValue);
    }

    /**
     * Whether recorded stub mapping files should be deleted if the HTTP response was an error code (>= 400).
     *
     * By default this returns true. Can be overridden if an error response is desired / expected from the HTTP request.
     */
    protected boolean isDeleteRecordedMappingsOnError() {
        return true;
    }

    /**
     * The target base URL that WireMock should watch for when recording requests.
     *
     * For example, if a test triggers an HTTP call on an external endpoint like https://api.foo.com/some/resource.
     * Then the base URL would be https://api.foo.com
     */
    protected abstract String getRecordTargetBaseUrl();

    /**
     * Conditions under which the {@link WireMockServer} should be started.
     */
    protected abstract boolean isMockingEnabled();

    /**
     * Customizes the {@link WireMockConfiguration} that will be used to create the next {@Link WireMockServer}.
     */
    protected void customizeWiremockConfiguration(WireMockConfiguration config) {
    }

    /**
     * Returns the subdirectory name for organizing stubs by test module.
     * If empty (default), stubs are stored in the root mappings/ directory.
     * If non-empty, stubs are stored in mappings/{subdirectory}/ and __files/{subdirectory}/.
     *
     * This allows multiple test modules to share a consolidated integration test while keeping
     * their stubs organized and avoiding naming conflicts.
     *
     * @return subdirectory name (e.g., "chat", "agent", "embeddings") or empty string for root
     */
    protected String getStubsSubdirectory() {
        return "";
    }

    /**
     * Hook to get a handle on any record stub mappings. Useful for performing stub post-processing or cleanup tasks.
     * Subclasses can override this to process stubs after recording, including:
     * - Deduplication of stubs with identical request patterns
     * - Updating bodyFileName references to include subdirectory prefix
     * - Removing unwanted stub recordings
     */
    protected void processRecordedStubMappings(List<StubMapping> stubMappings) {
    }

    /**
     * Creates and starts a {@link WireMockServer} on a random port. {@link MockBackendUtils} triggers the log
     * message that signifies mocking is in use.
     */
    private WireMockServer createServer() {
        LOG.info("Starting WireMockServer");

        MockBackendUtils.startMockBackend(true);
        WireMockConfiguration configuration = options().port(AvailablePortFinder.getNextAvailable());
        customizeWiremockConfiguration(configuration);

        if (!isRecordingEnabled()) {
            // Read mapping resources from the classpath in playback mode
            String subdir = getStubsSubdirectory();
            configuration.fileSource(new CamelQuarkusFileSource(subdir));
        }
        return new WireMockServer(configuration);
    }

    /**
     * Moves recorded stub files from the root mappings/ directory to a subdirectory if configured.
     */
    private void moveStubsToSubdirectory() {
        String subdir = getStubsSubdirectory();
        if (subdir.isEmpty()) {
            return; // No subdirectory configured, keep files in root
        }

        try {
            Path mappingsRoot = Paths.get("./src/test/resources/mappings");
            Path filesRoot = Paths.get("./src/test/resources/__files");
            Path mappingsSubdir = mappingsRoot.resolve(subdir);
            Path filesSubdir = filesRoot.resolve(subdir);

            // Create subdirectories
            Files.createDirectories(mappingsSubdir);
            Files.createDirectories(filesSubdir);

            // Move mapping files from root to subdirectory
            try (var stream = Files.list(mappingsRoot)) {
                stream.filter(Files::isRegularFile)
                        .filter(p -> p.toString().endsWith(".json"))
                        .forEach(mappingFile -> {
                            try {
                                Path targetMapping = mappingsSubdir.resolve(mappingFile.getFileName());
                                Files.move(mappingFile, targetMapping);
                                LOG.debugf("Moved stub %s to subdirectory %s", mappingFile.getFileName(), subdir);
                            } catch (IOException e) {
                                LOG.errorf("Failed to move stub %s to subdirectory %s", e, mappingFile, subdir);
                            }
                        });
            }

            // Move body files from root to subdirectory
            if (Files.exists(filesRoot)) {
                try (var stream = Files.list(filesRoot)) {
                    stream.filter(Files::isRegularFile)
                            .forEach(bodyFile -> {
                                try {
                                    Path targetBodyFile = filesSubdir.resolve(bodyFile.getFileName());
                                    Files.move(bodyFile, targetBodyFile);
                                } catch (IOException e) {
                                    LOG.debugf("Failed to move body file %s", bodyFile.getFileName());
                                }
                            });
                }
            }

            LOG.infof("Moved recorded stubs to subdirectory: %s", subdir);
        } catch (IOException e) {
            LOG.errorf("Failed to organize stubs into subdirectory %s", e, subdir);
        }
    }

    /**
     * Determine whether to enable WireMock record mode:
     *
     * http://wiremock.org/docs/record-playback/
     */
    private boolean isRecordingEnabled() {
        String recordEnabled = System.getProperty("wiremock.record", System.getenv("WIREMOCK_RECORD"));
        return recordEnabled != null && recordEnabled.equals("true");
    }

    /**
     * A custom ClasspathFileSource so that WireMock mapping files can be resolved in the quarkus-platform build.
     * If a subdirectory is configured (e.g., "chat"), WireMock loads stubs from mappings/{subdir}/ and __files/{subdir}/.
     */
    private static class CamelQuarkusFileSource extends ClasspathFileSource {
        private final String stubsSubdirectory;

        private CamelQuarkusFileSource(String stubsSubdirectory) {
            super("");
            this.stubsSubdirectory = stubsSubdirectory;
        }

        @Override
        public FileSource child(String subDirectoryName) {
            // If a subdirectory is configured, redirect "mappings" and "__files" to the subdirectory
            if (!stubsSubdirectory.isEmpty()) {
                if ("mappings".equals(subDirectoryName)) {
                    return new ClasspathFileSource("mappings/" + stubsSubdirectory);
                } else if ("__files".equals(subDirectoryName)) {
                    return new ClasspathFileSource("__files/" + stubsSubdirectory);
                }
            }
            // Default behavior: return child with the same name
            return new ClasspathFileSource(subDirectoryName);
        }
    }
}
