﻿using System;
using System.Collections.Generic;
using System.Globalization;
using System.IO;

namespace Oni.Dae.IO
{
    internal class ObjReader
    {
        private struct ObjVertex : IEquatable<ObjVertex>
        {
            public int PointIndex;
            public int TexCoordIndex;
            public int NormalIndex;

            public ObjVertex(int pointIndex, int uvIndex, int normalIndex)
            {
                PointIndex = pointIndex;
                TexCoordIndex = uvIndex;
                NormalIndex = normalIndex;
            }

            public static bool operator ==(ObjVertex v1, ObjVertex v2) =>
                    v1.PointIndex == v2.PointIndex
                && v1.TexCoordIndex == v2.TexCoordIndex
                && v1.NormalIndex == v2.NormalIndex;

            public static bool operator !=(ObjVertex v1, ObjVertex v2) =>
                    v1.PointIndex != v2.PointIndex
                || v1.TexCoordIndex != v2.TexCoordIndex
                || v1.NormalIndex != v2.NormalIndex;

            public bool Equals(ObjVertex v) => this == v;
            public override bool Equals(object obj) => obj is ObjVertex && Equals((ObjVertex)obj);
            public override int GetHashCode() => PointIndex ^ TexCoordIndex ^ NormalIndex;
        }

        private class ObjFace
        {
            public string ObjectName;
            public string[] GroupsNames;
            public ObjVertex[] Vertices;
        }

        private class ObjMaterial
        {
            private readonly string name;
            private string textureFilePath;
            private Material material;

            public ObjMaterial(string name)
            {
                this.name = name;
            }

            public string Name => name;

            public string TextureFilePath
            {
                get { return textureFilePath; }
                set { textureFilePath = value; }
            }

            public Material Material
            {
                get
                {
                    if (material == null && TextureFilePath != null)
                        CreateMaterial();

                    return material;
                }
            }

            private void CreateMaterial()
            {
                var image = new Image
                {
                    FilePath = TextureFilePath,
                    Name = name + "_img"
                };

                var effectSurface = new EffectSurface(image);
                var effectSampler = new EffectSampler(effectSurface);
                var effectTexture = new EffectTexture
                {
                    Sampler = effectSampler,
                    Channel = EffectTextureChannel.Diffuse,
                    TexCoordSemantic = "diffuse_TEXCOORD"
                };

                material = new Material
                {
                    Id = name,
                    Name = name,
                    Effect = new Effect
                    {
                        Id = name + "_fx",
                        DiffuseValue = effectTexture,
                        Parameters = {
                            new EffectParameter("surface", effectSurface),
                            new EffectParameter("sampler", effectSampler)
                        }
                    }
                };
            }
        }

        private class ObjPrimitives
        {
            public ObjMaterial Material;
            public readonly List<ObjFace> Faces = new List<ObjFace>(4);
        }

        #region Private data
        private static readonly string[] emptyStrings = new string[0];
        private static readonly char[] whiteSpaceChars = new char[] { ' ', '\t' };
        private static readonly char[] vertexSeparator = new char[] { '/' };

        private Scene mainScene;

        private readonly List<Vector3> points = new List<Vector3>();
        private readonly List<Vector2> texCoords = new List<Vector2>();
        private readonly List<Vector3> normals = new List<Vector3>();

        private int pointCount;
        private int normalCount;
        private int texCoordCount;

        private readonly Dictionary<Vector3, int> pointIndex = new Dictionary<Vector3, int>();
        private readonly Dictionary<Vector3, int> normalIndex = new Dictionary<Vector3, int>();
        private readonly Dictionary<Vector2, int> texCoordIndex = new Dictionary<Vector2, int>();
        private readonly List<int> pointRemap = new List<int>();
        private readonly List<int> normalRemap = new List<int>();
        private readonly List<int> texCoordRemap = new List<int>();

        private readonly Dictionary<string, ObjMaterial> materials = new Dictionary<string, ObjMaterial>(StringComparer.Ordinal);

        private string currentObjectName;
        private string[] currentGroupNames;
        private readonly List<ObjPrimitives> primitives = new List<ObjPrimitives>();
        private ObjPrimitives currentPrimitives;
        #endregion

        public static Scene ReadFile(string filePath)
        {
            var reader = new ObjReader();
            reader.ReadObjFile(filePath);
            reader.ImportObjects();
            return reader.mainScene;
        }

        private void ReadObjFile(string filePath)
        {
            mainScene = new Scene();

            foreach (string line in ReadLines(filePath))
            {
                var tokens = line.Split(whiteSpaceChars, StringSplitOptions.RemoveEmptyEntries);

                switch (tokens[0])
                {
                    case "o":
                        ReadObject(tokens);
                        break;

                    case "g":
                        ReadGroup(tokens);
                        break;

                    case "v":
                        ReadPoint(tokens);
                        break;

                    case "vn":
                        ReadNormal(tokens);
                        break;

                    case "vt":
                        ReadTexCoord(tokens);
                        break;

                    case "f":
                    case "fo":
                        ReadFace(tokens);
                        break;

                    case "mtllib":
                        ReadMtlLib(filePath, tokens);
                        break;

                    case "usemtl":
                        ReadUseMtl(tokens);
                        break;
                }
            }
        }

