/// SQLite persistence via GRDB.
/// Schema: Session -> Measurement -> DataPoint

import Foundation
import GRDB

// MARK: - Records

struct Session: Codable, FetchableRecord, MutablePersistableRecord {
    var id: Int64?
    var startedAt: Date
    var label: String?
    var notes: String?

    mutating func didInsert(_ inserted: InsertionSuccess) {
        id = inserted.rowID
    }
}

struct Measurement: Codable, FetchableRecord, MutablePersistableRecord {
    var id: Int64?
    var sessionId: Int64
    var type: String
    var startedAt: Date
    var config: Data?
    var resultSummary: Data?

    mutating func didInsert(_ inserted: InsertionSuccess) {
        id = inserted.rowID
    }
}

struct DataPoint: Codable, FetchableRecord, MutablePersistableRecord {
    var id: Int64?
    var measurementId: Int64
    var index: Int
    var payload: Data

    mutating func didInsert(_ inserted: InsertionSuccess) {
        id = inserted.rowID
    }
}

// MARK: - Database manager

final class Storage: @unchecked Sendable {
    static let shared = Storage()

    private let dbQueue: DatabaseQueue

    private init() {
        let dir = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)[0]
        let path = dir.appendingPathComponent("eis4.sqlite").path
        do {
            dbQueue = try DatabaseQueue(path: path)
            try migrate()
        } catch {
            fatalError("Database init failed: \(error)")
        }
    }

    private func migrate() throws {
        var migrator = DatabaseMigrator()

        migrator.registerMigration("v1") { db in
            try db.create(table: "session") { t in
                t.autoIncrementedPrimaryKey("id")
                t.column("startedAt", .datetime).notNull()
                t.column("label", .text)
                t.column("notes", .text)
            }

            try db.create(table: "measurement") { t in
                t.autoIncrementedPrimaryKey("id")
                t.column("sessionId", .integer).notNull()
                    .references("session", onDelete: .cascade)
                t.column("type", .text).notNull()
                t.column("startedAt", .datetime).notNull()
                t.column("config", .blob)
                t.column("resultSummary", .blob)
            }

            try db.create(table: "dataPoint") { t in
                t.autoIncrementedPrimaryKey("id")
                t.column("measurementId", .integer).notNull()
                    .references("measurement", onDelete: .cascade)
                t.column("index", .integer).notNull()
                t.column("payload", .blob).notNull()
            }
        }

        try migrator.migrate(dbQueue)
    }

    // MARK: - Sessions

    func createSession(label: String? = nil) throws -> Session {
        try dbQueue.write { db in
            var s = Session(startedAt: Date(), label: label)
            try s.insert(db)
            return s
        }
    }

    func fetchSessions() throws -> [Session] {
        try dbQueue.read { db in
            try Session.order(Column("startedAt").desc).fetchAll(db)
        }
    }

    func deleteSession(_ id: Int64) throws {
        try dbQueue.write { db in
            try db.execute(sql: "DELETE FROM session WHERE id = ?", arguments: [id])
        }
    }

    func updateSession(_ id: Int64, label: String?, notes: String?) throws {
        try dbQueue.write { db in
            try db.execute(
                sql: "UPDATE session SET label = ?, notes = ? WHERE id = ?",
                arguments: [label, notes, id]
            )
        }
    }

    // MARK: - Measurements

    func addMeasurement(
        sessionId: Int64,
        type: MeasurementType,
        config: (any Encodable)? = nil
    ) throws -> Measurement {
        let configData: Data? = if let config {
            try JSONEncoder().encode(config)
        } else {
            nil
        }
        return try dbQueue.write { db in
            var m = Measurement(
                sessionId: sessionId,
                type: type.rawValue,
                startedAt: Date(),
                config: configData
            )
            try m.insert(db)
            return m
        }
    }

    func setMeasurementResult(_ measurementId: Int64, result: any Encodable) throws {
        try dbQueue.write { db in
            let data = try JSONEncoder().encode(result)
            try db.execute(
                sql: "UPDATE measurement SET resultSummary = ? WHERE id = ?",
                arguments: [data, measurementId]
            )
        }
    }

    func fetchMeasurements(sessionId: Int64) throws -> [Measurement] {
        try dbQueue.read { db in
            try Measurement
                .filter(Column("sessionId") == sessionId)
                .order(Column("startedAt").asc)
                .fetchAll(db)
        }
    }

    func deleteMeasurement(_ id: Int64) throws {
        try dbQueue.write { db in
            try db.execute(sql: "DELETE FROM measurement WHERE id = ?", arguments: [id])
        }
    }

    func dataPointCount(measurementId: Int64) throws -> Int {
        try dbQueue.read { db in
            try DataPoint
                .filter(Column("measurementId") == measurementId)
                .fetchCount(db)
        }
    }

    func measurementCount(sessionId: Int64) throws -> Int {
        try dbQueue.read { db in
            try Measurement
                .filter(Column("sessionId") == sessionId)
                .fetchCount(db)
        }
    }

    // MARK: - Data points

    func addDataPoint<T: Encodable>(measurementId: Int64, index: Int, point: T) throws {
        let payload = try JSONEncoder().encode(point)
        try dbQueue.write { db in
            var dp = DataPoint(
                measurementId: measurementId,
                index: index,
                payload: payload
            )
            try dp.insert(db)
        }
    }

    func addDataPoints<T: Encodable>(measurementId: Int64, points: [(index: Int, value: T)]) throws {
        let encoder = JSONEncoder()
        try dbQueue.write { db in
            for (idx, val) in points {
                let payload = try encoder.encode(val)
                var dp = DataPoint(measurementId: measurementId, index: idx, payload: payload)
                try dp.insert(db)
            }
        }
    }

    func fetchDataPoints(measurementId: Int64) throws -> [DataPoint] {
        try dbQueue.read { db in
            try DataPoint
                .filter(Column("measurementId") == measurementId)
                .order(Column("index").asc)
                .fetchAll(db)
        }
    }

    /// Decode stored data points into typed values.
    func fetchTypedPoints<T: Decodable>(measurementId: Int64, as type: T.Type) throws -> [T] {
        let rows = try fetchDataPoints(measurementId: measurementId)
        let decoder = JSONDecoder()
        return try rows.map { try decoder.decode(T.self, from: $0.payload) }
    }

    // MARK: - Observation (for SwiftUI live updates)

    func observeDataPoints(
        measurementId: Int64,
        onChange: @escaping ([DataPoint]) -> Void
    ) -> DatabaseCancellable {
        let observation = ValueObservation.tracking { db in
            try DataPoint
                .filter(Column("measurementId") == measurementId)
                .order(Column("index").asc)
                .fetchAll(db)
        }
        return observation.start(in: dbQueue, onError: { _ in }, onChange: onChange)
    }

    func observeSessions(onChange: @escaping ([Session]) -> Void) -> DatabaseCancellable {
        let observation = ValueObservation.tracking { db in
            try Session.order(Column("startedAt").desc).fetchAll(db)
        }
        return observation.start(in: dbQueue, onError: { _ in }, onChange: onChange)
    }

    // MARK: - TOML export/import

    private static let tomlDateFmt: DateFormatter = {
        let f = DateFormatter()
        f.dateFormat = "yyyy-MM-dd HH:mm:ss"
        f.locale = Locale(identifier: "en_US_POSIX")
        f.timeZone = TimeZone.current
        return f
    }()

    func exportSession(_ id: Int64) throws -> String {
        let session = try dbQueue.read { db in
            try Session.fetchOne(db, key: id)
        }
        guard let session else { throw StorageError.notFound }

        var out = "[session]\n"
        out += "name = \(tomlQuote(session.label ?? ""))\n"
        out += "notes = \(tomlQuote(session.notes ?? ""))\n"
        out += "created_at = \(tomlQuote(Self.tomlDateFmt.string(from: session.startedAt)))\n"

        let measurements = try fetchMeasurements(sessionId: id)
        for m in measurements {
            out += "\n[[measurement]]\n"
            out += "type = \(tomlQuote(m.type))\n"
            out += "created_at = \(tomlQuote(Self.tomlDateFmt.string(from: m.startedAt)))\n"

            if let configData = m.config,
               let config = try? JSONSerialization.jsonObject(with: configData) as? [String: Any] {
                out += "\n[measurement.params]\n"
                for (k, v) in config.sorted(by: { $0.key < $1.key }) {
                    out += "\(k) = \(tomlValue(v))\n"
                }
            }

            guard let mid = m.id else { continue }
            let mtype = MeasurementType(rawValue: m.type)
            let points = try fetchDataPoints(measurementId: mid)
            let decoder = JSONDecoder()

            switch mtype {
            case .eis:
                for dp in points {
                    if let p = try? decoder.decode(EisPoint.self, from: dp.payload) {
                        out += "\n[[measurement.data]]\n"
                        out += "\"Frequency (Hz)\" = \(tomlFloat(p.freqHz))\n"
                        out += "\"|Z| (Ohm)\" = \(tomlFloat(p.magOhms))\n"
                        out += "\"Phase (deg)\" = \(tomlFloat(p.phaseDeg))\n"
                        out += "\"Re (Ohm)\" = \(tomlFloat(p.zReal))\n"
                        out += "\"Im (Ohm)\" = \(tomlFloat(p.zImag))\n"
                    }
                }
            case .lsv:
                for dp in points {
                    if let p = try? decoder.decode(LsvPoint.self, from: dp.payload) {
                        out += "\n[[measurement.data]]\n"
                        out += "\"Voltage (mV)\" = \(tomlFloat(p.vMv))\n"
                        out += "\"Current (uA)\" = \(tomlFloat(p.iUa))\n"
                    }
                }
            case .amp:
                for dp in points {
                    if let p = try? decoder.decode(AmpPoint.self, from: dp.payload) {
                        out += "\n[[measurement.data]]\n"
                        out += "\"Time (ms)\" = \(tomlFloat(p.tMs))\n"
                        out += "\"Current (uA)\" = \(tomlFloat(p.iUa))\n"
                    }
                }
            case .chlorine:
                for dp in points {
                    if let p = try? decoder.decode(ClPoint.self, from: dp.payload) {
                        out += "\n[[measurement.data]]\n"
                        out += "\"Time (ms)\" = \(tomlFloat(p.tMs))\n"
                        out += "\"Current (uA)\" = \(tomlFloat(p.iUa))\n"
                        out += "\"Phase\" = \(p.phase)\n"
                    }
                }
                if let resultData = m.resultSummary,
                   let r = try? decoder.decode(ClResult.self, from: resultData) {
                    out += "\n[measurement.result]\n"
                    out += "\"Free Cl (uA)\" = \(tomlFloat(r.iFreeUa))\n"
                    out += "\"Total Cl (uA)\" = \(tomlFloat(r.iTotalUa))\n"
                }
            case .ph:
                for dp in points {
                    if let p = try? decoder.decode(PhResult.self, from: dp.payload) {
                        out += "\n[[measurement.data]]\n"
                        out += "\"OCP (mV)\" = \(tomlFloat(p.vOcpMv))\n"
                        out += "\"pH\" = \(tomlFloat(p.ph))\n"
                        out += "\"Temperature (C)\" = \(tomlFloat(p.tempC))\n"
                    }
                }
            case nil:
                break
            }
        }
        return out
    }

    func importSession(from toml: String) throws -> Int64 {
        let parsed = TOMLParser.parse(toml)

        guard let sessionDict = parsed["session"] as? [String: Any] else {
            throw StorageError.parseError("missing [session]")
        }
        let name = sessionDict["name"] as? String ?? ""
        let notes = sessionDict["notes"] as? String ?? ""

        var session = Session(
            startedAt: Self.tomlDateFmt.date(from: sessionDict["created_at"] as? String ?? "") ?? Date(),
            label: name.isEmpty ? nil : name,
            notes: notes.isEmpty ? nil : notes
        )
        try dbQueue.write { db in try session.insert(db) }
        guard let sid = session.id else { throw StorageError.parseError("insert failed") }

        guard let measurements = parsed["measurement"] as? [[String: Any]] else { return sid }
        let encoder = JSONEncoder()

        for mDict in measurements {
            let typeStr = mDict["type"] as? String ?? "eis"
            let createdStr = mDict["created_at"] as? String ?? ""
            let created = Self.tomlDateFmt.date(from: createdStr) ?? Date()

            var configData: Data? = nil
            if let params = mDict["params"] as? [String: Any] {
                configData = try? JSONSerialization.data(withJSONObject: params)
            }

            var meas = Measurement(
                sessionId: sid,
                type: typeStr,
                startedAt: created,
                config: configData
            )
            try dbQueue.write { db in try meas.insert(db) }
            guard let mid = meas.id else { continue }

            let mtype = MeasurementType(rawValue: typeStr)
            guard let dataRows = mDict["data"] as? [[String: Any]] else { continue }

            try dbQueue.write { db in
                for (idx, row) in dataRows.enumerated() {
                    let payload: Data
                    switch mtype {
                    case .eis:
                        payload = try encoder.encode(EisPoint(
                            freqHz: floatVal(row, "Frequency (Hz)"),
                            magOhms: floatVal(row, "|Z| (Ohm)"),
                            phaseDeg: floatVal(row, "Phase (deg)"),
                            zReal: floatVal(row, "Re (Ohm)"),
                            zImag: floatVal(row, "Im (Ohm)"),
                            rtiaMagBefore: 0, rtiaMagAfter: 0,
                            revMag: 0, revPhase: 0, pctErr: 0
                        ))
                    case .lsv:
                        payload = try encoder.encode(LsvPoint(
                            vMv: floatVal(row, "Voltage (mV)"),
                            iUa: floatVal(row, "Current (uA)")
                        ))
                    case .amp:
                        payload = try encoder.encode(AmpPoint(
                            tMs: floatVal(row, "Time (ms)"),
                            iUa: floatVal(row, "Current (uA)")
                        ))
                    case .chlorine:
                        payload = try encoder.encode(ClPoint(
                            tMs: floatVal(row, "Time (ms)"),
                            iUa: floatVal(row, "Current (uA)"),
                            phase: UInt8(intVal(row, "Phase"))
                        ))
                    case .ph:
                        payload = try encoder.encode(PhResult(
                            vOcpMv: floatVal(row, "OCP (mV)"),
                            ph: floatVal(row, "pH"),
                            tempC: floatVal(row, "Temperature (C)")
                        ))
                    case nil:
                        continue
                    }
                    var dp = DataPoint(measurementId: mid, index: idx, payload: payload)
                    try dp.insert(db)
                }
            }

            if mtype == .chlorine, let resultDict = mDict["result"] as? [String: Any] {
                let r = ClResult(
                    iFreeUa: floatVal(resultDict, "Free Cl (uA)"),
                    iTotalUa: floatVal(resultDict, "Total Cl (uA)")
                )
                try setMeasurementResult(mid, result: r)
            }
            if mtype == .ph, let dataRows = mDict["data"] as? [[String: Any]], let first = dataRows.first {
                let r = PhResult(
                    vOcpMv: floatVal(first, "OCP (mV)"),
                    ph: floatVal(first, "pH"),
                    tempC: floatVal(first, "Temperature (C)")
                )
                try setMeasurementResult(mid, result: r)
            }
        }
        return sid
    }
}

