//
//  MandiriEmoneyApi.swift
//  Emoney Info
//
//  Created by Wira Irawan on 27/07/24.
//

import Foundation
import CoreNFC

public class MandiriEmoneyApi : UnifiedNfcApi {
    var emoney : Emoney = Emoney()
    var riwayatList: [RiwayatCard] = []
    var cardType : Int?
    var mapv1 : String = ""
    var start = 0
    var finish = 256
    var finish2 = 10
    private var historyRetryCount = 0
    private let useMandiriSpecHistoryReader = true
    private let mandiriSpecMaxPage = 4
    private let mandiriSpecEmptyPageStopThreshold = 2
    
    public override init() {}

    private func logMandiri(_ message: String) {
        _ = message
        // print("[Mandiri] \(message)")
        // debugLog("[Mandiri] \(message)")
    }

    private func logMandiriApdu(_ label: String, command: NFCISO7816APDU) {
        logMandiri("APDU -> \(label) | \(command.toHexString())")
    }

    private func logMandiriResponse(_ label: String, response: ApduResponse) {
        logMandiri(
            String(
                format: "APDU <- %@ | sw1=%02X sw2=%02X data=%@",
                label,
                response.sw1 ?? 0,
                response.sw2 ?? 0,
                response.getData().hexEncodedString()
            )
        )
    }
    
    public func getCardNumber(){
        logMandiriApdu("MANDIRI_APDU01", command: EmoneyApduCommands.MANDIRI_APDU01)
        apduRunner.exchangeApdu(apduCommand: EmoneyApduCommands.MANDIRI_APDU01, completionHandler: {response in
            self.logMandiriResponse("MANDIRI_APDU01", response: response)
            if (response.sw1 == 0x90 && response.sw2 == 0x00){
                let raw = response.getData().hexEncodedString()
                self.emoney.setCardLabel("Mandiri e-Money")
                self.emoney.setCardNumber(raw.subString(from: 0, to: 16))
                self.cardType = raw.subString(from: 36, to: 38).hex2decimal()
                self.logMandiri("Card info raw=\(raw)")
                self.logMandiri("Card parsed | number=\(self.emoney.getCardNumber()) | cardType=\(self.cardType ?? -1)")
                self.getBalance()
            } else {
                self.logMandiri("Card info failed | status=\(response.sw1)-\(response.sw2)")
                self.apduRunner.invalidateSession(msg: "readFailed".localizeString(string: self.langCode!))
            }
        })
    }
    
    private func getBalance(){
        logMandiriApdu("MANDIRI_APDU02", command: EmoneyApduCommands.MANDIRI_APDU02)
        apduRunner.exchangeApdu(apduCommand: EmoneyApduCommands.MANDIRI_APDU02, completionHandler: {response in
            self.logMandiriResponse("MANDIRI_APDU02", response: response)
            if (response.sw1 == 0x90 && response.sw2 == 0x00){
                let raw = response.getData().hexEncodedString()
                let balance = raw.subString(from: 0, to: 8)
                self.emoney.setBalance(self.getRealBalance(reverseHexa: balance))
                self.logMandiri("Balance raw=\(raw)")
                self.logMandiri("Balance parsed=\(self.emoney.getBalance())")
                self.resetHistoryState()
                self.historyRetryCount = 0
                self.startHistoryRead()
            } else {
                self.logMandiri("Balance read failed | status=\(response.sw1)-\(response.sw2)")
                self.apduRunner.invalidateSession(msg: "readFailed".localizeString(string: self.langCode!))
            }
        })
    }

    private func startHistoryRead() {
        resetHistoryState()
        logMandiri("Start history read | retry=\(historyRetryCount) | cardType=\(cardType ?? -1) | useSpec=\(useMandiriSpecHistoryReader)")
        if useMandiriSpecHistoryReader {
            startMandiriSpecHistoryRead()
            return
        }
        if (self.cardType == 131){
            self.getLogStep01(index: self.start)
        } else {
            self.getLogStep02(index: self.start)
        }
    }

