/**
 * 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
 *
 *     https://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.
 */
using System;
using System.IO;
using Avro.IO;
using System.Collections.Generic;
using System.Text;
using Avro.Generic;
using NUnit.Framework;
using Decoder = Avro.IO.Decoder;
using Encoder = Avro.IO.Encoder;

namespace Avro.Test.Generic
{
    class GenericTests
    {
        private static string intToUtf8(int value)
        {
            var decimalLogicalType = new Avro.Util.Decimal();
            var logicalSchema = (LogicalSchema)
                Schema.Parse(@"{ ""type"": ""bytes"", ""logicalType"": ""decimal"", ""precision"": 4 }");

            byte[] byteArray = (byte[])decimalLogicalType.ConvertToBaseValue(new AvroDecimal(value), logicalSchema);

            return Encoding.GetEncoding("iso-8859-1").GetString(byteArray);
        }

        [Test]
        public void ConvertsDecimalZeroToLogicalType() => ConvertsDefaultToLogicalType(
            @"{""type"": ""bytes"", ""logicalType"": ""decimal"", ""precision"": 4}",
            @$"""{intToUtf8(0)}""", new AvroDecimal(0));

        [Test]
        public void ConvertsDecimalIntegerToLogicalType() => ConvertsDefaultToLogicalType(
            @"{""type"": ""bytes"", ""logicalType"": ""decimal"", ""precision"": 4}",
            @$"""{intToUtf8(1234)}""", new AvroDecimal(1234));

        [Test]
        public void ConvertsDecimalScaledToLogicalType() => ConvertsDefaultToLogicalType(
            @"{""type"": ""bytes"", ""logicalType"": ""decimal"", ""precision"": 4, ""scale"": 3}",
            @$"""{intToUtf8(1234)}""", new AvroDecimal(1.234));

        private static IEnumerable<TestCaseData> ConvertsDefaultToLogicalTypeSource = new List<TestCaseData>()
        {
            new TestCaseData(@"{""type"": ""string"", ""logicalType"": ""uuid""}", @"""00000000-0000-0000-0000-000000000000""", new Guid()),
            new TestCaseData(@"{""type"": ""string"", ""logicalType"": ""uuid""}", @"""00000000000000000000000000000000""", new Guid()),
            new TestCaseData(@"{""type"": ""string"", ""logicalType"": ""uuid""}", @"""12345678-1234-5678-1234-123456789012""", new Guid("12345678-1234-5678-1234-123456789012")),
            new TestCaseData(@"{""type"": ""string"", ""logicalType"": ""uuid""}", @"""12345678123456781234123456789012""", new Guid("12345678-1234-5678-1234-123456789012")),
            new TestCaseData(@"{""type"": ""int"", ""logicalType"": ""date""}", "0", DateTime.UnixEpoch),
            new TestCaseData(@"{""type"": ""int"", ""logicalType"": ""date""}", "123456", DateTime.UnixEpoch.AddDays(123456)),
            new TestCaseData(@"{""type"": ""long"", ""logicalType"": ""time-micros""}", "0", new TimeSpan()),
            new TestCaseData(@"{""type"": ""long"", ""logicalType"": ""time-micros""}", "123456", new TimeSpan(123456*TimeSpan.TicksPerMillisecond/1000)),
            new TestCaseData(@"{""type"": ""int"", ""logicalType"": ""time-millis""}", "0", new TimeSpan()),
            new TestCaseData(@"{""type"": ""int"", ""logicalType"": ""time-millis""}", "123456", new TimeSpan(0, 0, 0, 0, 123456)),
            new TestCaseData(@"{""type"": ""long"", ""logicalType"": ""timestamp-micros""}", "0", DateTime.UnixEpoch),
            new TestCaseData(@"{""type"": ""long"", ""logicalType"": ""timestamp-micros""}", "123456", DateTime.UnixEpoch.AddTicks(123456*TimeSpan.TicksPerMillisecond/1000)),
            new TestCaseData(@"{""type"": ""long"", ""logicalType"": ""timestamp-millis""}", "0", DateTime.UnixEpoch),
            new TestCaseData(@"{""type"": ""long"", ""logicalType"": ""timestamp-millis""}", "123456", DateTime.UnixEpoch.AddMilliseconds(123456))
        };

        [TestCaseSource(nameof(ConvertsDefaultToLogicalTypeSource))]
        public void ConvertsDefaultToLogicalType(string typeDefinition, string defaultDefinition, object expected)
        {
            var writerSchemaString = @"{
    ""type"": ""record"",
    ""name"": ""Foo"",
    ""fields"": [      
    ]
}";