enum StorageError: Error {
    case notFound
    case parseError(String)
}

// MARK: - TOML helpers

private func tomlQuote(_ s: String) -> String {
    let escaped = s.replacingOccurrences(of: "\\", with: "\\\\")
        .replacingOccurrences(of: "\"", with: "\\\"")
        .replacingOccurrences(of: "\n", with: "\\n")
    return "\"\(escaped)\""
}

private func tomlFloat(_ v: Float) -> String {
    if v == v.rounded() && abs(v) < 1e15 {
        return String(format: "%.1f", v)
    }
    return String(v)
}

private func tomlValue(_ v: Any) -> String {
    switch v {
    case let s as String: return tomlQuote(s)
    case let n as NSNumber:
        if CFGetTypeID(n) == CFBooleanGetTypeID() { return n.boolValue ? "true" : "false" }
        let d = n.doubleValue
        if d == d.rounded() && abs(d) < 1e15 && !"\(n)".contains(".") {
            return "\(n)"
        }
        return String(format: "%g", d)
    default: return tomlQuote("\(v)")
    }
}

private func floatVal(_ dict: [String: Any], _ key: String) -> Float {
    if let n = dict[key] as? NSNumber { return n.floatValue }
    return 0
}

private func intVal(_ dict: [String: Any], _ key: String) -> Int {
    if let n = dict[key] as? NSNumber { return n.intValue }
    return 0
}

