using System;
using System.Collections.Generic;
using System.Globalization;
using System.Linq;
using LipingShare.LCLib.Asn1Processor;
using System.Text;
using System.Threading;

namespace UnityEngine.Purchasing.Security
{
    /// <summary>
    /// This class will validate the Apple receipt is signed with the correct certificate.
    /// </summary>
    public class AppleValidator
    {
        private X509Cert cert;
        private AppleReceiptParser parser = new AppleReceiptParser ();

        /// <summary>
        /// Constructs an instance with Apple Certificate.
        /// </summary>
        /// <param name="appleRootCertificate">The apple certificate.</param>
        public AppleValidator (byte[] appleRootCertificate)
        {
            cert = new X509Cert(appleRootCertificate);
        }

        /// <summary>
        /// Validate that the Apple receipt is signed correctly.
        /// </summary>
        /// <param name="receiptData">The Apple receipt to validate.</param>
        /// <returns>The parsed AppleReceipt</returns>
        /// <exception cref="InvalidSignatureException">The exception thrown if the receipt is incorrectly signed.</exception>
        public AppleReceipt Validate (byte [] receiptData)
        {
            PKCS7 receipt;
            var result = parser.Parse (receiptData, out receipt);
            if (!receipt.Verify (cert, result.receiptCreationDate)) {
                throw new InvalidSignatureException ();
            }
            return result;
        }
    }

    /// <summary>
    /// This class with parse the Apple receipt data received in byte[] into a AppleReceipt object
    /// </summary>
    public class AppleReceiptParser
    {
        // Cache the AppleReceipt object, PKCS7, and raw data for the most recently parsed data.
        private static Dictionary<string, object> _mostRecentReceiptData = new Dictionary<string, object>();
        private const string k_AppleReceiptKey = "k_AppleReceiptKey";
        private const string k_PKCS7Key = "k_PKCS7Key";
        private const string k_ReceiptBytesKey = "k_ReceiptBytesKey";

        /// <summary>
        /// Parse the Apple receipt data into a AppleReceipt object
        /// </summary>
        /// <param name="receiptData">Apple receipt data</param>
        /// <returns>The converted AppleReceipt object from the Apple receipt data</returns>
        public AppleReceipt Parse (byte [] receiptData)
        {
            return Parse(receiptData, out _);
        }

        internal AppleReceipt Parse (byte [] receiptData, out PKCS7 receipt)
        {
            // Avoid Culture-sensitive parsing for the duration of this method
            CultureInfo originalCulture = PushInvariantCultureOnThread();

            try
            {
                // Check to see if this receipt has been parsed before.
                // If so, return the most recent AppleReceipt and PKCS7; do not parse it again.
                if (_mostRecentReceiptData.ContainsKey(k_AppleReceiptKey) &&
                    _mostRecentReceiptData.ContainsKey(k_PKCS7Key) &&
                    _mostRecentReceiptData.ContainsKey(k_ReceiptBytesKey) &&
                    ArrayEquals<byte>(receiptData, (byte[])_mostRecentReceiptData[k_ReceiptBytesKey])) {
                    receipt = (PKCS7)_mostRecentReceiptData[k_PKCS7Key];
                    return (AppleReceipt)_mostRecentReceiptData[k_AppleReceiptKey];
                }

                using (var stm = new System.IO.MemoryStream (receiptData)) {
                    Asn1Parser parser = new Asn1Parser ();
                    parser.LoadData (stm);
                    receipt = new PKCS7 (parser.RootNode);
                    var result = ParseReceipt (receipt.data);

                    // Cache the receipt info
                    _mostRecentReceiptData[k_AppleReceiptKey] = result;
                    _mostRecentReceiptData[k_PKCS7Key] = receipt;
                    _mostRecentReceiptData[k_ReceiptBytesKey] = receiptData;
                    return result;
                }
            }
            finally
            {
                PopCultureOffThread(originalCulture);
            }
        }

        /// <summary>
        /// Use InvariantCulture on this thread to avoid provoking Culture-sensitive reactions.
        /// E.g. when using DateTime.Parse we might load the host's current Culture, and that may
        /// have been stripped, and so this non-default culture would cause a crash.
        /// (*) NOTE Culture stripping for IL2CPP will be reduced in future Unitys in 2021
        /// (unity/il2cpp@5d3712f).
        /// </summary>
        /// <returns></returns>
        private static CultureInfo PushInvariantCultureOnThread()
        {
            var originalCulture = Thread.CurrentThread.CurrentCulture;

            Thread.CurrentThread.CurrentCulture = CultureInfo.InvariantCulture;

            return originalCulture;
        }

        /// <summary>
        /// Restores the original culture to this thread.
        /// </summary>
        /// <param name="originalCulture"></param>
        private static void PopCultureOffThread(CultureInfo originalCulture)
        {
            // Undo our parser Culture-preparations, safely
            Thread.CurrentThread.CurrentCulture = originalCulture;
        }