            var readerSchemaString = $@"{{
    ""type"": ""record"",
    ""name"": ""Foo"",
    ""fields"": [
        {{
            ""name"": ""x"",
            ""type"": {typeDefinition},
            ""default"": {defaultDefinition}
        }}
    ]
}}";
            var writerSchema = Schema.Parse(writerSchemaString);

            Stream stream;

            serialize(writerSchemaString,
                MkRecord(new object[] { }, (RecordSchema)writerSchema),
                out stream,
                out _);

            var output = deserialize<GenericRecord>(stream, writerSchema, Schema.Parse(readerSchemaString)).GetValue(0);
            
            Assert.AreEqual(expected, output);
        }

        private static void test<T>(string s, T value)
        {
            Stream ms;
            Schema ws;
            serialize(s, value, out ms, out ws);
            Schema rs = Schema.Parse(s);
            T output = deserialize<T>(ms, ws, rs);
            Assert.AreEqual(value, output);
        }

        [TestCase("{\"type\": \"boolean\"}", true)]
        [TestCase("{\"type\": \"boolean\"}", false)]

        // Union
        [TestCase("[\"boolean\", \"null\"]", null)]
        [TestCase("[\"boolean\", \"null\"]", true)]
        [TestCase("[\"int\", \"long\"]", 100)]
        [TestCase("[\"int\", \"long\"]", 100L)]
        [TestCase("[\"float\", \"double\"]", 100.75)]
        [TestCase("[\"float\", \"double\"]", 23.67f)]
        [TestCase("[\"float\", \"int\"]", 0)]
        [TestCase("[\"float\", \"int\"]", 0.0f)]
        [TestCase("[\"float\", \"int\"]", 100)]
        [TestCase("[\"float\", \"int\"]", 100.0f)]
        [TestCase("[\"float\", \"int\"]", -100)]
        [TestCase("[\"float\", \"int\"]", -100.0f)]
        [TestCase("[\"double\", \"long\"]", 0L)]
        [TestCase("[\"double\", \"long\"]", 0.0)]
        [TestCase("[\"double\", \"long\"]", 100L)]
        [TestCase("[\"double\", \"long\"]", 100.0)]
        [TestCase("[\"double\", \"long\"]", -100L)]
        [TestCase("[\"double\", \"long\"]", -100.0)]
        [TestCase("[{\"type\": \"array\", \"items\": \"float\"}, \"double\"]", new float[] { 23.67f, 22.78f })]
        [TestCase("[{\"type\": \"array\", \"items\": \"float\"}, \"double\"]", 100.89)]
        [TestCase("[{\"type\": \"array\", \"items\": \"string\"}, \"string\"]", "a")]
        [TestCase("[{\"type\": \"array\", \"items\": \"string\"}, \"string\"]", new string[] { "a", "b" })]
        [TestCase("[{\"type\": \"array\", \"items\": \"bytes\"}, \"bytes\"]", new byte[] { 1, 2, 3 })]
        [TestCase("[{\"type\": \"array\", \"items\": \"bytes\"}, \"bytes\"]",
            new object[] { new byte[] { 1, 2 }, new byte[] { 3, 4 } })]
        [TestCase("[{\"type\": \"enum\", \"symbols\": [\"s1\", \"s2\"], \"name\": \"e\"}, \"string\"]", "h1")]
        public void TestPrimitive(string schema, object value)
        {
            test(schema, value);
        }

        [TestCase("{\"type\":\"record\", \"name\":\"n\", \"fields\":[{\"name\":\"f1\", \"type\":\"null\"}]}",
            new object[] { "f1", null })]
        [TestCase("{\"type\":\"record\", \"name\":\"n\", \"fields\":[{\"name\":\"f1\", \"type\":\"boolean\"}]}",
            new object[] { "f1", true })]
        [TestCase("{\"type\":\"record\", \"name\":\"n\", \"fields\":[{\"name\":\"f1\", \"type\":\"boolean\"}]}",
            new object[] { "f1", false })]
        [TestCase("{\"type\":\"record\", \"name\":\"n\", \"fields\":[{\"name\":\"f1\", \"type\":\"int\"}]}",
            new object[] { "f1", 101 })]
        [TestCase("{\"type\":\"record\", \"name\":\"n\", \"fields\":[{\"name\":\"f1\", \"type\":\"long\"}]}",
            new object[] { "f1", 101L })]
        [TestCase("{\"type\":\"record\", \"name\":\"n\", \"fields\":[{\"name\":\"f1\", \"type\":\"float\"}]}",
            new object[] { "f1", 101.78f })]
        [TestCase("{\"type\":\"record\", \"name\":\"n\", \"fields\":[{\"name\":\"f1\", \"type\":\"double\"}]}",
            new object[] { "f1", 101.78 })]
        [TestCase("{\"type\":\"record\", \"name\":\"n\", \"fields\":[{\"name\":\"f1\", \"type\":\"string\"}]}",
            new object[] { "f1", "A" })]
        [TestCase("{\"type\":\"record\", \"name\":\"n\", \"fields\":[{\"name\":\"f1\", \"type\":\"bytes\"}]}",
            new object[] { "f1", new byte[] { 0, 1 } })]
        [TestCase("{\"type\":\"record\", \"name\":\"n\", \"fields\":" +
            "[{\"name\":\"f1\", \"type\":{\"type\": \"enum\", \"name\": \"e\", \"symbols\":[\"s1\", \"s2\"]}}]}",
            new object[] { "f1", "s2" })]
        [TestCase("{\"type\":\"record\", \"name\":\"n\", \"fields\":" +
            "[{\"name\":\"f1\", \"type\":{\"type\": \"array\", \"items\": \"int\"}}]}",
            new object[] { "f1", new object[] { 0, 1, 101 } })]
        [TestCase("{\"type\":\"record\", \"name\":\"n\", \"fields\":" +
            "[{\"name\":\"f1\", \"type\":{\"type\": \"array\", \"items\": \"int\"}}]}",
            new object[] { "f1", new int[] { 0, 1, 101 } })]
        [TestCase("{\"type\":\"record\", \"name\":\"n\", \"fields\":" +
            "[{\"name\":\"f1\", \"type\":[\"int\", \"long\"]}]}",
            new object[] { "f1", 100 })]
        [TestCase("{\"type\":\"record\", \"name\":\"n\", \"fields\":" +
            "[{\"name\":\"f1\", \"type\":[\"int\", \"long\"]}]}",
            new object[] { "f1", 100L })]
        [TestCase("{\"type\":\"record\", \"name\":\"n\", \"fields\":" +
            "[{\"name\":\"f1\", \"type\":{\"type\": \"fixed\", \"name\": \"f\", \"size\": 2}}]}",
            new object[] { "f1", new byte[] { 1, 2 } })]
        public void TestRecord(string schema, object[] kv)
        {
            test(schema, MkRecord(kv, Schema.Parse(schema) as RecordSchema));
        }

        [TestCase("{\"type\": \"map\", \"values\": \"string\"}",
            new object[] { "a", "0", "b", "1", "c", "101" })]
        public void TestMap(string schema, object[] values)
        {
            test(schema, mkMap(values));
        }

        [TestCase("{\"type\": \"enum\", \"name\": \"Test\", \"symbols\": [\"Unknown\", \"A\", \"B\"], \"default\": \"Unknown\" }", "C", "Unknown")]
        [TestCase("{\"type\": \"enum\", \"name\": \"Test\", \"symbols\": [\"Unknown\", \"A\", \"B\"], \"default\": \"Unknown\" }", "A", "A")]
        public void TestEnumDefault(string schema, string attemptedValue, string expectedValue)
        {
            var newEnum = mkEnum(schema, attemptedValue) as GenericEnum;
            Assert.NotNull(newEnum);
            Assert.AreEqual(newEnum.Value, expectedValue);
        }

        [TestCase()]
        public void TestLogical_Date()
        {
            test("{\"type\": \"int\", \"logicalType\": \"date\"}", DateTime.UtcNow.Date);
        }

        [TestCase()]
        public void TestLogical_TimeMillisecond()
        {
            test("{\"type\": \"int\", \"logicalType\": \"time-millis\"}", new TimeSpan(3, 0, 0));
        }

        [TestCase()]
        public void TestLogical_TimeMicrosecond()
        {
            test("{\"type\": \"long\", \"logicalType\": \"time-micros\"}", new TimeSpan(3, 0, 0));
        }

        [TestCase()]
        public void TestLogical_TimestampMillisecond()
        {
            test("{\"type\": \"long\", \"logicalType\": \"timestamp-millis\"}", new DateTime(1990, 1, 1, 14, 15, 30, DateTimeKind.Utc));
        }

        [TestCase()]
        public void TestLogical_TimestampMicrosecond()
        {
            test("{\"type\": \"long\", \"logicalType\": \"timestamp-micros\"}", new DateTime(1990, 1, 1, 14, 15, 30, DateTimeKind.Utc));
        }

        [TestCase()]
        public void TestLogical_Decimal_Bytes()
        {
            test("{\"type\": \"bytes\", \"logicalType\": \"decimal\", \"precision\": 30, \"scale\": 2}",
                (AvroDecimal)12345678912345.55M);
        }

        [TestCase()]
        public void TestLogical_Decimal_Fixed()
        {
            test("{\"type\": {\"type\": \"fixed\", \"size\": 16, \"name\": \"n\"}, \"logicalType\": \"decimal\", \"precision\": 30, \"scale\": 2}",
                (AvroDecimal)12345678912345.55M);
        }

        [TestCase("[{\"type\":\"record\", \"name\":\"n\", \"fields\":[{\"name\":\"f1\", \"type\":\"string\"}]}, \"string\"]",
            "{\"type\":\"record\", \"name\":\"n\", \"fields\":[{\"name\":\"f1\", \"type\":\"string\"}]}",
            new object[] { "f1", "v1" })]
        public void TestUnion_record(string unionSchema, string recordSchema, object[] value)
        {
            test(unionSchema, MkRecord(value, Schema.Parse(recordSchema) as RecordSchema));
        }

        [TestCase("[{\"type\": \"enum\", \"symbols\": [\"s1\", \"s2\"], \"name\": \"e\"}, \"string\"]",
            "{\"type\": \"enum\", \"symbols\": [\"s1\", \"s2\"], \"name\": \"e\"}", "s1")]
        [TestCase("[{\"type\": \"enum\", \"symbols\": [\"s1\", \"s2\"], \"name\": \"e\"}, \"string\"]",
            "{\"type\": \"enum\", \"symbols\": [\"s1\", \"s2\"], \"name\": \"e\"}", "s2")]
        [TestCase("[{\"type\": \"enum\", \"symbols\": [\"s1\", \"s2\"], \"name\": \"e\"}, \"string\"]",
            "{\"type\": \"enum\", \"symbols\": [\"s1\", \"s2\"], \"name\": \"e\"}", "s3", typeof(AvroException))]
        public void TestUnion_enum(string unionSchema, string enumSchema, string value, Type expectedExceptionType = null)
        {
            if (expectedExceptionType != null)
            {
                Assert.Throws(expectedExceptionType, () => { test(unionSchema, mkEnum(enumSchema, value)); });
            }
            else
            {
                test(unionSchema, mkEnum(enumSchema, value));
            }
        }


        [TestCase("[{\"type\": \"map\", \"values\": \"int\"}, \"string\"]",
            "{\"type\": \"map\", \"values\": \"int\"}", new object[] { "a", 1, "b", 2 })]
        public void TestUnion_map(string unionSchema, string mapSchema, object[] value)
        {
            test(unionSchema, mkMap(value));
        }

        [TestCase("[{\"type\": \"fixed\", \"size\": 2, \"name\": \"f\"}, \"string\"]",
            "{\"type\": \"fixed\", \"size\": 2, \"name\": \"f\"}", new byte[] { 1, 2 })]
        [TestCase("[{\"type\": \"fixed\", \"size\": 2, \"name\": \"f\"}, \"string\"]",
            "{\"type\": \"fixed\", \"size\": 2, \"name\": \"f\"}", new byte[] { 1, 2, 3 }, typeof(AvroException))]
        [TestCase("[{\"type\": \"fixed\", \"size\": 2, \"name\": \"f\"}, \"string\"]",
            "{\"type\": \"fixed\", \"size\": 3, \"name\": \"f\"}", new byte[] { 1, 2, 3 }, typeof(AvroException))]
        public void TestUnion_fixed(string unionSchema, string fixedSchema, byte[] value, Type expectedExceptionType = null)
        {
            if (expectedExceptionType != null)
            {
                Assert.Throws(expectedExceptionType, () => { test(unionSchema, mkFixed(fixedSchema, value)); });
            }
            else
            {
                test(unionSchema, mkFixed(fixedSchema, value));
            }
        }

        public void TestResolution<T, S>(string writerSchema, T actual, string readerSchema, S expected)
        {
            Stream ms;
            Schema ws;
            serialize<T>(writerSchema, actual, out ms, out ws);
            Schema rs = Schema.Parse(readerSchema);
            S output = deserialize<S>(ms, ws, rs);
            Assert.AreEqual(expected, output);
        }

        [TestCase("int", 10, "long", 10L)]
        [TestCase("int", 10, "float", 10.0f)]
        [TestCase("int", 10, "double", 10.0)]
        [TestCase("long", 10L, "float", 10.0f)]
        [TestCase("long", 10L, "double", 10.0)]
        [TestCase("float", 10.0f, "double", 10.0)]
        [TestCase("{\"type\":\"array\", \"items\":\"int\"}", new int[] { 10, 20 },
            "{\"type\":\"array\", \"items\":\"long\"}", new object[] { 10L, 20L })]
        [TestCase("[\"int\", \"boolean\"]", true, "[\"boolean\", \"double\"]", true)]
        [TestCase("[\"int\", \"boolean\"]", 10, "[\"boolean\", \"double\"]", 10.0)]
        [TestCase("[\"int\", \"boolean\"]", 10, "\"int\"", 10)]
        [TestCase("[\"int\", \"boolean\"]", 10, "\"double\"", 10.0)]
        [TestCase("\"int\"", 10, "[\"int\", \"boolean\"]", 10)]
        [TestCase("\"int\"", 10, "[\"long\", \"boolean\"]", 10L)]
        public void TestResolution_simple(string writerSchema, object actual, string readerSchema, object expected)
        {
            TestResolution(writerSchema, actual, readerSchema, expected);
        }

        [Test]
        public void TestResolution_intMapToLongMap()
        {
            TestResolution("{\"type\":\"map\", \"values\":\"int\"}", mkMap(new object[] { "a", 10, "b", 20 }),
                "{\"type\":\"map\", \"values\":\"long\"}", mkMap(new object[] { "a", 10L, "b", 20L }));
        }

        [Test]
        public void TestResolution_enum()
        {
            string ws = "{\"type\":\"enum\", \"symbols\":[\"a\", \"b\"], \"name\":\"e\"}";
            string rs = "{\"type\":\"enum\", \"symbols\":[\"a\", \"b\"], \"name\":\"e\"}";
            TestResolution(ws, mkEnum(ws, "a"), rs, mkEnum(rs, "a"));
        }

        [TestCase("{\"type\":\"record\",\"name\":\"r\",\"fields\":" +
            "[{\"name\":\"f1\",\"type\":\"boolean\"},{\"name\":\"f2\",\"type\":\"int\"}]}",
            new object[] { "f1", true, "f2", 100 },
            "{\"type\":\"record\",\"name\":\"r\",\"fields\":" +
            "[{\"name\":\"f2\",\"type\":\"int\"},{\"name\":\"f1\",\"type\":\"boolean\"}]}",
            new object[] { "f1", true, "f2", 100 }, Description = "Out of order fields")]
        [TestCase("{\"type\":\"record\",\"name\":\"r\",\"fields\":" +
            "[{\"name\":\"f1\",\"type\":\"boolean\"},{\"name\":\"f2\",\"type\":\"int\"}]}",
            new object[] { "f1", true, "f2", 100 },
            "{\"type\":\"record\",\"name\":\"r\",\"fields\":" +
            "[{\"name\":\"f1\",\"type\":\"boolean\"},{\"name\":\"f2\",\"type\":\"long\"}]}",
            new object[] { "f1", true, "f2", 100L }, Description = "Field promotion")]
        [TestCase("{\"type\":\"record\",\"name\":\"r\",\"fields\":" +
            "[{\"name\":\"f1\",\"type\":\"boolean\"},{\"name\":\"f2\",\"type\":\"int\"}]}",
            new object[] { "f1", true, "f2", 100 },
            "{\"type\":\"record\",\"name\":\"r\",\"fields\":" +
            "[{\"name\":\"f1\",\"type\":\"boolean\"}]}",
            new object[] { "f1", true }, Description = "Missing fields - 1")]
        [TestCase("{\"type\":\"record\",\"name\":\"r\",\"fields\":" +
            "[{\"name\":\"f1\",\"type\":\"null\"},{\"name\":\"f2\",\"type\":\"int\"}]}",
            new object[] { "f1", null, "f2", 100 },
            "{\"type\":\"record\",\"name\":\"r\",\"fields\":" +
            "[{\"name\":\"f2\",\"type\":\"int\"}]}",
            new object[] { "f2", 100 }, Description = "Missing fields - null")]
        [TestCase("{\"type\":\"record\",\"name\":\"r\",\"fields\":" +
            "[{\"name\":\"f1\",\"type\":\"boolean\"},{\"name\":\"f2\",\"type\":\"int\"}]}",
            new object[] { "f1", true, "f2", 100 },
            "{\"type\":\"record\",\"name\":\"r\",\"fields\":" +
            "[{\"name\":\"f2\",\"type\":\"int\"}]}",
            new object[] { "f2", 100 }, Description = "Missing fields - boolean")]
        [TestCase("{\"type\":\"record\",\"name\":\"r\",\"fields\":" +
            "[{\"name\":\"f1\",\"type\":\"int\"},{\"name\":\"f2\",\"type\":\"int\"}]}",
            new object[] { "f1", 1, "f2", 100 },
            "{\"type\":\"record\",\"name\":\"r\",\"fields\":" +
            "[{\"name\":\"f2\",\"type\":\"int\"}]}",
            new object[] { "f2", 100 }, Description = "Missing fields - int")]
        [TestCase("{\"type\":\"record\",\"name\":\"r\",\"fields\":" +
            "[{\"name\":\"f1\",\"type\":\"long\"},{\"name\":\"f2\",\"type\":\"int\"}]}",
            new object[] { "f1", 1L, "f2", 100 },
            "{\"type\":\"record\",\"name\":\"r\",\"fields\":" +
            "[{\"name\":\"f2\",\"type\":\"int\"}]}",
            new object[] { "f2", 100 }, Description = "Missing fields - long")]
        [TestCase("{\"type\":\"record\",\"name\":\"r\",\"fields\":" +
            "[{\"name\":\"f1\",\"type\":\"float\"},{\"name\":\"f2\",\"type\":\"int\"}]}",
            new object[] { "f1", 1.0f, "f2", 100 },
            "{\"type\":\"record\",\"name\":\"r\",\"fields\":" +
            "[{\"name\":\"f2\",\"type\":\"int\"}]}",
            new object[] { "f2", 100 }, Description = "Missing fields - float")]
        [TestCase("{\"type\":\"record\",\"name\":\"r\",\"fields\":" +
            "[{\"name\":\"f1\",\"type\":\"double\"},{\"name\":\"f2\",\"type\":\"int\"}]}",
            new object[] { "f1", 1.0, "f2", 100 },
            "{\"type\":\"record\",\"name\":\"r\",\"fields\":" +
            "[{\"name\":\"f2\",\"type\":\"int\"}]}",
            new object[] { "f2", 100 }, Description = "Missing fields - double")]
        [TestCase("{\"type\":\"record\",\"name\":\"r\",\"fields\":" +
            "[{\"name\":\"f1\",\"type\":\"bytes\"},{\"name\":\"f2\",\"type\":\"int\"}]}",
            new object[] { "f1", new byte[] { 1 , 0 }, "f2", 100 },
            "{\"type\":\"record\",\"name\":\"r\",\"fields\":" +
            "[{\"name\":\"f2\",\"type\":\"int\"}]}",
            new object[] { "f2", 100 }, Description = "Missing fields - bytes")]
        [TestCase("{\"type\":\"record\",\"name\":\"r\",\"fields\":" +
            "[{\"name\":\"f1\",\"type\":\"string\"},{\"name\":\"f2\",\"type\":\"int\"}]}",
            new object[] { "f1", "h", "f2", 100 },
            "{\"type\":\"record\",\"name\":\"r\",\"fields\":" +
            "[{\"name\":\"f2\",\"type\":\"int\"}]}",
            new object[] { "f2", 100 }, Description = "Missing fields - string")]
        [TestCase("{\"type\":\"record\",\"name\":\"r\",\"fields\":" +
            "[{\"name\":\"f1\",\"type\":{\"type\":\"array\",\"items\":\"int\"}},{\"name\":\"f2\",\"type\":\"int\"}]}",
            new object[] { "f1", new int[] { 100, 101 }, "f2", 100 },
            "{\"type\":\"record\",\"name\":\"r\",\"fields\":" +
            "[{\"name\":\"f2\",\"type\":\"int\"}]}",
            new object[] { "f2", 100 }, Description = "Missing fields - array")]
        [TestCase("{\"type\":\"record\",\"name\":\"r\",\"fields\":" +
            "[{\"name\":\"f1\",\"type\":[\"int\", \"null\"]},{\"name\":\"f2\",\"type\":\"int\"}]}",
            new object[] { "f1", 101, "f2", 100 },
            "{\"type\":\"record\",\"name\":\"r\",\"fields\":" +
            "[{\"name\":\"f2\",\"type\":\"int\"}]}",
            new object[] { "f2", 100 }, Description = "Missing fields - union")]
        // TODO: Missing fields - record, enum, map, fixed
        [TestCase("{\"type\":\"record\",\"name\":\"r\",\"fields\":" +
            "[{\"name\":\"f1\",\"type\":\"boolean\"}]}",
            new object[] { "f1", true },
            "{\"type\":\"record\",\"name\":\"r\",\"fields\":" +
            "[{\"name\":\"f1\",\"type\":\"boolean\"},{\"name\":\"f2\",\"type\":\"string\",\"default\":\"d\"}]}",
            new object[] { "f1", true, "f2", "d" }, Description = "Default field")]
        public void TestResolution_record(string ws, object[] actual, string rs, object[] expected)
        {
            TestResolution(ws, MkRecord(actual, Schema.Parse(ws) as RecordSchema), rs,
                MkRecord(expected, Schema.Parse(rs) as RecordSchema));
        }

        [TestCase("{\"type\":\"map\",\"values\":\"int\"}", new object[] { "a", 100, "b", -202 },
            "{\"type\":\"map\",\"values\":\"long\"}", new object[] { "a", 100L, "b", -202L })]
        public void TestResolution_intMapToLongMap(string ws, object[] value, string rs, object[] expected)
        {
            TestResolution(ws, mkMap(value), rs, mkMap(expected));
        }


        private static void testResolutionMismatch<T>(string writerSchema, T value, string readerSchema)
        {
            Stream ms;
            Schema ws;
            serialize(writerSchema, value, out ms, out ws);
            deserialize<object>(ms, ws, Schema.Parse(readerSchema));
        }

        [TestCase("boolean", true, "null", typeof(AvroException))]
        [TestCase("int", 10, "boolean", typeof(AvroException))]
        [TestCase("int", 10, "string", typeof(AvroException))]
        [TestCase("int", 10, "bytes", typeof(AvroException))]
        [TestCase("int", 10, "{\"type\":\"record\",\"name\":\"r\",\"fields\":[{\"name\":\"f\", \"type\":\"int\"}]}",
            typeof(AvroException))]
        [TestCase("int", 10, "{\"type\":\"enum\",\"name\":\"e\",\"symbols\":[\"s\", \"t\"]}", typeof(AvroException))]
        [TestCase("int", 10, "{\"type\":\"array\",\"items\":\"int\"}", typeof(AvroException))]
        [TestCase("int", 10, "{\"type\":\"map\",\"values\":\"int\"}", typeof(AvroException))]
        [TestCase("int", 10, "[\"string\", \"bytes\"]", typeof(AvroException))]
        [TestCase("int", 10, "{\"type\":\"fixed\",\"name\":\"f\",\"size\":2}", typeof(AvroException))]
        [TestCase("{\"type\":\"array\",\"items\":\"int\"}", new int[] { 10 },
            "\"boolean\"", typeof(AvroException))]
        [TestCase("{\"type\":\"array\",\"items\":\"int\"}", new int[] { 10 },
            "{\"type\":\"array\",\"items\":\"string\"}", typeof(AvroException))]
        [TestCase("[\"int\", \"boolean\"]", 10, "[\"string\", \"bytes\"]", typeof(AvroException))]
        [TestCase("[\"int\", \"boolean\"]", 10, "\"string\"", typeof(AvroException))]
        public void TestResolutionMismatch_simple(string writerSchema, object value, string readerSchema, Type expectedExceptionType = null)
        {
            if (expectedExceptionType != null)
            {
                Assert.Throws(expectedExceptionType, () => { testResolutionMismatch(writerSchema, value, readerSchema); });
            }
            else
            {
                testResolutionMismatch(writerSchema, value, readerSchema);
            }
        }

        [TestCase("{\"type\":\"record\",\"name\":\"r\",\"fields\":" +
            "[{\"name\":\"f1\",\"type\":[\"int\", \"null\"]},{\"name\":\"f2\",\"type\":\"int\"}]}",
            new object[] { "f1", 101, "f2", 100 }, "int",
            typeof(AvroException), Description = "Non-record schema")]
        [TestCase("{\"type\":\"record\",\"name\":\"r\",\"fields\":" +
            "[{\"name\":\"f1\",\"type\":[\"int\", \"null\"]},{\"name\":\"f2\",\"type\":\"int\"}]}",
            new object[] { "f1", 101, "f2", 100 },
            "{\"type\":\"record\",\"name\":\"s\",\"fields\":" +
            "[{\"name\":\"f2\",\"type\":\"int\"}]}",
            typeof(AvroException), Description = "Name mismatch")]
        [TestCase("{\"type\":\"record\",\"name\":\"r\",\"fields\":" +
            "[{\"name\":\"f1\",\"type\":[\"int\", \"null\"]},{\"name\":\"f2\",\"type\":\"int\"}]}",
            new object[] { "f1", 101, "f2", 100 },
            "{\"type\":\"record\",\"name\":\"r\",\"fields\":" +
            "[{\"name\":\"f2\",\"type\":\"string\"}]}",
            typeof(AvroException), Description = "incompatible field")]
        [TestCase("{\"type\":\"record\",\"name\":\"r\",\"fields\":" +
            "[{\"name\":\"f1\",\"type\":[\"int\", \"null\"]},{\"name\":\"f2\",\"type\":\"int\"}]}",
            new object[] { "f1", 101, "f2", 100 },
            "{\"type\":\"record\",\"name\":\"r\",\"fields\":" +
            "[{\"name\":\"f3\",\"type\":\"string\"}]}",
            typeof(AvroException), Description = "new field without default")]
        public void TestResolutionMismatch_record(string ws, object[] actual, string rs, Type expectedExceptionType = null)
        {
            if (expectedExceptionType != null)
            {
                Assert.Throws(expectedExceptionType, () => { testResolutionMismatch(ws, MkRecord(actual, Schema.Parse(ws) as RecordSchema), rs); });
            }
            else
            {
                testResolutionMismatch(ws, MkRecord(actual, Schema.Parse(ws) as RecordSchema), rs);
            }
        }

        [TestCase("{\"type\":\"enum\",\"name\":\"e\",\"symbols\":[\"s\", \"t\"]}", "s", "int",
            typeof(AvroException), Description = "Non-enum schema")]
        [TestCase("{\"type\":\"enum\",\"name\":\"e\",\"symbols\":[\"s\", \"t\"]}",
            "s", "{\"type\":\"enum\",\"name\":\"f\",\"symbols\":[\"s\", \"t\"]}",
            typeof(AvroException), Description = "Name mismatch")]
        [TestCase("{\"type\":\"enum\",\"name\":\"e\",\"symbols\":[\"s\", \"t\"]}",
            "s", "{\"type\":\"enum\",\"name\":\"f\",\"symbols\":[\"t\", \"u\"]}",
            typeof(AvroException), Description = "Incompatible symbols")]
        public void TestResolutionMismatch_enum(string ws, string value, string rs, Type expectedExceptionType = null)
        {
            if (expectedExceptionType != null)
            {
                Assert.Throws(expectedExceptionType, () => { testResolutionMismatch(ws, mkEnum(ws, value), rs); });
            }
            else
            {
                testResolutionMismatch(ws, mkEnum(ws, value), rs);
            }
        }

        [TestCase("{\"type\":\"map\",\"values\":\"int\"}", new object[] { "a", 0 }, "int",
            typeof(AvroException), Description = "Non-map schema")]
        [TestCase("{\"type\":\"map\",\"values\":\"int\"}",
            new object[] { "a", 0 }, "{\"type\":\"map\",\"values\":\"string\"}",
            typeof(AvroException), Description = "Name mismatch")]
        public void TestResolutionMismatch_map(string ws, object[] value, string rs, Type expectedExceptionType = null)
        {
            if (expectedExceptionType != null)
            {
                Assert.Throws(expectedExceptionType, () => { testResolutionMismatch(ws, mkMap(value), rs); });
            }
            else
            {
                testResolutionMismatch(ws, mkMap(value), rs);
            }
        }

        [TestCase("{\"type\":\"fixed\",\"name\":\"f\",\"size\":2}", new byte[] { 1, 1 }, "int",
            typeof(AvroException), Description = "Non-fixed schema")]
        [TestCase("{\"type\":\"fixed\",\"name\":\"f\",\"size\":2}",
            new byte[] { 1, 1 }, "{\"type\":\"fixed\",\"name\":\"g\",\"size\":2}",
            typeof(AvroException), Description = "Name mismatch")]
        [TestCase("{\"type\":\"fixed\",\"name\":\"f\",\"size\":2}",
            new byte[] { 1, 1 }, "{\"type\":\"fixed\",\"name\":\"f\",\"size\":1}",
            typeof(AvroException), Description = "Size mismatch")]
        public void TestResolutionMismatch_fixed(string ws, byte[] value, string rs, Type expectedExceptionType = null)
        {
            if (expectedExceptionType != null)
            {
                Assert.Throws(expectedExceptionType, () => { testResolutionMismatch(ws, mkFixed(ws, value), rs); });
            }
            else
            {
                 testResolutionMismatch(ws, mkFixed(ws, value), rs); 
            }
        }

        [Test]
        public void TestRecordEquality_arrayFieldnotEqual()
        {
            var schema = (RecordSchema)Schema.Parse(
                "{\"type\":\"record\",\"name\":\"r\",\"fields\":" +
                "[{\"name\":\"a\",\"type\":{\"type\":\"array\",\"items\":\"int\"}}]}");

            Func<int[], GenericRecord> makeRec = arr => MkRecord(new object[] { "a", arr }, schema);

            var rec1 = makeRec(new[] { 69, 23 });
            var rec2 = makeRec(new[] { 42, 11 });

            Assert.AreNotEqual(rec1, rec2);
        }

        [Test]
        public void TestRecordEquality_arrayFieldequal()
        {
            var schema = (RecordSchema)Schema.Parse(
                "{\"type\":\"record\",\"name\":\"r\",\"fields\":" +
                "[{\"name\":\"a\",\"type\":{\"type\":\"array\",\"items\":\"int\"}}]}");

            Func<int[], GenericRecord> makeRec = arr => MkRecord(new object[] { "a", arr }, schema);

            // Intentionally duplicated so reference equality doesn't apply
            var rec1 = makeRec(new[] { 89, 12, 66 });
            var rec2 = makeRec(new[] { 89, 12, 66 });

            Assert.AreEqual(rec1, rec2);
        }

        [Test]
        public void TestRecordEquality_mapFieldequal()
        {
            var schema = (RecordSchema)Schema.Parse(
                "{\"type\":\"record\",\"name\":\"r\",\"fields\":" +
                "[{\"name\":\"a\",\"type\":{\"type\":\"map\",\"values\":\"int\"}}]}");

            Func<int, GenericRecord> makeRec = value => MkRecord(
                new object[] { "a", new Dictionary<string, int> { { "key", value } } }, schema);

            var rec1 = makeRec(52);
            var rec2 = makeRec(52);

            Assert.AreEqual(rec1, rec2);
        }

        [Test]
        public void TestRecordEquality_mapFieldnotEqual()
        {
            var schema = (RecordSchema)Schema.Parse(
                "{\"type\":\"record\",\"name\":\"r\",\"fields\":" +
                "[{\"name\":\"a\",\"type\":{\"type\":\"map\",\"values\":\"int\"}}]}");

            Func<int, GenericRecord> makeRec = value => MkRecord(
                new object[] { "a", new Dictionary<string, int> { { "key", value } } }, schema);

            var rec1 = makeRec(69);
            var rec2 = makeRec(98);

            Assert.AreNotEqual(rec1, rec2);
        }

        public static GenericRecord MkRecord(object[] kv, RecordSchema s)
        {
            GenericRecord input = new GenericRecord(s);
            for (int i = 0; i < kv.Length; i += 2)
            {
                string fieldName = (string)kv[i];
                object fieldValue = kv[i + 1];
                Schema inner = s[fieldName].Schema;
                if (inner is EnumSchema)
                {
                    GenericEnum ge = new GenericEnum(inner as EnumSchema, (string)fieldValue);
                    fieldValue = ge;
                }
                else if (inner is FixedSchema)
                {
                    GenericFixed gf = new GenericFixed(inner as FixedSchema);
                    gf.Value = (byte[])fieldValue;
                    fieldValue = gf;
                }
                input.Add(s[fieldName].Pos, fieldValue);
            }
            return input;
        }

        private static IDictionary<string, object> mkMap(object[] vv)
        {
            IDictionary<string, object> d = new Dictionary<string, object>();
            for (int j = 0; j < vv.Length; j += 2)
            {
                d[(string)vv[j]] = vv[j + 1];
            }
            return d;
        }

        private static object mkEnum(string enumSchema, string value)
        {
            return new GenericEnum(Schema.Parse(enumSchema) as EnumSchema, value);
        }

        private static object mkFixed(string fixedSchema, byte[] value)
        {
            return new GenericFixed(Schema.Parse(fixedSchema) as FixedSchema, value);
        }

        private static S deserialize<S>(Stream ms, Schema ws, Schema rs)
        {
            long initialPos = ms.Position;
            GenericReader<S> r = new GenericReader<S>(ws, rs);
            Decoder d = new BinaryDecoder(ms);
            var items = new List<S>();
            // validate reading twice to make sure there isn't some state that isn't reset between reads.
            items.Add( Read( r, d ) );
            items.Add( Read( r, d ) );
            Assert.AreEqual(ms.Length, ms.Position); // Ensure we have read everything.
            checkAlternateDeserializers(items, ms, initialPos, ws, rs);
            return items[0];
        }

        private static S Read<S>( DatumReader<S> reader, Decoder d )
        {
            S reuse = default( S );
            return reader.Read( reuse, d );
        }

        private static void checkAlternateDeserializers<S>(IEnumerable<S> expectations, Stream input, long startPos, Schema ws, Schema rs)
        {
            input.Position = startPos;
            var reader = new GenericDatumReader<S>(ws, rs);
            Decoder d = new BinaryDecoder(input);
            foreach( var expected in expectations )
            {
                var read = Read( reader, d );
                Assert.AreEqual(expected, read);
            }
            Assert.AreEqual(input.Length, input.Position); // Ensure we have read everything.
        }

        private static void serialize<T>(string writerSchema, T actual, out Stream stream, out Schema ws)
        {
            var ms = new MemoryStream();
            Encoder e = new BinaryEncoder(ms);
            ws = Schema.Parse(writerSchema);
            GenericWriter<T> w = new GenericWriter<T>(ws);
            // write twice so we can validate reading twice
            w.Write(actual, e);
            w.Write(actual, e);
            ms.Flush();
            ms.Position = 0;
            checkAlternateSerializers(ms.ToArray(), actual, ws);
            stream = ms;
        }

        private static void checkAlternateSerializers<T>(byte[] expected, T value, Schema ws)
        {
            var ms = new MemoryStream();
            var writer = new GenericDatumWriter<T>(ws);
            var e = new BinaryEncoder(ms);
            writer.Write(value, e);
            writer.Write(value, e);
            var output = ms.ToArray();

            Assert.AreEqual(expected.Length, output.Length);
            Assert.True(expected.SequenceEqual(output));
        }
    }
}
