/*
 * Copyright The WildFly Authors
 * SPDX-License-Identifier: Apache-2.0
 */

package org.jboss.as.test.integration.logging.formatters;

import java.io.IOException;
import java.io.StringReader;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.time.ZoneId;
import java.time.format.DateTimeFormatter;
import java.util.ArrayList;
import java.util.Collection;
import java.util.Collections;
import java.util.HashMap;
import java.util.HashSet;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Set;
import jakarta.json.Json;
import jakarta.json.JsonObject;
import jakarta.json.JsonReader;
import jakarta.json.JsonValue;

import org.apache.http.HttpStatus;
import org.jboss.as.controller.client.helpers.Operations;
import org.jboss.as.controller.client.helpers.Operations.CompositeOperationBuilder;
import org.jboss.as.test.integration.logging.AbstractLoggingTestCase;
import org.jboss.as.test.integration.logging.LoggingServiceActivator;
import org.jboss.dmr.ModelNode;
import org.jboss.logging.Logger;
import org.junit.After;
import org.junit.AfterClass;
import org.junit.Assert;
import org.junit.Before;
import org.junit.BeforeClass;
import org.junit.Test;
import org.junit.runner.RunWith;
import org.wildfly.core.testrunner.WildFlyRunner;

/**
 * Tests output from the JSON formatter to ensure that integration between the subsystem and the log manager is correct.
 *
 * @author <a href="mailto:jperkins@redhat.com">James R. Perkins</a>
 */
@RunWith(WildFlyRunner.class)
public class JsonFormatterTestCase extends AbstractLoggingTestCase {

    private static final String FILE_NAME = "json-file-handler.log";
    private static final String JSON_HANDLER_NAME = "jsonFileHandler";
    private static final String JSON_FORMATTER_NAME = "json";
    private static final ModelNode HANDLER_ADDRESS = createAddress("file-handler", JSON_HANDLER_NAME);
    private static final ModelNode FORMATTER_ADDRESS = createAddress("json-formatter", JSON_FORMATTER_NAME);
    private static final ModelNode LOGGER_ADDRESS = createAddress("logger", LoggingServiceActivator.LOGGER.getName());

    private Path logFile = null;

    @BeforeClass
    public static void deploy() throws Exception {
        deploy(createDeployment(), DEPLOYMENT_NAME);
    }

    @AfterClass
    public static void undeploy() throws Exception {
        undeploy(DEPLOYMENT_NAME);
    }

    @Before
    public void setLogFile() {
        if (logFile == null) {
            logFile = getAbsoluteLogFilePath(FILE_NAME);
        }
    }

    @After
    public void remove() throws Exception {

        final CompositeOperationBuilder builder = CompositeOperationBuilder.create();

        // Remove the logger
        builder.addStep(Operations.createRemoveOperation(LOGGER_ADDRESS));

        // Remove the formatter
        builder.addStep(Operations.createRemoveOperation(FORMATTER_ADDRESS));

        // Remove the custom handler
        builder.addStep(Operations.createRemoveOperation(HANDLER_ADDRESS));

        executeOperation(builder.build());

        if (logFile != null) Files.deleteIfExists(logFile);
    }