        private AppleReceipt ParseReceipt (Asn1Node data)
        {
            if (data == null || data.ChildNodeCount != 1) {
                throw new InvalidPKCS7Data ();
            }

            Asn1Node set = data.GetChildNode (0);
            var result = new AppleReceipt ();
            var inApps = new List<AppleInAppPurchaseReceipt> ();

            for (int t = 0; t < set.ChildNodeCount; t++) {
                var node = set.GetChildNode (t);
                // Each node should contain three children.

                if (node.ChildNodeCount == 3) {
                    var type = Asn1Util.BytesToLong (node.GetChildNode (0).Data);
                    var value = node.GetChildNode (2);
                    // See https://developer.apple.com/library/ios/releasenotes/General/ValidateAppStoreReceipt/Chapters/ReceiptFields.html#//apple_ref/doc/uid/TP40010573-CH106-SW1
                    switch (type) {
                    case 2:
                        result.bundleID = Encoding.UTF8.GetString (value.GetChildNode (0).Data);
                        break;
                    case 3:
                        result.appVersion = Encoding.UTF8.GetString (value.GetChildNode (0).Data);
                        break;
                    case 4:
                        result.opaque = value.Data;
                        break;
                    case 5:
                        result.hash = value.Data;
                        break;
                    case 12:
                        var dateString = Encoding.UTF8.GetString (value.GetChildNode (0).Data);
                        result.receiptCreationDate = DateTime.Parse (dateString).ToUniversalTime ();
                        break;
                    case 17:
                        inApps.Add (ParseInAppReceipt (value.GetChildNode (0)));
                        break;
                    case 19:
                        result.originalApplicationVersion = Encoding.UTF8.GetString (value.GetChildNode (0).Data);
                        break;
                    }
                }
            }

            result.inAppPurchaseReceipts = inApps.ToArray ();
            return result;
        }

        private AppleInAppPurchaseReceipt ParseInAppReceipt (Asn1Node inApp)
        {
            var result = new AppleInAppPurchaseReceipt ();
            for (int t = 0; t < inApp.ChildNodeCount; t++) {
                var node = inApp.GetChildNode (t);
                if (node.ChildNodeCount == 3) {
                    var type = Asn1Util.BytesToLong (node.GetChildNode (0).Data);
                    var value = node.GetChildNode (2);
                    switch (type) {
                    case 1701:
                        result.quantity = (int)Asn1Util.BytesToLong (value.GetChildNode (0).Data);
                        break;
                    case 1702:
                        result.productID = Encoding.UTF8.GetString (value.GetChildNode (0).Data);
                        break;
                    case 1703:
                        result.transactionID = Encoding.UTF8.GetString (value.GetChildNode (0).Data);
                        break;
                    case 1705:
                        result.originalTransactionIdentifier = Encoding.UTF8.GetString (value.GetChildNode (0).Data);
                        break;
                    case 1704:
                        result.purchaseDate = TryParseDateTimeNode (value);
                        break;
                    case 1706:
                        result.originalPurchaseDate = TryParseDateTimeNode (value);
                        break;
                    case 1708:
                        result.subscriptionExpirationDate = TryParseDateTimeNode (value);
                        break;
                    case 1712:
                        result.cancellationDate = TryParseDateTimeNode (value);
                        break;

                    case 1707:
                        // looks like possibly a type?
                        result.productType = (int)Asn1Util.BytesToLong (value.GetChildNode (0).Data);
                        break;

                    case 1713:
                        // looks like possibly is_trial?
                        result.isFreeTrial = (int)Asn1Util.BytesToLong (value.GetChildNode (0).Data);
                        break;

                    case 1719:
                        result.isIntroductoryPricePeriod = (int)Asn1Util.BytesToLong (value.GetChildNode (0).Data);
                        break;

                    default:
                        break;


                    }

                }
            }
            return result;
        }

        /// <summary>
        /// Try and parse a DateTime, returning the minimum DateTime on failure.
        /// </summary>
        private static DateTime TryParseDateTimeNode (Asn1Node node)
        {
            var dateString = Encoding.UTF8.GetString (node.GetChildNode (0).Data);
            if (!string.IsNullOrEmpty (dateString)) {
                return DateTime.Parse (dateString).ToUniversalTime ();
            }

            return DateTime.MinValue;
        }

        /// <summary>
        /// Indicates whether both arrays are the same or contains the same information.
        ///
        /// This method is used to validate if the receipts are different.
        /// </summary>
        /// <param name="a">First object to validate against second object.</param>
        /// <param name="b">Second object to validate against first object.</param>
        /// <typeparam name="T">Type of object to check.</typeparam>
        /// <returns>Returns true if they are the same length and contain the same information or else returns false.</returns>
        public static bool ArrayEquals<T>(T[] a, T[] b) where T: IEquatable<T> {
            if (a.Length != b.Length) {
                return false;
            }
            for (int i = 0; i < a.Length; i++)
            {
                if (!a[i].Equals(b[i])) {
                    return false;
                }
            }
            return true;
        }
    }
}