    private func retryHistoryRead(reason: String, retryable: Bool = true) {
        guard retryable else {
            logMandiri("History read not retryable | reason=\(reason)")
            finalizeHistoryResult()
            return
        }
        if historyRetryCount < 1 {
            historyRetryCount += 1
            logMandiri("Retrying history read | reason=\(reason) | retry=\(historyRetryCount)")
            startHistoryRead()
        } else {
            logMandiri("Retry exhausted | reason=\(reason)")
            finalizeHistoryResult()
        }
    }

    private func finalizeHistoryResult() {
        self.start = 0
        self.finalizeHistory()
        self.emoney.setRiwayatList(self.riwayatList)
        self.emoney.setTampilRiwayat(!self.riwayatList.isEmpty)
        self.logMandiri("Finalize history | count=\(self.riwayatList.count)")
        self.updateScreen()
        self.apduRunner.sessionEx?.alertMessage = "readFinish".localizeString(string: self.langCode!)
        self.apduRunner.invalidateSession()
    }

    // MARK: - New Mandiri Spec Reader
    // Toggle `useMandiriSpecHistoryReader` to rollback to the legacy implementation.

    private func startMandiriSpecHistoryRead() {
        logMandiri("Start spec history read | pages=1...\(mandiriSpecMaxPage)")
        readMandiriSpecPage(page: 1, collectedChunks: [], emptyPageStreak: 0)
    }

    private func readMandiriSpecPage(page: Int, collectedChunks: [String], emptyPageStreak: Int) {
        guard page <= mandiriSpecMaxPage else {
            probeMandiriSpecStop(collectedChunks: collectedChunks)
            return
        }

        let command = NFCISO7816APDU(
            instructionClass: 0x00,
            instructionCode: 0xD1,
            p1Parameter: UInt8(page & 0xFF),
            p2Parameter: 0x00,
            data: Data(),
            expectedResponseLength: CommonConstants.LE_GET_ALL_RESPONSE_DATA
        )
        logMandiriApdu("MANDIRI_SPEC_PAGE[\(page)]", command: command)
        apduRunner.exchangeApdu(apduCommand: command, completionHandler: { response in
            self.logMandiriResponse("MANDIRI_SPEC_PAGE[\(page)]", response: response)
            if response.sw1 == 0x90 && response.sw2 == 0x00 {
                let chunk = response.getData().hexEncodedString()
                let isEmptyPage = self.isMandiriSpecPageEmpty(chunk)
                self.logMandiri("Spec page chunk | page=\(page) | rawLength=\(chunk.count) | empty=\(isEmptyPage)")
                var nextChunks = collectedChunks
                nextChunks.append(chunk)
                let nextEmptyPageStreak = isEmptyPage ? (emptyPageStreak + 1) : 0
                if nextEmptyPageStreak >= self.mandiriSpecEmptyPageStopThreshold {
                    self.logMandiri("Spec page empty streak reached threshold | streak=\(nextEmptyPageStreak) | stopAfterPage=\(page)")
                    self.probeMandiriSpecStop(collectedChunks: nextChunks)
                    return
                }
                self.readMandiriSpecPage(page: page + 1, collectedChunks: nextChunks, emptyPageStreak: nextEmptyPageStreak)
            } else {
                self.logMandiri("Spec page read failed | page=\(page) | status=\(response.sw1)-\(response.sw2)")
                self.retryHistoryRead(reason: "spec page status \(response.sw1)-\(response.sw2) at page \(page)")
            }
        })
    }