    @Test
    public void testKeyOverrides() throws Exception {
        final Map<String, String> keyOverrides = new HashMap<>();
        keyOverrides.put("timestamp", "dateTime");
        keyOverrides.put("sequence", "seq");
        final Map<String, String> metaData = new LinkedHashMap<>();
        metaData.put("test-key-1", "test-value-1");
        metaData.put("key-no-value", null);
        // Configure the subsystem
        configure(keyOverrides, metaData, true);

        final String msg = "Logging test: JsonFormatterTestCase.defaultLoggingTest";
        final Map<String, String> params = new LinkedHashMap<>();
        // Indicate we need an exception logged
        params.put(LoggingServiceActivator.LOG_EXCEPTION_KEY, "true");
        // Add an NDC value
        params.put(LoggingServiceActivator.NDC_KEY, "test.ndc.value");
        // Add some map entries for MDC values
        params.put("mdcKey1", "mdcValue1");
        params.put("mdcKey2", "mdcValue2");

        final List<String> expectedKeys = createDefaultKeys();
        expectedKeys.remove("timestamp");
        expectedKeys.remove("sequence");
        expectedKeys.addAll(keyOverrides.values());
        expectedKeys.addAll(metaData.keySet());
        expectedKeys.add("exception");
        expectedKeys.add("stackTrace");
        expectedKeys.add("sourceFileName");
        expectedKeys.add("sourceMethodName");
        expectedKeys.add("sourceClassName");
        expectedKeys.add("sourceLineNumber");
        expectedKeys.add("sourceModuleVersion");
        expectedKeys.add("sourceModuleName");

        final int statusCode = getResponse(msg, params);
        Assert.assertTrue("Invalid response statusCode: " + statusCode, statusCode == HttpStatus.SC_OK);

        // Validate each line
        for (String s : Files.readAllLines(logFile, StandardCharsets.UTF_8)) {
            if (s.trim().isEmpty()) continue;
            try (JsonReader reader = Json.createReader(new StringReader(s))) {
                final JsonObject json = reader.readObject();
                validateDefault(json, expectedKeys, msg);

                // Timestamp should have been renamed to dateTime
                Assert.assertNull("Found timestamp entry in " + s, json.get("timestamp"));

                // Sequence should have been renamed to seq
                Assert.assertNull("Found sequence entry in " + s, json.get("sequence"));

                // Validate MDC
                final JsonObject mdcObject = json.getJsonObject("mdc");
                Assert.assertEquals("mdcValue1", mdcObject.getString("mdcKey1"));
                Assert.assertEquals("mdcValue2", mdcObject.getString("mdcKey2"));

                // Validate the meta-data
                Assert.assertEquals("test-value-1", json.getString("test-key-1"));
                Assert.assertEquals("Expected a null type but got " + json.get("key-no-value"),
                        JsonValue.ValueType.NULL, json.get("key-no-value").getValueType());

                validateStackTrace(json, true, true);
            }
        }
    }

    @Test
    public void testNoExceptions() throws Exception {
        configure(Collections.emptyMap(), Collections.emptyMap(), false);
        final String msg = "Logging test: JsonFormatterTestCase.testNoExceptions";
        int statusCode = getResponse(msg, Collections.singletonMap(LoggingServiceActivator.LOG_EXCEPTION_KEY, "false"));
        Assert.assertTrue("Invalid response statusCode: " + statusCode, statusCode == HttpStatus.SC_OK);

        final List<String> expectedKeys = createDefaultKeys();

        for (String s : Files.readAllLines(logFile, StandardCharsets.UTF_8)) {
            if (s.trim().isEmpty()) continue;
            try (JsonReader reader = Json.createReader(new StringReader(s))) {
                final JsonObject json = reader.readObject();

                validateDefault(json, expectedKeys, msg);
                validateStackTrace(json, false, false);
            }
        }
    }

    @Test
    public void testFormattedException() throws Exception {
        configure(Collections.emptyMap(), Collections.emptyMap(), false);

        // Change the exception-output-type
        executeOperation(Operations.createWriteAttributeOperation(FORMATTER_ADDRESS, "exception-output-type", "formatted"));

        final String msg = "Logging test: JsonFormatterTestCase.testNoExceptions";
        int statusCode = getResponse(msg, Collections.singletonMap(LoggingServiceActivator.LOG_EXCEPTION_KEY, "true"));
        Assert.assertTrue("Invalid response statusCode: " + statusCode, statusCode == HttpStatus.SC_OK);

        final List<String> expectedKeys = createDefaultKeys();
        expectedKeys.add("stackTrace");

        for (String s : Files.readAllLines(logFile, StandardCharsets.UTF_8)) {
            if (s.trim().isEmpty()) continue;
            try (JsonReader reader = Json.createReader(new StringReader(s))) {
                final JsonObject json = reader.readObject();

                validateDefault(json, expectedKeys, msg);
                validateStackTrace(json, true, false);
            }
        }
    }