// MARK: - Minimal TOML parser

enum TOMLParser {
    static func parse(_ input: String) -> [String: Any] {
        var root: [String: Any] = [:]
        var currentSection: [String] = []
        var arrayCounters: [String: Int] = [:]
        let lines = input.components(separatedBy: .newlines)

        for line in lines {
            let trimmed = line.trimmingCharacters(in: .whitespaces)
            if trimmed.isEmpty || trimmed.hasPrefix("#") { continue }

            if let match = parseArrayTable(trimmed) {
                let parts = match.components(separatedBy: ".")
                let rootKey = parts[0]
                let counterKey = match

                if arrayCounters[counterKey] == nil {
                    setNested(&root, path: [rootKey], value: [[String: Any]]())
                    arrayCounters[counterKey] = 0
                } else {
                    arrayCounters[counterKey]! += 1
                }

                if var arr = getNested(root, path: [rootKey]) as? [[String: Any]] {
                    if arr.count <= arrayCounters[counterKey]! {
                        arr.append([:])
                        setNested(&root, path: [rootKey], value: arr)
                    }
                }

                currentSection = parts
                continue
            }

            if let match = parseTable(trimmed) {
                currentSection = match.components(separatedBy: ".")
                continue
            }

            if let (key, value) = parseKeyValue(trimmed) {
                let path = resolvePath(currentSection, arrayCounters: arrayCounters)
                var target = (getNested(root, path: path) as? [String: Any]) ?? [:]
                target[key] = value
                if path.isEmpty {
                    for (k, v) in target { root[k] = v }
                } else {
                    setNested(&root, path: path, value: target)
                }
            }
        }
        return root
    }