    private func probeMandiriSpecStop(collectedChunks: [String]) {
        let probePage = mandiriSpecMaxPage + 1
        let command = NFCISO7816APDU(
            instructionClass: 0x00,
            instructionCode: 0xD1,
            p1Parameter: UInt8(probePage & 0xFF),
            p2Parameter: 0x00,
            data: Data(),
            expectedResponseLength: CommonConstants.LE_GET_ALL_RESPONSE_DATA
        )
        logMandiriApdu("MANDIRI_SPEC_PROBE[\(probePage)]", command: command)
        apduRunner.exchangeApdu(apduCommand: command, completionHandler: { response in
            self.logMandiriResponse("MANDIRI_SPEC_PROBE[\(probePage)]", response: response)
            if response.sw1 == 0x69 && response.sw2 == 0x86 {
                self.logMandiri("Spec probe stop confirmed | page=\(probePage)")
            } else {
                self.logMandiri("Spec probe unexpected status | page=\(probePage) | status=\(response.sw1)-\(response.sw2)")
            }

            self.mapv1 = collectedChunks.joined()
            if self.parseMandiriSpecLog() {
                self.finalizeHistoryResult()
            } else {
                        self.retryHistoryRead(reason: "invalid spec history payload", retryable: false)
            }
        })
    }

    private func parseMandiriSpecLog() -> Bool {
        let logs = self.mapv1.trimmingCharacters(in: .whitespacesAndNewlines)
        let recordHexLength = 48
        logMandiri("Parse spec log | rawLength=\(logs.count)")

        guard logs.count >= recordHexLength, logs.count % recordHexLength == 0 else {
            logMandiri("Parse spec log invalid length | actual=\(logs.count) | recordHexLength=\(recordHexLength)")
            return false
        }

        let total = logs.count / recordHexLength
        logMandiri("Parse spec log totalRecords=\(total)")
        for i in 0..<total {
            let start = i * recordHexLength
            let end = start + recordHexLength
            let recordHex = logs.subString(from: start, to: end)
            guard let riwayat = parseMandiriSpecRecord(recordHex, index: i) else {
                logMandiri("Skip spec log record | index=\(i) | raw=\(recordHex)")
                continue
            }
            riwayatList.append(riwayat)
        }
        return true
    }

    private func parseMandiriSpecRecord(_ recordHex: String, index: Int) -> RiwayatCard? {
        guard recordHex.count == 48 else {
            logMandiri("Spec record invalid length | index=\(index) | length=\(recordHex.count)")
            return nil
        }

        if isMandiriSpecRecordEmpty(recordHex) {
            logMandiri("Spec record empty | index=\(index)")
            return nil
        }

        let bytes = recordHex.hex2byte().bytes
        guard bytes.count == 24 else {
            logMandiri("Spec record invalid byte count | index=\(index) | bytes=\(bytes.count)")
            return nil
        }

        guard let time = decodeMandiriSpecTimestamp(bytes, index: index) else {
            logMandiri("Spec record invalid timestamp | index=\(index) | raw=\(recordHex)")
            return nil
        }

        let transactionType = bytes[15]
        let amount = decodeMandiriLittleEndianUInt32(Array(bytes[16...19]))
        let balanceAfter = decodeMandiriLittleEndianUInt32(Array(bytes[20...23]))
        let terminalData = Array(bytes[7...13]).map { String(format: "%02X", $0) }.joined()

        let riwayat = RiwayatCard()
        riwayat.setTransactionTime(time)
        riwayat.setAmount(amount)
        riwayat.setDesk("BAL_AFTER=\(balanceAfter)")
        riwayat.setLocationId(terminalData)

        if transactionType == 0x00 {
            riwayat.setProsesTipe(0)
            riwayat.setTitle("topup".localizeString(string: self.langCode!))
        } else if transactionType == 0x20 {
            riwayat.setProsesTipe(1)
            riwayat.setTitle("payment".localizeString(string: self.langCode!))
        } else {
            riwayat.setProsesTipe(-1)
            riwayat.setTitle("unknown".localizeString(string: self.langCode!))
        }

        logMandiri(
            String(
                format: "Parsed spec log | index=%d | type=%02X | title=%@ | amount=%d | balanceAfter=%d | terminal=%@ | raw=%@",
                index,
                transactionType,
                riwayat.getTitle() ?? "-",
                amount,
                balanceAfter,
                terminalData,
                recordHex
            )
        )
        return riwayat
    }

    private func isMandiriSpecPageEmpty(_ pageHex: String) -> Bool {
        guard !pageHex.isEmpty else {
            return true
        }
        return !pageHex.contains { $0 != "0" }
    }

