import ExpoModulesCore
import sqlite3

public final class SQLiteModule: Module {
  private var cachedDatabases = [String: OpaquePointer]()
  private var hasListeners = false
  private lazy var selfPointer = Unmanaged.passRetained(self).toOpaque()

  public func definition() -> ModuleDefinition {
    Name("ExpoSQLite")

    Events("onDatabaseChange")

    OnCreate {
      crsqlite_init_from_swift()
    }

    AsyncFunction("exec") { (dbName: String, queries: [[Any]], readOnly: Bool) -> [Any?] in
      guard let db = openDatabase(dbName: dbName) else {
        throw DatabaseException()
      }

      let results = try queries.map { query in
        guard let sql = query[0] as? String else {
          throw InvalidSqlException()
        }

        guard let args = query[1] as? [Any] else {
          throw InvalidArgumentsException()
        }

        return executeSql(sql: sql, with: args, for: db, readOnly: readOnly)
      }

      return results
    }

    AsyncFunction("close") { (dbName: String) in
      cachedDatabases.removeValue(forKey: dbName)
    }

    Function("closeSync") { (dbName: String) in
      cachedDatabases.removeValue(forKey: dbName)
    }

    AsyncFunction("deleteAsync") { (dbName: String) in
      if cachedDatabases[dbName] != nil {
        throw DeleteDatabaseException(dbName)
      }

      guard let path = self.pathForDatabaseName(name: dbName) else {
        throw Exceptions.FileSystemModuleNotFound()
      }

      if !FileManager.default.fileExists(atPath: path.absoluteString) {
        throw DatabaseNotFoundException(dbName)
      }

      do {
        try FileManager.default.removeItem(atPath: path.absoluteString)
      } catch {
        throw DeleteDatabaseFileException(dbName)
      }
    }

    OnStartObserving {
      hasListeners = true
    }

    OnStopObserving {
      hasListeners = false
    }

    OnDestroy {
      cachedDatabases.values.forEach {
        executeSql(sql: "SELECT crsql_finalize()", with: [], for: $0, readOnly: false)
        sqlite3_close($0)
      }
    }
  }

  private func pathForDatabaseName(name: String) -> URL? {
    guard let fileSystem = appContext?.fileSystem else {
      return nil
    }

    let directory = URL(string: fileSystem.documentDirectory)?.appendingPathComponent("SQLite")
    fileSystem.ensureDirExists(withPath: directory?.absoluteString)

    return directory?.appendingPathComponent(name)
  }

  private func openDatabase(dbName: String) -> OpaquePointer? {
    var db: OpaquePointer?
    guard let path = pathForDatabaseName(name: dbName) else {
      return nil
    }

    let fileExists = FileManager.default.fileExists(atPath: path.absoluteString)

    if fileExists {
      db = cachedDatabases[dbName]
    }

    if let db {
      return db
    }

    cachedDatabases.removeValue(forKey: dbName)

    if sqlite3_open(path.absoluteString, &db) != SQLITE_OK {
      return nil
    }

    sqlite3_update_hook(
      db, { (obj, action, _, tableName, rowId) in
        if let obj, let tableName {
          let selfObj = Unmanaged<SQLiteModule>.fromOpaque(obj).takeUnretainedValue()
          if selfObj.hasListeners {
            selfObj.sendEvent("onDatabaseChange", [
              "tableName": String(cString: UnsafePointer(tableName)),
              "rowId": rowId,
              "typeId": SqlAction.fromCode(value: action)
            ])
          }
        }
      },
      selfPointer
    )

    cachedDatabases[dbName] = db
    return db
  }

