/*
 * 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.flink.agents.runtime.eventlog;

import com.fasterxml.jackson.annotation.JsonIgnore;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.apache.flink.agents.api.Event;
import org.apache.flink.agents.api.EventContext;
import org.apache.flink.agents.api.InputEvent;
import org.apache.flink.agents.api.OutputEvent;
import org.apache.flink.agents.api.configuration.AgentConfigOptions;
import org.apache.flink.agents.api.logger.EventLoggerConfig;
import org.apache.flink.agents.api.logger.EventLoggerOpenParams;
import org.apache.flink.agents.api.logger.LoggerType;
import org.apache.flink.api.common.JobID;
import org.apache.flink.api.common.JobInfo;
import org.apache.flink.api.common.TaskInfo;
import org.apache.flink.streaming.api.operators.StreamingRuntimeContext;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
import org.mockito.Mock;
import org.mockito.MockitoAnnotations;

import java.nio.file.Files;
import java.nio.file.Path;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.UUID;

import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.Mockito.when;

class FileEventLoggerTest {

    @TempDir Path tempDir;

    @Mock private StreamingRuntimeContext runtimeContext;

    @Mock private JobInfo jobInfo;

    @Mock private TaskInfo taskInfo;

    private FileEventLogger logger;
    private EventLoggerConfig config;
    private EventLoggerOpenParams openParams;
    private ObjectMapper objectMapper;

    private final JobID testJobId = JobID.generate();
    private final String testTaskName = "action-execute-operator";
    private final int testSubTaskId = 0;

    @BeforeEach
    void setUp() {
        MockitoAnnotations.openMocks(this);
        objectMapper = new ObjectMapper();

        // Configure mocks
        when(runtimeContext.getJobInfo()).thenReturn(jobInfo);
        when(runtimeContext.getTaskInfo()).thenReturn(taskInfo);
        when(jobInfo.getJobId()).thenReturn(testJobId);
        when(taskInfo.getTaskName()).thenReturn(testTaskName);
        when(taskInfo.getIndexOfThisSubtask()).thenReturn(testSubTaskId);

        // Create config and logger
        config = buildConfig(new HashMap<>());
        logger = new FileEventLogger(config);
        openParams = new EventLoggerOpenParams(runtimeContext);
    }

    /**
     * Builds an EventLoggerConfig for the file logger, seeding the agent-config map with {@code
     * baseLogDir} so tests can drop their per-test boilerplate and only specify the keys they
     * actually care about.
     */
    private EventLoggerConfig buildConfig(Map<String, Object> extraAgentConfig) {
        Map<String, Object> agentConfig = new HashMap<>(extraAgentConfig);
        agentConfig.putIfAbsent(AgentConfigOptions.BASE_LOG_DIR.getKey(), tempDir.toString());
        return EventLoggerConfig.builder()
                .loggerType(LoggerType.FILE)
                .property(EventLoggerConfig.AGENT_CONFIG_PROPERTY_KEY, agentConfig)
                .build();
    }

    @AfterEach
    void tearDown() throws Exception {
        if (logger != null) {
            logger.close();
        }
    }

    @Test
    void testOpenCreatesLogFile() throws Exception {
        logger.open(openParams);

        Path expectedLogFile = getExpectedLogFilePath();
        assertTrue(Files.exists(expectedLogFile), "Log file should be created");
        assertTrue(Files.isRegularFile(expectedLogFile), "Path should be a regular file");
    }

    @Test
    void testAppendWritesJsonEvents() throws Exception {
        logger.open(openParams);
        InputEvent inputEvent = new InputEvent("test input");
        EventContext context = new EventContext(inputEvent);

        logger.append(context, inputEvent);
        logger.flush();

        Path logFile = getExpectedLogFilePath();
        List<String> lines = Files.readAllLines(logFile);
        assertEquals(1, lines.size(), "Should have written one line");

        EventLogRecord deserializedRecord =
                objectMapper.readValue(lines.get(0), EventLogRecord.class);
        assertNotNull(deserializedRecord, "Deserialized record should not be null");
        assertNotNull(deserializedRecord.getContext(), "Deserialized context should not be null");
        assertNotNull(deserializedRecord.getEvent(), "Deserialized event should not be null");

        assertEquals(InputEvent.EVENT_TYPE, deserializedRecord.getContext().getEventType());
        assertEquals(InputEvent.EVENT_TYPE, deserializedRecord.getEvent().getType());
        InputEvent deserializedInput = InputEvent.fromEvent(deserializedRecord.getEvent());
        assertEquals("test input", deserializedInput.getInput());
    }

    @Test
    void testAppendMultipleEvents() throws Exception {
        logger.open(openParams);
        InputEvent inputEvent = new InputEvent("input data");
        OutputEvent outputEvent = new OutputEvent("output data");
        EventContext inputContext = new EventContext(inputEvent);
        EventContext outputContext = new EventContext(outputEvent);

        logger.append(inputContext, inputEvent);
        logger.append(outputContext, outputEvent);
        logger.flush();

        Path logFile = getExpectedLogFilePath();
        List<String> lines = Files.readAllLines(logFile);
        assertEquals(2, lines.size(), "Should have written two lines");

        // Verify first event (InputEvent) - deserialization via fromEvent
        EventLogRecord inputRecord = objectMapper.readValue(lines.get(0), EventLogRecord.class);
        assertEquals(InputEvent.EVENT_TYPE, inputRecord.getEvent().getType());
        assertEquals("input data", InputEvent.fromEvent(inputRecord.getEvent()).getInput());

        // Verify second event (OutputEvent) - deserialization via fromEvent
        EventLogRecord outputRecord = objectMapper.readValue(lines.get(1), EventLogRecord.class);
        assertEquals(OutputEvent.EVENT_TYPE, outputRecord.getEvent().getType());
        assertEquals("output data", OutputEvent.fromEvent(outputRecord.getEvent()).getOutput());
    }

    @Test
    void testAppendWithCustomEvent() throws Exception {
        // Given
        logger.open(openParams);
        TestCustomEvent customEvent = new TestCustomEvent("custom data", 42);
        EventContext context = new EventContext(customEvent);

        // When
        logger.append(context, customEvent);
        logger.flush();

        // Then
        Path logFile = getExpectedLogFilePath();
        List<String> lines = Files.readAllLines(logFile);
        assertEquals(1, lines.size());

        // Verify JSON structure
        JsonNode jsonNode = objectMapper.readTree(lines.get(0));
        assertEquals(TestCustomEvent.EVENT_TYPE, jsonNode.get("event").get("eventType").asText());

        JsonNode attrsNode = jsonNode.get("event").get("attributes");
        assertEquals("custom data", attrsNode.get("customData").asText());
        assertEquals(42, attrsNode.get("customNumber").asInt());

        // Verify deserialization via fromEvent
        EventLogRecord deserializedRecord =
                objectMapper.readValue(lines.get(0), EventLogRecord.class);
        assertNotNull(deserializedRecord);
        assertEquals(TestCustomEvent.EVENT_TYPE, deserializedRecord.getEvent().getType());

        TestCustomEvent deserializedEvent =
                TestCustomEvent.fromEvent(deserializedRecord.getEvent());
        assertEquals("custom data", deserializedEvent.getCustomData());
        assertEquals(42, deserializedEvent.getCustomNumber());
    }

    @Test
    void testAppendInAppendMode() throws Exception {
        // Given - first session
        logger.open(openParams);
        InputEvent event1 = new InputEvent("first event");
        EventContext context1 = new EventContext(event1);
        logger.append(context1, event1);
        logger.close();

        // When - second session (append mode)
        FileEventLogger secondLogger = new FileEventLogger(config);
        secondLogger.open(openParams);
        InputEvent event2 = new InputEvent("second event");
        EventContext context2 = new EventContext(event2);
        secondLogger.append(context2, event2);
        secondLogger.flush();
        secondLogger.close();

        // Then
        Path logFile = getExpectedLogFilePath();
        List<String> lines = Files.readAllLines(logFile);
        assertEquals(2, lines.size(), "Should have both events in append mode");

        // Verify JSON structure
        JsonNode firstEventJson = objectMapper.readTree(lines.get(0));
        assertEquals(
                "first event", firstEventJson.get("event").get("attributes").get("input").asText());

        JsonNode secondEventJson = objectMapper.readTree(lines.get(1));
        assertEquals(
                "second event",
                secondEventJson.get("event").get("attributes").get("input").asText());

        // Verify deserialization via fromEvent
        EventLogRecord firstRecord = objectMapper.readValue(lines.get(0), EventLogRecord.class);
        assertEquals(InputEvent.EVENT_TYPE, firstRecord.getEvent().getType());
        assertEquals("first event", InputEvent.fromEvent(firstRecord.getEvent()).getInput());

        EventLogRecord secondRecord = objectMapper.readValue(lines.get(1), EventLogRecord.class);
        assertEquals(InputEvent.EVENT_TYPE, secondRecord.getEvent().getType());
        assertEquals("second event", InputEvent.fromEvent(secondRecord.getEvent()).getInput());
    }

    @Test
    void testMultipleSubTasks() throws Exception {
        // Given - subtask 0
        logger.open(openParams);
        InputEvent event1 = new InputEvent("subtask 0 event");
        EventContext context1 = new EventContext(event1);
        logger.append(context1, event1);
        logger.flush();

        // Given - subtask 1
        when(taskInfo.getIndexOfThisSubtask()).thenReturn(1);
        FileEventLogger logger2 = new FileEventLogger(config);
        EventLoggerOpenParams openParams2 = new EventLoggerOpenParams(runtimeContext);
        logger2.open(openParams2);
        InputEvent event2 = new InputEvent("subtask 1 event");
        EventContext context2 = new EventContext(event2);
        logger2.append(context2, event2);
        logger2.flush();
        logger2.close();

        // Then - verify separate files with structured names
        Path subtask0File =
                tempDir.resolve(
                        String.format(
                                "events-%s-%s-%d.log", testJobId.toString(), testTaskName, 0));
        Path subtask1File =
                tempDir.resolve(
                        String.format(
                                "events-%s-%s-%d.log", testJobId.toString(), testTaskName, 1));

        assertTrue(Files.exists(subtask0File), "Subtask 0 file should exist");
        assertTrue(Files.exists(subtask1File), "Subtask 1 file should exist");

        List<String> subtask0Lines = Files.readAllLines(subtask0File);
        List<String> subtask1Lines = Files.readAllLines(subtask1File);

        assertEquals(1, subtask0Lines.size());
        assertEquals(1, subtask1Lines.size());

        // Verify JSON structure
        JsonNode subtask0EventJson = objectMapper.readTree(subtask0Lines.get(0));
        JsonNode subtask1EventJson = objectMapper.readTree(subtask1Lines.get(0));

        assertEquals(
                "subtask 0 event",
                subtask0EventJson.get("event").get("attributes").get("input").asText());
        assertEquals(
                "subtask 1 event",
                subtask1EventJson.get("event").get("attributes").get("input").asText());

        // Verify deserialization via fromEvent
        EventLogRecord subtask0Record =
                objectMapper.readValue(subtask0Lines.get(0), EventLogRecord.class);
        assertEquals(InputEvent.EVENT_TYPE, subtask0Record.getEvent().getType());
        assertEquals("subtask 0 event", InputEvent.fromEvent(subtask0Record.getEvent()).getInput());

        EventLogRecord subtask1Record =
                objectMapper.readValue(subtask1Lines.get(0), EventLogRecord.class);
        assertEquals(InputEvent.EVENT_TYPE, subtask1Record.getEvent().getType());
        assertEquals("subtask 1 event", InputEvent.fromEvent(subtask1Record.getEvent()).getInput());
    }

    @Test
    void testPrettyPrintOutputsFormattedJson() throws Exception {
        // Given - config with prettyPrint enabled
        Map<String, Object> agentConfig = new HashMap<>();
        agentConfig.put(AgentConfigOptions.PRETTY_PRINT.getKey(), true);
        config = buildConfig(agentConfig);
        logger = new FileEventLogger(config);

        logger.open(openParams);
        InputEvent inputEvent = new InputEvent("test input");
        logger.append(new EventContext(inputEvent), inputEvent);
        logger.flush();

        // Then - output should be valid JSON spanning multiple lines (pretty-printed)
        Path logFile = getExpectedLogFilePath();
        List<String> lines = Files.readAllLines(logFile);
        // Pretty-printed JSON for a single event record spans multiple lines
        assertTrue(lines.size() > 1, "Pretty-printed JSON should span multiple lines");
        // Each line after the first should be indented
        assertTrue(
                lines.subList(1, lines.size()).stream().anyMatch(line -> line.startsWith("  ")),
                "Pretty-printed JSON lines should be indented");
        // The entire content should still be valid JSON
        String content = String.join("\n", lines);
        assertDoesNotThrow(
                () -> objectMapper.readValue(content, EventLogRecord.class),
                "Pretty-printed output should be valid JSON deserializable to EventLogRecord");
    }

    @Test
    void testStandardLevelTruncation() throws Exception {
        // Given - config with STANDARD level and a small max-string-length for easy testing
        Map<String, Object> agentConfig = new HashMap<>();
        agentConfig.put("event-log.level", "STANDARD");
        agentConfig.put("event-log.standard.max-string-length", 10);
        agentConfig.put("event-log.standard.max-array-elements", 20);
        agentConfig.put("event-log.standard.max-depth", 5);

        config = buildConfig(agentConfig);
        logger = new FileEventLogger(config);
        logger.open(openParams);

        // Use a custom event with a very long string field
        TestCustomEvent event =
                new TestCustomEvent("this is a very long string that exceeds 10", 1);
        EventContext context = new EventContext(event);

        logger.append(context, event);
        logger.flush();

        Path logFile = getExpectedLogFilePath();
        List<String> lines = Files.readAllLines(logFile);
        assertEquals(1, lines.size());

        JsonNode jsonNode = objectMapper.readTree(lines.get(0));
        assertEquals("STANDARD", jsonNode.get("logLevel").asText());

        // The customData field (inside attributes) should be truncated
        JsonNode attrsNode = jsonNode.get("event").get("attributes");
        JsonNode customDataNode = attrsNode.get("customData");
        assertTrue(
                customDataNode.has("truncatedString"),
                "Long string should be truncated at STANDARD level");
        assertTrue(customDataNode.has("omittedChars"));
    }

    @Test
    void testVerboseLevelNoTruncation() throws Exception {
        // Given - config with VERBOSE level
        Map<String, Object> agentConfig = new HashMap<>();
        agentConfig.put("event-log.level", "VERBOSE");
        agentConfig.put("event-log.standard.max-string-length", 10);

        config = buildConfig(agentConfig);
        logger = new FileEventLogger(config);
        logger.open(openParams);

        TestCustomEvent event =
                new TestCustomEvent("this is a very long string that exceeds 10", 1);
        EventContext context = new EventContext(event);

        logger.append(context, event);
        logger.flush();

        Path logFile = getExpectedLogFilePath();
        List<String> lines = Files.readAllLines(logFile);
        assertEquals(1, lines.size());

        JsonNode jsonNode = objectMapper.readTree(lines.get(0));
        assertEquals("VERBOSE", jsonNode.get("logLevel").asText());

        // The customData field (inside attributes) should NOT be truncated
        JsonNode attrsNode = jsonNode.get("event").get("attributes");
        assertTrue(
                attrsNode.get("customData").isTextual(),
                "String should be preserved at VERBOSE level");
        assertEquals(
                "this is a very long string that exceeds 10", attrsNode.get("customData").asText());
    }

    @Test
    void testOffLevelSkipsEvent() throws Exception {
        // Given - config with OFF level
        Map<String, Object> agentConfig = new HashMap<>();
        agentConfig.put("event-log.level", "OFF");

        config = buildConfig(agentConfig);
        logger = new FileEventLogger(config);
        logger.open(openParams);

        InputEvent event = new InputEvent("should not be logged");
        EventContext context = new EventContext(event);

        logger.append(context, event);
        logger.flush();

        Path logFile = getExpectedLogFilePath();
        List<String> lines = Files.readAllLines(logFile);
        assertEquals(0, lines.size(), "OFF level should produce no output");
    }

    @Test
    void testPerTypeLevelOverride() throws Exception {
        // Given - root is STANDARD but InputEvent is set to VERBOSE
        Map<String, Object> agentConfig = new HashMap<>();
        agentConfig.put("event-log.level", "STANDARD");
        agentConfig.put("event-log.standard.max-string-length", 10);
        agentConfig.put("event-log.type." + InputEvent.EVENT_TYPE + ".level", "VERBOSE");

        config = buildConfig(agentConfig);
        logger = new FileEventLogger(config);
        logger.open(openParams);

        // InputEvent should be VERBOSE (no truncation)
        InputEvent inputEvent = new InputEvent("this is a very long string that exceeds 10");
        logger.append(new EventContext(inputEvent), inputEvent);

        // TestCustomEvent should be STANDARD (truncated)
        TestCustomEvent customEvent =
                new TestCustomEvent("this is a very long string that exceeds 10", 1);
        logger.append(new EventContext(customEvent), customEvent);
        logger.flush();

        Path logFile = getExpectedLogFilePath();
        List<String> lines = Files.readAllLines(logFile);
        assertEquals(2, lines.size());

        // InputEvent at VERBOSE - no truncation (data lives in attributes)
        JsonNode inputJson = objectMapper.readTree(lines.get(0));
        assertEquals("VERBOSE", inputJson.get("logLevel").asText());
        assertTrue(inputJson.get("event").get("attributes").get("input").isTextual());

        // TestCustomEvent at STANDARD - truncated (data lives in attributes)
        JsonNode customJson = objectMapper.readTree(lines.get(1));
        assertEquals("STANDARD", customJson.get("logLevel").asText());
        assertTrue(
                customJson.get("event").get("attributes").get("customData").has("truncatedString"));
    }

    @Test
    void testJsonOutputHasNewFields() throws Exception {
        // Given - default config
        logger.open(openParams);
        InputEvent event = new InputEvent("test");
        EventContext context = new EventContext(event);

        logger.append(context, event);
        logger.flush();

        Path logFile = getExpectedLogFilePath();
        List<String> lines = Files.readAllLines(logFile);
        JsonNode jsonNode = objectMapper.readTree(lines.get(0));

        // Verify new top-level fields exist
        assertTrue(jsonNode.has("logLevel"), "JSON should have logLevel field");
        assertTrue(jsonNode.has("eventType"), "JSON should have eventType field");
        assertEquals(InputEvent.EVENT_TYPE, jsonNode.get("eventType").asText());
        assertNotNull(jsonNode.get("logLevel").asText());
    }

    @Test
    void testBackwardCompatibleDeserialization() throws Exception {
        // Simulate old-format JSON without a top-level logLevel field. The Event payload uses
        // the post-#631 shape: {type, id, attributes}. The deserializer must still parse it.
        String oldFormatJson =
                "{\"timestamp\":\"2024-01-15T10:30:00Z\","
                        + "\"event\":{\"eventType\":\""
                        + InputEvent.EVENT_TYPE
                        + "\",\"type\":\""
                        + InputEvent.EVENT_TYPE
                        + "\","
                        + "\"attributes\":{\"input\":\"test\"}}}";

        EventLogRecord record = objectMapper.readValue(oldFormatJson, EventLogRecord.class);
        assertNotNull(record.getEvent());
        assertEquals(InputEvent.EVENT_TYPE, record.getEvent().getType());
        assertEquals(
                "test",
                InputEvent.fromEvent(record.getEvent()).getInput(),
                "Old-format JSON without logLevel should still deserialize the event payload");
    }

    @Test
    void testHierarchicalInheritance() throws Exception {
        // Set namespace-level OFF, but specific type VERBOSE. Uses custom dotted event types
        // because built-in event types (e.g., "_input_event") have no dot-separated parents.
        Map<String, Object> agentConfig = new HashMap<>();
        agentConfig.put("event-log.level", "STANDARD");
        agentConfig.put("event-log.type.com.example.events.level", "OFF");
        agentConfig.put("event-log.type." + TestNamespacedEventA.EVENT_TYPE + ".level", "VERBOSE");

        config = buildConfig(agentConfig);
        logger = new FileEventLogger(config);
        logger.open(openParams);

        // EventA has explicit VERBOSE override — should be logged
        TestNamespacedEventA eventA = new TestNamespacedEventA("should be logged");
        logger.append(new EventContext(eventA), eventA);

        // EventB inherits OFF from namespace level — should NOT be logged
        TestNamespacedEventB eventB = new TestNamespacedEventB("should not be logged");
        logger.append(new EventContext(eventB), eventB);
        logger.flush();

        Path logFile = getExpectedLogFilePath();
        List<String> lines = Files.readAllLines(logFile);
        assertEquals(1, lines.size(), "Only EventA (VERBOSE override) should be logged");

        JsonNode json = objectMapper.readTree(lines.get(0));
        assertEquals("VERBOSE", json.get("logLevel").asText());
        assertEquals(TestNamespacedEventA.EVENT_TYPE, json.get("eventType").asText());
    }

    private Path getExpectedLogFilePath() {
        return tempDir.resolve(
                String.format(
                        "events-%s-%s-%d.log", testJobId.toString(), testTaskName, testSubTaskId));
    }

    /** Custom test event class using the attributes-based pattern. */
    public static class TestCustomEvent extends Event {
        public static final String EVENT_TYPE = "TestCustomEvent";

        public TestCustomEvent(String customData, int customNumber) {
            super(EVENT_TYPE);
            setAttr("customData", customData);
            setAttr("customNumber", customNumber);
        }

        private TestCustomEvent(UUID id, Map<String, Object> attributes) {
            super(id, EVENT_TYPE, attributes);
        }

        public static TestCustomEvent fromEvent(Event event) {
            TestCustomEvent result =
                    new TestCustomEvent(event.getId(), new HashMap<>(event.getAttributes()));
            if (event.hasSourceTimestamp()) {
                result.setSourceTimestamp(event.getSourceTimestamp());
            }
            return result;
        }

        @JsonIgnore
        public String getCustomData() {
            return (String) getAttr("customData");
        }

        @JsonIgnore
        public int getCustomNumber() {
            return ((Number) getAttr("customNumber")).intValue();
        }
    }

    /** Custom event with a dot-separated type to exercise hierarchical level inheritance. */
    public static class TestNamespacedEventA extends Event {
        public static final String EVENT_TYPE = "com.example.events.A";

        public TestNamespacedEventA(String payload) {
            super(EVENT_TYPE);
            setAttr("payload", payload);
        }
    }

    /** Custom event sharing EventA's namespace, used to verify namespace-level inheritance. */
    public static class TestNamespacedEventB extends Event {
        public static final String EVENT_TYPE = "com.example.events.B";

        public TestNamespacedEventB(String payload) {
            super(EVENT_TYPE);
            setAttr("payload", payload);
        }
    }
}