        private void ReadPoint(string[] tokens)
        {
            var point = new Vector3(
                float.Parse(tokens[1], CultureInfo.InvariantCulture),
                float.Parse(tokens[2], CultureInfo.InvariantCulture),
                float.Parse(tokens[3], CultureInfo.InvariantCulture));

            AddPoint(point);

            pointCount++;
        }

        private void AddPoint(Vector3 point)
        {
            int newIndex;

            if (pointIndex.TryGetValue(point, out newIndex))
            {
                pointRemap.Add(newIndex);
            }
            else
            {
                pointRemap.Add(points.Count);
                pointIndex.Add(point, points.Count);
                points.Add(point);
            }
        }

        private void ReadNormal(string[] tokens)
        {
            var normal = new Vector3(
                float.Parse(tokens[1], CultureInfo.InvariantCulture),
                float.Parse(tokens[2], CultureInfo.InvariantCulture),
                float.Parse(tokens[3], CultureInfo.InvariantCulture));

            AddNormal(normal);

            normalCount++;
        }

        private void AddNormal(Vector3 normal)
        {
            int newIndex;

            if (normalIndex.TryGetValue(normal, out newIndex))
            {
                normalRemap.Add(newIndex);
            }
            else
            {
                normalRemap.Add(normals.Count);
                normalIndex.Add(normal, normals.Count);
                normals.Add(normal);
            }
        }

        private void ReadTexCoord(string[] tokens)
        {
            var texCoord = new Vector2(
                float.Parse(tokens[1], CultureInfo.InvariantCulture),
                1.0f - float.Parse(tokens[2], CultureInfo.InvariantCulture));

            AddTexCoord(texCoord);

            texCoordCount++;
        }

        private void AddTexCoord(Vector2 texCoord)
        {
            int newIndex;

            if (texCoordIndex.TryGetValue(texCoord, out newIndex))
            {
                texCoordRemap.Add(newIndex);
            }
            else
            {
                texCoordRemap.Add(texCoords.Count);
                texCoordIndex.Add(texCoord, texCoords.Count);
                texCoords.Add(texCoord);
            }
        }

        private void ReadFace(string[] tokens)
        {
            var faceVertices = ReadVertices(tokens);

            if (currentPrimitives == null)
                ReadUseMtl(emptyStrings);

            currentPrimitives.Faces.Add(new ObjFace
            {
                ObjectName = currentObjectName,
                GroupsNames = currentGroupNames,
                Vertices = faceVertices
            });
        }

        private ObjVertex[] ReadVertices(string[] tokens)
        {
            var vertices = new ObjVertex[tokens.Length - 1];

            for (int i = 0; i < vertices.Length; i++)
            {
                //
                // Read a point/texture/normal index pair
                //

                var indices = tokens[i + 1].Split(vertexSeparator);

                if (indices.Length == 0 || indices.Length > 3)
                    throw new InvalidDataException();

                //
                // Extract indices from file: 0 means "not specified"
                //

                int pointIndex = int.Parse(indices[0], CultureInfo.InvariantCulture);
                int texCoordIndex = (indices.Length > 1 && indices[1].Length > 0) ? int.Parse(indices[1], CultureInfo.InvariantCulture) : 0;
                int normalIndex = (indices.Length > 2 && indices[2].Length > 0) ? int.Parse(indices[2], CultureInfo.InvariantCulture) : 0;

                //
                // Adjust for negative indices
                //

                if (pointIndex < 0)
                    pointIndex = pointCount + pointIndex + 1;

                if (texCoordIndex < 0)
                    texCoordIndex = texCoordCount + texCoordIndex + 1;

                if (normalIndex < 0)
                    normalIndex = normalCount + normalIndex + 1;

                //
                // Convert indices to internal representation: range 0..n and -1 means "not specified".
                //

                pointIndex = pointIndex - 1;
                texCoordIndex = texCoordIndex - 1;
                normalIndex = normalIndex - 1;

                //
                // Remap indices
                //

                pointIndex = pointRemap[pointIndex];

                if (texCoordIndex < 0 || texCoordRemap.Count <= texCoordIndex)
                    texCoordIndex = -1;
                else
                    texCoordIndex = texCoordRemap[texCoordIndex];

                if (normalIndex < 0 || normalRemap.Count <= normalIndex)
                    normalIndex = -1;
                else
                    normalIndex = normalRemap[normalIndex];

                vertices[i] = new ObjVertex
                {
                    PointIndex = pointIndex,
                    TexCoordIndex = texCoordIndex,
                    NormalIndex = normalIndex
                };
            }

            return vertices;
        }