    @Test
    public void testStructuredException() throws Exception {
        configure(Collections.emptyMap(), Collections.emptyMap(), false);

        // Change the exception-output-type
        executeOperation(Operations.createWriteAttributeOperation(FORMATTER_ADDRESS, "exception-output-type", "detailed"));

        final String msg = "Logging test: JsonFormatterTestCase.testNoExceptions";
        int statusCode = getResponse(msg, Collections.singletonMap(LoggingServiceActivator.LOG_EXCEPTION_KEY, "true"));
        Assert.assertTrue("Invalid response statusCode: " + statusCode, statusCode == HttpStatus.SC_OK);

        final List<String> expectedKeys = createDefaultKeys();
        expectedKeys.add("exception");

        for (String s : Files.readAllLines(logFile, StandardCharsets.UTF_8)) {
            if (s.trim().isEmpty()) continue;
            try (JsonReader reader = Json.createReader(new StringReader(s))) {
                final JsonObject json = reader.readObject();

                validateDefault(json, expectedKeys, msg);
                validateStackTrace(json, false, true);
            }
        }
    }

    @Test
    public void testDateFormat() throws Exception {
        configure(Collections.emptyMap(), Collections.emptyMap(), false);

        final String dateFormat = "yyyy-MM-dd'T'HH:mm:ssSSSZ";
        final String timezone = "GMT";

        // Change the date format and time zone
        final CompositeOperationBuilder builder = CompositeOperationBuilder.create();
        builder.addStep(Operations.createWriteAttributeOperation(FORMATTER_ADDRESS, "date-format", dateFormat));
        builder.addStep(Operations.createWriteAttributeOperation(FORMATTER_ADDRESS, "zone-id", timezone));
        executeOperation(builder.build());

        final String msg = "Logging test: JsonFormatterTestCase.testNoExceptions";
        int statusCode = getResponse(msg, Collections.singletonMap(LoggingServiceActivator.LOG_EXCEPTION_KEY, "false"));
        Assert.assertTrue("Invalid response statusCode: " + statusCode, statusCode == HttpStatus.SC_OK);

        final List<String> expectedKeys = createDefaultKeys();

        for (String s : Files.readAllLines(logFile, StandardCharsets.UTF_8)) {
            if (s.trim().isEmpty()) continue;
            try (JsonReader reader = Json.createReader(new StringReader(s))) {
                final JsonObject json = reader.readObject();

                validateDefault(json, expectedKeys, msg);
                validateStackTrace(json, false, false);

                // Validate the date format is correct. We don't want to validate the specific date, only that it's
                // parsable.
                final String jsonDate = json.getString("timestamp");
                // If the date is not parsable an exception should be thrown
                try {
                    DateTimeFormatter.ofPattern(dateFormat, Locale.ROOT).withZone(ZoneId.of(timezone)).parse(jsonDate);
                } catch (Exception e) {
                    Assert.fail(String.format("Failed to parse %s with pattern %s and zone %s: %s", jsonDate, dateFormat,
                            timezone, e.getMessage()));
                }
            }
        }
    }

    private static void validateDefault(final JsonObject json, final Collection<String> expectedKeys, final String expectedMessage) {
        final Set<String> remainingKeys = new HashSet<>(json.keySet());

        // Check all the expected keys
        for (String key : expectedKeys) {
            checkNonNull(json, key);
            Assert.assertTrue("Missing key " + key + " from JSON object: " + json, remainingKeys.remove(key));
        }

        // Should have no more remaining keys
        Assert.assertTrue("There are remaining keys that were not validated: " + remainingKeys, remainingKeys.isEmpty());

        Assert.assertEquals("org.jboss.logging.Logger", json.getString("loggerClassName"));
        Assert.assertEquals(LoggingServiceActivator.LOGGER.getName(), json.getString("loggerName"));
        Assert.assertTrue("Invalid level found in " + json.get("level"), isValidLevel(json.getString("level")));
        Assert.assertEquals(expectedMessage, json.getString("message"));
    }