    private func isMandiriSpecRecordEmpty(_ recordHex: String) -> Bool {
        return !recordHex.contains { $0 != "0" }
    }

    private func decodeMandiriSpecTimestamp(_ bytes: [UInt8], index: Int) -> Date? {
        guard bytes.count >= 7 else {
            return nil
        }

        let values = bytes.prefix(7).map { decodeBcd($0) }
        guard values.allSatisfy({ $0 >= 0 }) else {
            logMandiri("BCD decode failed | index=\(index) | bytes=\(bytes.prefix(7).map { String(format: "%02X", $0) }.joined())")
            return nil
        }

        var components = DateComponents()
        components.calendar = Calendar(identifier: .gregorian)
        components.timeZone = TimeZone(identifier: "Asia/Jakarta") ?? .current
        components.year = 2000 + values[2]
        components.month = values[1]
        components.day = values[0]
        components.hour = values[3]
        components.minute = values[4]
        components.second = values[5]
        components.nanosecond = values[6] * 10_000_000
        return components.date
    }

    private func decodeBcd(_ value: UInt8) -> Int {
        let high = Int((value & 0xF0) >> 4)
        let low = Int(value & 0x0F)
        guard high < 10, low < 10 else {
            return -1
        }
        return (high * 10) + low
    }

    private func decodeMandiriLittleEndianUInt32(_ bytes: [UInt8]) -> Int {
        guard bytes.count >= 4 else {
            return 0
        }
        return Int(bytes[0])
            | (Int(bytes[1]) << 8)
            | (Int(bytes[2]) << 16)
            | (Int(bytes[3]) << 24)
    }
    
    private func getLogStep01(index : Int){
        //00 d1 00 00 00
        let hex = String(index, radix: 16)
        let p1Parameter = hex.hex2byte().bytes.first ?? 0x00
        let MANDIRI_LOG = NFCISO7816APDU(instructionClass : 0x00, instructionCode : 0xD1, p1Parameter : p1Parameter, p2Parameter : 0x00, data : Data(), expectedResponseLength : CommonConstants.LE_GET_ALL_RESPONSE_DATA)
        logMandiriApdu("MANDIRI_LOG_NEW[\(index)]", command: MANDIRI_LOG)
        apduRunner.exchangeApdu(apduCommand: MANDIRI_LOG, completionHandler: {response in
            self.logMandiriResponse("MANDIRI_LOG_NEW[\(index)]", response: response)
            if (response.sw1 == 0x90 && response.sw2 == 0x00){
                let chunk = response.getData().hexEncodedString()
                self.mapv1.append(chunk)
                self.logMandiri("New log chunk | index=\(index) | rawLength=\(chunk.count) | accumulated=\(self.mapv1.count)")
                self.start+=1
                if (self.start < (self.finish)){
                    self.getLogStep01(index: self.start)
                } else {
                    if self.parseNewLog() {
                        self.finalizeHistoryResult()
                    } else {
                        self.retryHistoryRead(reason: "invalid new history payload", retryable: false)
                    }
                }
            } else {
                self.logMandiri("New log read failed | index=\(index) | status=\(response.sw1)-\(response.sw2)")
                self.retryHistoryRead(reason: "new history status \(response.sw1)-\(response.sw2) at index \(index)")
            }
        })
    }
    