    private static func parseArrayTable(_ line: String) -> String? {
        guard line.hasPrefix("[[") && line.hasSuffix("]]") else { return nil }
        let inner = line.dropFirst(2).dropLast(2).trimmingCharacters(in: .whitespaces)
        return inner.isEmpty ? nil : inner
    }

    private static func parseTable(_ line: String) -> String? {
        guard line.hasPrefix("[") && line.hasSuffix("]") && !line.hasPrefix("[[") else { return nil }
        let inner = line.dropFirst(1).dropLast(1).trimmingCharacters(in: .whitespaces)
        return inner.isEmpty ? nil : inner
    }

    private static func parseKeyValue(_ line: String) -> (String, Any)? {
        guard let eqIdx = line.firstIndex(of: "=") else { return nil }
        var key = String(line[line.startIndex..<eqIdx]).trimmingCharacters(in: .whitespaces)
        let valStr = String(line[line.index(after: eqIdx)...]).trimmingCharacters(in: .whitespaces)

        if key.hasPrefix("\"") && key.hasSuffix("\"") {
            key = String(key.dropFirst(1).dropLast(1))
        }

        return (key, parseValue(valStr))
    }

    private static func parseValue(_ s: String) -> Any {
        if s.hasPrefix("\"") && s.hasSuffix("\"") {
            var inner = String(s.dropFirst(1).dropLast(1))
            inner = inner.replacingOccurrences(of: "\\n", with: "\n")
            inner = inner.replacingOccurrences(of: "\\\"", with: "\"")
            inner = inner.replacingOccurrences(of: "\\\\", with: "\\")
            return inner
        }
        if s == "true" { return true }
        if s == "false" { return false }
        if s.contains(".") || s.contains("e") || s.contains("E") {
            if let d = Double(s) { return NSNumber(value: d) }
        }
        if let i = Int(s) { return NSNumber(value: i) }
        return s
    }

