/*
 * 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.pulsar.broker.service.schema;

import static java.nio.charset.StandardCharsets.UTF_8;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.module.jsonSchema.JsonSchema;
import com.fasterxml.jackson.module.jsonSchema.JsonSchemaGenerator;
import java.io.IOException;
import java.util.Collections;
import java.util.Map;
import lombok.Data;
import org.apache.pulsar.client.api.Schema;
import org.apache.pulsar.client.api.SchemaSerializationException;
import org.apache.pulsar.client.api.schema.SchemaDefinition;
import org.apache.pulsar.client.impl.schema.JSONSchema;
import org.apache.pulsar.common.policies.data.SchemaCompatibilityStrategy;
import org.apache.pulsar.common.protocol.schema.SchemaData;
import org.apache.pulsar.common.schema.SchemaInfo;
import org.apache.pulsar.common.schema.SchemaType;
import org.testng.Assert;
import org.testng.annotations.Test;

@Test(groups = "broker")
public class JsonSchemaCompatibilityCheckTest extends BaseAvroSchemaCompatibilityTest{

    @Override
    public SchemaCompatibilityCheck getSchemaCheck() {
        return new JsonSchemaCompatibilityCheck();
    }

    @Test
    public void testJsonSchemaBackwardsCompatibilityWithLegacyEnabled() throws JsonProcessingException {
        // When legacy format is enabled, backward compatibility between old and new formats should work
        JsonSchemaCompatibilityCheck check = new JsonSchemaCompatibilityCheck();
        check.setAllowLegacyJacksonFormat(true);

        SchemaData from = SchemaData.builder().data(OldJSONSchema.of(Foo.class).getSchemaInfo().getSchema()).build();
        SchemaData to = SchemaData.builder().data(JSONSchema.of(SchemaDefinition.builder()
                .withPojo(Foo.class).build()).getSchemaInfo().getSchema()).build();
        Assert.assertTrue(check.isCompatible(from, to, SchemaCompatibilityStrategy.FULL));

        from = SchemaData.builder().data(JSONSchema.of(SchemaDefinition.<Foo>builder()
                .withPojo(Foo.class).build()).getSchemaInfo().getSchema()).build();
        to = SchemaData.builder().data(OldJSONSchema.of(Foo.class).getSchemaInfo().getSchema()).build();
        Assert.assertTrue(check.isCompatible(from, to, SchemaCompatibilityStrategy.FULL));
    }

    @Test
    public void testJsonSchemaBackwardsCompatibilityRejectedByDefault() throws JsonProcessingException {
        // When legacy format is disabled (default), mixed old/new format should be rejected
        JsonSchemaCompatibilityCheck check = new JsonSchemaCompatibilityCheck();
        // allowLegacyJacksonFormat defaults to false

        // Old format (Jackson) -> New format (Avro): should be rejected because 'from' is not valid Avro
        SchemaData from = SchemaData.builder().data(OldJSONSchema.of(Foo.class).getSchemaInfo().getSchema()).build();
        SchemaData to = SchemaData.builder().data(JSONSchema.of(SchemaDefinition.builder()
                .withPojo(Foo.class).build()).getSchemaInfo().getSchema()).build();
        Assert.assertFalse(check.isCompatible(from, to, SchemaCompatibilityStrategy.FULL));

        // New format (Avro) -> Old format (Jackson): should be rejected because 'to' is not valid Avro
        from = SchemaData.builder().data(JSONSchema.of(SchemaDefinition.<Foo>builder()
                .withPojo(Foo.class).build()).getSchemaInfo().getSchema()).build();
        to = SchemaData.builder().data(OldJSONSchema.of(Foo.class).getSchemaInfo().getSchema()).build();
        Assert.assertFalse(check.isCompatible(from, to, SchemaCompatibilityStrategy.FULL));
    }

    @Test
    public void testAvroToAvroCompatibilityUnaffectedByConfig() throws JsonProcessingException {
        // Both Avro schemas: should work the same regardless of legacy flag
        JsonSchemaCompatibilityCheck strictCheck = new JsonSchemaCompatibilityCheck();
        JsonSchemaCompatibilityCheck legacyCheck = new JsonSchemaCompatibilityCheck();
        legacyCheck.setAllowLegacyJacksonFormat(true);

        SchemaData from = SchemaData.builder().data(JSONSchema.of(SchemaDefinition.<Foo>builder()
                .withPojo(Foo.class).build()).getSchemaInfo().getSchema()).build();
        SchemaData to = SchemaData.builder().data(JSONSchema.of(SchemaDefinition.<Foo>builder()
                .withPojo(Foo.class).build()).getSchemaInfo().getSchema()).build();

        Assert.assertTrue(strictCheck.isCompatible(from, to, SchemaCompatibilityStrategy.FULL));
        Assert.assertTrue(legacyCheck.isCompatible(from, to, SchemaCompatibilityStrategy.FULL));
    }

    @Test
    public void testJsonSchemaDraftRejectedByDefault() {
        // JSON Schema Draft 2020-12 format should be rejected when legacy is disabled
        JsonSchemaCompatibilityCheck check = new JsonSchemaCompatibilityCheck();

        String avroSchema = "{\"type\":\"record\",\"name\":\"Foo\",\"namespace\":\"org.example\","
                + "\"fields\":[{\"name\":\"field1\",\"type\":\"string\"}]}";
        String jsonSchemaDraft = "{\"$schema\":\"https://json-schema.org/draft/2020-12/schema\","
                + "\"type\":\"object\",\"properties\":{\"field1\":{\"type\":\"string\"}}}";

        // Avro -> JSON Schema Draft: should be rejected
        SchemaData from = SchemaData.builder().data(avroSchema.getBytes(UTF_8)).build();
        SchemaData to = SchemaData.builder().data(jsonSchemaDraft.getBytes(UTF_8)).build();
        Assert.assertFalse(check.isCompatible(from, to, SchemaCompatibilityStrategy.FULL));

        // JSON Schema Draft -> Avro: should be rejected (existing schema not valid Avro)
        from = SchemaData.builder().data(jsonSchemaDraft.getBytes(UTF_8)).build();
        to = SchemaData.builder().data(avroSchema.getBytes(UTF_8)).build();
        Assert.assertFalse(check.isCompatible(from, to, SchemaCompatibilityStrategy.FULL));
    }

    @Test
    public void testSchemaWithDollarSignInRecordNameRejectsIncompatibleChange() {
        // Schema v1: has field1 (string)
        String schemaV1 =
                "{\"type\":\"record\",\"name\":\"Outer$Inner\",\"namespace\":\"org.example\","
                        + "\"fields\":[{\"name\":\"field1\",\"type\":\"string\"}]}";
        // Schema v2: removed field1, added field2 without default — NOT backward compatible
        String schemaV2 =
                "{\"type\":\"record\",\"name\":\"Outer$Inner\",\"namespace\":\"org.example\","
                        + "\"fields\":[{\"name\":\"field2\",\"type\":\"string\"}]}";
        SchemaData from = SchemaData.builder()
                .data(schemaV1.getBytes(UTF_8))
                .type(SchemaType.JSON)
                .build();
        SchemaData to = SchemaData.builder()
                .data(schemaV2.getBytes(UTF_8))
                .type(SchemaType.JSON)
                .build();
        JsonSchemaCompatibilityCheck check = new JsonSchemaCompatibilityCheck();
        // Without the fix, isAvroSchema() rejects '$' and the compatibility check is
        // skipped entirely (falls through to "corrupted, allow overwrite"), so this
        // would incorrectly return true.
        // With the fix, isAvroSchema() recognizes these as valid Avro schemas and the
        // Avro compatibility check correctly detects the incompatibility.
        Assert.assertFalse(check.isCompatible(from, to, SchemaCompatibilityStrategy.BACKWARD));
    }

    @Data
    private static class Foo {
        private String field1;
        private String field2;
        private int field3;
        private Bar field4;
    }

    @Data
    private static class Bar {
        private boolean field1;
    }

    public static class OldJSONSchema<T> implements Schema<T> {

        private final SchemaInfo info;
        private final ObjectMapper objectMapper;
        private final Class<T> pojo;

        private OldJSONSchema(SchemaInfo info, Class<T> pojo, ObjectMapper objectMapper) {
            this.info = info;
            this.pojo = pojo;
            this.objectMapper = objectMapper;
        }

        @Override
        public byte[] encode(T message) throws SchemaSerializationException {
            try {
                return objectMapper.writeValueAsBytes(message);
            } catch (JsonProcessingException e) {
                throw new SchemaSerializationException(e);
            }
        }

        @Override
        public T decode(byte[] bytes) {
            try {
                return objectMapper.readValue(new String(bytes), pojo);
            } catch (IOException e) {
                throw new RuntimeException(new SchemaSerializationException(e));
            }
        }

        @Override
        public SchemaInfo getSchemaInfo() {
            return info;
        }

        public static <T> OldJSONSchema<T> of(Class<T> pojo) throws JsonProcessingException {
            return of(pojo, Collections.emptyMap());
        }

        public static <T> OldJSONSchema<T> of(Class<T> pojo, Map<String, String> properties)
                throws JsonProcessingException {
            ObjectMapper mapper = new ObjectMapper();
            JsonSchemaGenerator schemaGen = new JsonSchemaGenerator(mapper);
            JsonSchema schema = schemaGen.generateSchema(pojo);

            SchemaInfo info = SchemaInfo.builder()
                    .name("")
                    .properties(properties)
                    .type(SchemaType.JSON)
                    .schema(mapper.writeValueAsBytes(schema))
                    .build();
            return new OldJSONSchema<>(info, pojo, mapper);
        }

        @Override
        public Schema<T> clone() {
            return this;
        }
    }
}