  private func executeSql(sql: String, with args: [Any], for db: OpaquePointer, readOnly: Bool) -> [Any?] {
    var resultRows = [Any]()
    var statement: OpaquePointer?
    var rowsAffected: Int32 = 0
    var insertId: Int64 = 0
    var error: String?

    if sqlite3_prepare_v2(db, sql, -1, &statement, nil) != SQLITE_OK {
      return [convertSqlLiteErrorToString(db: db)]
    }

    let queryIsReadOnly = sqlite3_stmt_readonly(statement) > 0

    if readOnly && !queryIsReadOnly {
      return ["could not prepare \(sql)"]
    }

    for (index, arg) in args.enumerated() {
      guard let obj = arg as? NSObject else { continue }
      bindStatement(statement: statement, with: obj, at: Int32(index + 1))
    }

    var columnCount: Int32 = 0
    var columnNames = [String]()
    var columnType: Int32
    var fetchedColumns = false
    var value: Any?
    var hasMore = true

    while hasMore {
      let result = sqlite3_step(statement)

      switch result {
      case SQLITE_ROW:
        if !fetchedColumns {
          columnCount = sqlite3_column_count(statement)

          for i in 0..<Int(columnCount) {
            let columnName = NSString(format: "%s", sqlite3_column_name(statement, Int32(i))) as String
            columnNames.append(columnName)
          }
          fetchedColumns = true
        }

        var entry = [Any]()

        for i in 0..<Int(columnCount) {
          columnType = sqlite3_column_type(statement, Int32(i))
          value = getSqlValue(for: columnType, with: statement, index: Int32(i))
          entry.append(value)
        }

        resultRows.append(entry)
      case SQLITE_DONE:
        hasMore = false
      default:
        error = convertSqlLiteErrorToString(db: db)
        hasMore = false
      }
    }

    if !queryIsReadOnly {
      rowsAffected = sqlite3_changes(db)
      if rowsAffected > 0 {
        insertId = sqlite3_last_insert_rowid(db)
      }
    }

    sqlite3_finalize(statement)

    if error != nil {
      return [error]
    }

    return [nil, insertId, rowsAffected, columnNames, resultRows]
  }

  private func bindStatement(statement: OpaquePointer?, with arg: NSObject, at index: Int32) {
    if arg == NSNull() {
      sqlite3_bind_null(statement, index)
    } else if arg is Double {
      sqlite3_bind_double(statement, index, arg as? Double ?? 0.0)
    } else {
      var stringArg: NSString

      if arg is NSString {
        stringArg = NSString(format: "%@", arg)
      } else {
        stringArg = arg.description as NSString
      }

      let SQLITE_TRANSIENT = unsafeBitCast(OpaquePointer(bitPattern: -1), to: sqlite3_destructor_type.self)

      let data = stringArg.data(using: NSUTF8StringEncoding)
      sqlite3_bind_text(statement, index, stringArg.utf8String, Int32(data?.count ?? 0), SQLITE_TRANSIENT)
    }
  }

  private func getSqlValue(for columnType: Int32, with statement: OpaquePointer?, index: Int32) -> Any? {
    switch columnType {
    case SQLITE_INTEGER:
      return sqlite3_column_int64(statement, index)
    case SQLITE_FLOAT:
      return sqlite3_column_double(statement, index)
    case SQLITE_BLOB, SQLITE_TEXT:
      return NSString(bytes: sqlite3_column_text(statement, index), length: Int(sqlite3_column_bytes(statement, index)), encoding: NSUTF8StringEncoding)
    default:
      return nil
    }
  }

  private func convertSqlLiteErrorToString(db: OpaquePointer?) -> String {
    let code = sqlite3_errcode(db)
    let message = NSString(utf8String: sqlite3_errmsg(db)) ?? ""
    return NSString(format: "Error code %i: %@", code, message) as String
  }
}

enum SqlAction: String, Enumerable {
  case insert
  case delete
  case update
  case unknown

  static func fromCode(value: Int32) -> SqlAction {
    switch value {
    case 9:
      return .delete
    case 18:
      return .insert
    case 23:
      return .update
    default:
      return .unknown
    }
  }
}