    private static func resolvePath(
        _ section: [String], arrayCounters: [String: Int]
    ) -> [String] {
        guard !section.isEmpty else { return [] }

        let rootKey = section[0]
        let fullKey = section.joined(separator: ".")
        var path: [String] = [rootKey]

        if let idx = arrayCounters[rootKey] {
            path.append("\(idx)")
        }

        if section.count > 1 {
            let subKey = section[1...].joined(separator: ".")
            if let idx = arrayCounters[fullKey] {
                path.append(subKey)
                path.append("\(idx)")
            } else {
                for part in section[1...] {
                    path.append(part)
                }
            }
        }

        return path
    }

    private static func getNested(_ dict: [String: Any], path: [String]) -> Any? {
        var current: Any = dict
        for component in path {
            if let idx = Int(component), let arr = current as? [Any], idx < arr.count {
                current = arr[idx]
            } else if let d = current as? [String: Any], let val = d[component] {
                current = val
            } else {
                return nil
            }
        }
        return current
    }

    private static func setNested(_ dict: inout [String: Any], path: [String], value: Any) {
        guard !path.isEmpty else { return }
        if path.count == 1 {
            dict[path[0]] = value
            return
        }

        let key = path[0]
        let rest = Array(path[1...])

        if let idxStr = rest.first, let idx = Int(idxStr), var arr = dict[key] as? [Any] {
            while arr.count <= idx { arr.append([String: Any]()) }
            if rest.count == 1 {
                arr[idx] = value
            } else {
                var sub = (arr[idx] as? [String: Any]) ?? [:]
                setNested(&sub, path: Array(rest[1...]), value: value)
                arr[idx] = sub
            }
            dict[key] = arr
        } else {
            var sub = (dict[key] as? [String: Any]) ?? [:]
            setNested(&sub, path: rest, value: value)
            dict[key] = sub
        }
    }
}