    func parseNewLog() -> Bool {
        let logs = self.mapv1.trimmingCharacters(in: .whitespacesAndNewlines)
        logMandiri("Parse new log | rawLength=\(logs.count)")
        if (logs.count % 48 != 0){
            logMandiri("Parse new log invalid length | length=\(logs.count)")
            return false
        }
        let total = logs.count/48
        logMandiri("Parse new log totalRecords=\(total)")
        for i in 0..<total {
            let start = i*48
            let end = start + 48
            let data = logs.subString(from: start, to: end)
            let riwayat = RiwayatCard()
            let time = self.getTransactionTime(formatDate: data.subString(from: 0, to: 6), formatTime: data.subString(from: 6, to: 12))
            guard let time else {
                self.logMandiri("Skip new log record | index=\(i) | reason=invalid_timestamp | raw=\(data)")
                continue
            }
            riwayat.setTransactionTime(time)
            let processType = Int(data.subString(from: 28, to: 32))
            if (processType == 100){
                riwayat.setProsesTipe(0)
                riwayat.setTitle("Top Up")
            } else {
                riwayat.setProsesTipe(1)
                riwayat.setTitle("Payment")
            }
            riwayat.setLocationId(data.subString(from: 12, to: 20))
            let amount = data.subString(from: 32, to: 40)
            riwayat.setAmount(getRealBalance(reverseHexa: amount))
            self.logMandiri("Parsed new log | index=\(i) | title=\(riwayat.getTitle() ?? "-") | processType=\(processType ?? -1) | locationId=\(riwayat.getLocationId() ?? "-") | amount=\(riwayat.getAmount()) | raw=\(data)")
            riwayatList.append(riwayat)
        }
        return true
    }
    
    private func getLogStep02(index : Int){
        //00 b2 00 00 1e
        let hex = String(index, radix: 16)
        let p1Parameter = hex.hex2byte().bytes.first ?? 0x00
        let MANDIRI_LOG = NFCISO7816APDU(instructionClass : 0x00, instructionCode : 0xB2, p1Parameter : p1Parameter, p2Parameter : 0x00, data : Data(), expectedResponseLength : 30)
        logMandiriApdu("MANDIRI_LOG_OLD[\(index)]", command: MANDIRI_LOG)
        apduRunner.exchangeApdu(apduCommand: MANDIRI_LOG, completionHandler: {response in
            self.logMandiriResponse("MANDIRI_LOG_OLD[\(index)]", response: response)
            if (response.sw1 == 0x90 && response.sw2 == 0x00){
                let chunk = response.getData().hexEncodedString()
                self.mapv1.append(chunk)
                self.logMandiri("Old log chunk | index=\(index) | rawLength=\(chunk.count) | accumulated=\(self.mapv1.count)")
                self.start+=1
                if (self.start < (self.finish2)){
                    self.getLogStep02(index: self.start)
                } else {
                    if self.parseOldLog() {
                        self.finalizeHistoryResult()
                    } else {
                        self.retryHistoryRead(reason: "invalid old history payload", retryable: false)
                    }
                }
            } else {
                self.logMandiri("Old log read failed | index=\(index) | status=\(response.sw1)-\(response.sw2)")
                self.retryHistoryRead(reason: "old history status \(response.sw1)-\(response.sw2) at index \(index)")
            }
        })
    }
    
    func parseOldLog() -> Bool {
        let logs = self.mapv1.trimmingCharacters(in: .whitespacesAndNewlines)
        logMandiri("Parse old log | rawLength=\(logs.count)")
        if (logs.count % 60 != 0){
            logMandiri("Parse old log invalid length | length=\(logs.count)")
            return false
        }
        let total = logs.count/60
        logMandiri("Parse old log totalRecords=\(total)")
        for i in 0..<total {
            let start = i*60
            let end = start + 60
            let data = logs.subString(from: start, to: end)
            if let riwayat = riwayatCard(data.hex2byte().bytes) {
                self.logMandiri("Parsed old log | index=\(i) | title=\(riwayat.getTitle() ?? "-") | amount=\(riwayat.getAmount()) | raw=\(data)")
                riwayatList.append(riwayat)
            } else {
                self.logMandiri("Skip old log record | index=\(i) | raw=\(data)")
            }
        }
        return true
    }
    
    func getTransactionTime(formatDate : String, formatTime : String) -> Date?{
        let dateFormatter2 = DateFormatter()
        dateFormatter2.locale = Locale(identifier: "en_US_POSIX")
        dateFormatter2.dateFormat = "HHmmss"
        guard let date12 = dateFormatter2.date(from: formatTime) else {
            return nil
        }

        dateFormatter2.dateFormat = "hh:mm a"
        let date22 = dateFormatter2.string(from: date12)
        
        let dateFormatter = DateFormatter()
        dateFormatter.locale = Locale(identifier: "en_US_POSIX")
        dateFormatter.dateFormat = "ddMMyy hh:mm a"
        return dateFormatter.date(from:(formatDate + " " + date22))
    }
    