        private void ReadObject(string[] tokens)
        {
            currentObjectName = tokens[1];
        }

        private void ReadGroup(string[] tokens)
        {
            currentGroupNames = tokens;
        }

        private void ReadUseMtl(string[] tokens)
        {
            currentPrimitives = new ObjPrimitives();

            if (tokens.Length > 0)
                materials.TryGetValue(tokens[1], out currentPrimitives.Material);

            primitives.Add(currentPrimitives);
        }

        private void ReadMtlLib(string objFilePath, string[] tokens)
        {
            string materialLibraryFilePath = tokens[1];

            if (Path.GetExtension(materialLibraryFilePath).Length == 0)
                materialLibraryFilePath += ".mtl";

            var dirPath = Path.GetDirectoryName(objFilePath);
            var mtlFilePath = Path.Combine(dirPath, materialLibraryFilePath);

            if (!File.Exists(mtlFilePath))
            {
                Console.Error.WriteLine("Material file {0} does not exist", mtlFilePath);
                return;
            }

            ReadMtlFile(mtlFilePath);
        }

        private void ReadMtlFile(string filePath)
        {
            var dirPath = Path.GetDirectoryName(filePath);

            ObjMaterial currentMaterial = null;

            foreach (string line in ReadLines(filePath))
            {
                var tokens = line.Split(whiteSpaceChars, StringSplitOptions.RemoveEmptyEntries);

                switch (tokens[0])
                {
                    case "newmtl":
                        currentMaterial = new ObjMaterial(tokens[1]);
                        materials[currentMaterial.Name] = currentMaterial;
                        break;

                    case "map_Kd":
                        string textureFilePath = Path.GetFullPath(Path.Combine(dirPath, tokens[1]));

                        if (File.Exists(textureFilePath))
                            currentMaterial.TextureFilePath = textureFilePath;

                        break;
                }
            }
        }

        private void ImportObjects()
        {
            var inputs = new List<IndexedInput>();
            IndexedInput positionInput, texCoordInput, normalInput;

            positionInput = new IndexedInput(Semantic.Position, new Source(points));
            inputs.Add(positionInput);

            if (texCoords.Count > 0)
            {
                texCoordInput = new IndexedInput(Semantic.TexCoord, new Source(texCoords));
                inputs.Add(texCoordInput);
            }
            else
            {
                texCoordInput = null;
            }

            if (normals.Count > 0)
            {
                normalInput = new IndexedInput(Semantic.Normal, new Source(normals));
                inputs.Add(normalInput);
            }
            else
            {
                normalInput = null;
            }

            var geometry = new Geometry
            {
                Vertices = { positionInput }
            };

            var geometryInstance = new GeometryInstance
            {
                Target = geometry
            };

            foreach (var primitive in primitives.Where(p => p.Faces.Count > 0))
            {
                var meshPrimtives = new MeshPrimitives(MeshPrimitiveType.Polygons, inputs);

                foreach (var face in primitive.Faces)
                {
                    meshPrimtives.VertexCounts.Add(face.Vertices.Length);

                    foreach (var vertex in face.Vertices)
                    {
                        positionInput.Indices.Add(vertex.PointIndex);

                        if (texCoordInput != null)
                            texCoordInput.Indices.Add(vertex.TexCoordIndex);

                        if (normalInput != null)
                            normalInput.Indices.Add(vertex.NormalIndex);
                    }
                }

                geometry.Primitives.Add(meshPrimtives);

                if (primitive.Material != null && primitive.Material.Material != null)
                {
                    meshPrimtives.MaterialSymbol = "mat" + geometryInstance.Materials.Count;

                    geometryInstance.Materials.Add(new MaterialInstance
                    {
                        Symbol = meshPrimtives.MaterialSymbol,
                        Target = primitive.Material.Material,
                        Bindings = { new MaterialBinding("diffuse_TEXCOORD", texCoordInput) }
                    });
                }
            }

            mainScene.Nodes.Add(new Node
            {
                Instances = { geometryInstance }
            });
        }

        private Vector3 ComputeFaceNormal(ObjVertex[] vertices)
        {
            if (vertices.Length < 3)
                return Vector3.Up;

            var v1 = points[vertices[0].PointIndex];
            var v2 = points[vertices[1].PointIndex];
            var v3 = points[vertices[2].PointIndex];

            return Vector3.Normalize(Vector3.Cross(v2 - v1, v3 - v1));
        }

        private static IEnumerable<string> ReadLines(string filePath)
        {
            using (var reader = File.OpenText(filePath))
            {
                for (var line = reader.ReadLine(); line != null; line = reader.ReadLine())
                {
                    line = line.Trim();

                    if (line.Length == 0)
                        continue;

                    int commentStart = line.IndexOf('#');

                    if (commentStart != -1)
                    {
                        line = line.Substring(0, commentStart).Trim();

                        if (line.Length == 0)
                            continue;
                    }

                    yield return line;
                }
            }
        }
    }
}