    private static void validateStackTrace(final JsonObject json, final boolean validateFormatted, final boolean validateStructured) {
        if (validateFormatted) {
            Assert.assertNotNull(json.get("stackTrace"));
        } else {
            Assert.assertNull(json.get("stackTrace"));
        }
        if (validateStructured) {
            validateStackTrace(json.getJsonObject("exception"));
        } else {
            Assert.assertNull(json.get("exception"));
        }
    }

    private static void validateStackTrace(final JsonObject json) {
        checkNonNull(json, "refId");
        checkNonNull(json, "exceptionType");
        checkNonNull(json, "message");
        checkNonNull(json, "frames");
        if (json.get("causedBy") != null) {
            validateStackTrace(json.getJsonObject("causedBy").getJsonObject("exception"));
        }
    }

    private static boolean isValidLevel(final String level) {
        for (Logger.Level l : LoggingServiceActivator.LOG_LEVELS) {
            if (l.name().equals(level)) {
                return true;
            }
        }
        return false;
    }

    private static void checkNonNull(final JsonObject json, final String key) {
        Assert.assertNotNull(String.format("Missing %s in %s", key, json), json.get(key));
    }

    private static List<String> createDefaultKeys() {
        final List<String> expectedKeys = new ArrayList<>();
        expectedKeys.add("timestamp");
        expectedKeys.add("sequence");
        expectedKeys.add("loggerClassName");
        expectedKeys.add("loggerName");
        expectedKeys.add("level");
        expectedKeys.add("message");
        expectedKeys.add("threadName");
        expectedKeys.add("threadId");
        expectedKeys.add("mdc");
        expectedKeys.add("ndc");
        expectedKeys.add("hostName");
        expectedKeys.add("processName");
        expectedKeys.add("processId");
        return expectedKeys;
    }

    private void configure(final Map<String, String> keyOverrides, final Map<String, String> metaData,
                           final boolean printDetails) throws IOException {

        final CompositeOperationBuilder builder = CompositeOperationBuilder.create();

        // Create a JSON formatter
        ModelNode op = Operations.createAddOperation(FORMATTER_ADDRESS);

        final ModelNode keyOverridesNode = op.get("key-overrides").setEmptyObject();
        for (String key : keyOverrides.keySet()) {
            final String value = keyOverrides.get(key);
            if (value == null) {
                keyOverridesNode.get(key);
            } else {
                keyOverridesNode.get(key).set(value);
            }
        }

        final ModelNode metaDataNode = op.get("meta-data").setEmptyObject();
        for (String key : metaData.keySet()) {
            final String value = metaData.get(key);
            if (value == null) {
                metaDataNode.get(key);
            } else {
                metaDataNode.get(key).set(value);
            }
        }

        op.get("exception-output-type").set("detailed-and-formatted");
        op.get("print-details").set(printDetails);
        // This should always be false so that the each line will be a separate entry since we're writing to a file
        op.get("pretty-print").set(false);
        builder.addStep(op);

        // Create the handler
        op = Operations.createAddOperation(HANDLER_ADDRESS);
        final ModelNode fileNode = op.get("file").setEmptyObject();
        fileNode.get("relative-to").set("jboss.server.log.dir");
        fileNode.get("path").set(logFile.getFileName().toString());
        op.get("autoFlush").set(true);
        op.get("append").set(false);
        op.get("named-formatter").set(JSON_FORMATTER_NAME);
        builder.addStep(op);

        // Add the handler to the root-logger
        op = Operations.createAddOperation(LOGGER_ADDRESS);
        op.get("handlers").setEmptyList().add(JSON_HANDLER_NAME);
        builder.addStep(op);

        executeOperation(builder.build());
    }
}