    func getRealBalance(reverseHexa: String?) -> Int {
        guard let reverseHexa = reverseHexa?.trimmingCharacters(in: .whitespacesAndNewlines), !reverseHexa.isEmpty else {
            return 0
        }
        
        if reverseHexa.count % 2 != 0 {
            return 0
        }
        
        var sb = ""
        for l in stride(from: reverseHexa.count / 2, through: 1, by: -1) {
            let index1 = reverseHexa.index(reverseHexa.startIndex, offsetBy: l * 2 - 2)
            let index2 = reverseHexa.index(reverseHexa.startIndex, offsetBy: l * 2 - 1)
            sb.append(reverseHexa[index1])
            sb.append(reverseHexa[index2])
        }
        return sb.hex2decimal()
    }
    
    private func updateScreen(){
        if (self.apduRunner.callback != nil){
            self.apduRunner.callback?.complete(emoney: self.emoney)
        }
    }

    private func resetHistoryState() {
        riwayatList.removeAll()
        mapv1 = ""
        start = 0
    }

    private func finalizeHistory() {
        riwayatList = riwayatList.sorted { lhs, rhs in
            switch (lhs.getTransationTime(), rhs.getTransationTime()) {
            case let (left?, right?):
                return left.compare(right) == .orderedDescending
            case (_?, nil):
                return true
            case (nil, _?):
                return false
            case (nil, nil):
                return false
            }
        }
    }
    
    func riwayatCard(_ bArr: [UInt8]) -> RiwayatCard? {
        var str: String
        let riwayatCard = RiwayatCard()
        var wrap = Data(bArr)
        var bArr2 = [UInt8](repeating: 0, count: 6)
        var bArr3 = [UInt8](repeating: 0, count: 16)
        
        wrap.copyBytes(to: &bArr2, count: 6)
        wrap.removeFirst(10)
        
        let len = wrap.withUnsafeBytes { $0.load(as: Int32.self).bigEndian }
        wrap.removeFirst(4)
        let amount = wrap.withUnsafeBytes { $0.load(as: Int32.self).littleEndian }
        wrap.removeFirst(4)
        let desk = wrap.withUnsafeBytes { $0.load(as: Int32.self).littleEndian }
        wrap.removeFirst(4)
        
        do {
            wrap.copyBytes(to: &bArr3, count: 16)
            logMandiri("Old log detail bytes=\(bArr3.map { String(format: "%02X", $0) }.joined())")
        } catch {
            logMandiri("Old log detail error=\(error.localizedDescription)")
        }
        
        var type = 0
        if len == 288 {
            str = "payment".localizeString(string: self.langCode!)
            type = 1
        } else if len == 256 || len == 336 {
            type = 3
            str = "topup".localizeString(string: self.langCode!)
        } else {
            type = -1
            str = "unknown".localizeString(string: self.langCode!)
        }
        
        
        var str2 = ""
        for y in 0..<6 {
            str2 += String(format: "%02X", bArr2[y])
        }
        
        guard let transactionTime = self.getTransactionTime(formatDate: str.subString(from: 0, to: 6), formatTime: str.subString(from: 6, to: 12)) else {
            logMandiri("Skip old log record | reason=invalid_timestamp | raw=\(str)")
            return nil
        }
        riwayatCard.setTransactionTime(transactionTime)
        riwayatCard.setAmount(Int(amount))
        
//        if str.caseInsensitiveCompare("payment") == .orderedSame {
//            type = 1
//        } else if str.caseInsensitiveCompare("topup") != .orderedSame {
//            type = 3
//        }
        
        riwayatCard.setProsesTipe(type)
        riwayatCard.setTitle(str)
        
        if type == -1 {
            logMandiri("Skip old log record | reason=unknown_type | len=\(len) | raw=\(str)")
            return nil
        }
        
        return riwayatCard
    }
}
