//
//  Storage.swift
//  KeymanEngine
//
//  Created by Gabriel Wong on 2017-11-21.
//  Copyright © 2017 SIL International. All rights reserved.
//

import Foundation
import Sentry

// MARK: - Static members
extension Storage {
  /// Storage that is not shared with the other process (app or extension).
  static let nonShared: Storage? = {
    let paths = FileManager.default.urls(for: .libraryDirectory, in: .userDomainMask)
    if paths.isEmpty {
      return nil
    }
    SentryManager.breadcrumbAndLog("Storage.nonShared:  using path \(paths[0])")
    return Storage(baseURL: paths[0], userDefaults: UserDefaults.standard)
  }()

  /// Storage shared between the app and app extension.
  /// - Important: `Manager.applicationGroupIdentifier` must be set before accessing this. Modifications to
  ///   `Manager.applicationGroupIdentifier` will not be reflected here.
  static let shared: Storage? = {
    guard let groupID = Manager.applicationGroupIdentifier else {
      return nil
    }
    guard let url = FileManager.default.containerURL(forSecurityApplicationGroupIdentifier: groupID),
      let userDefaults = UserDefaults(suiteName: groupID)
    else {
      return nil
    }
    return Storage(baseURL: url, userDefaults: userDefaults)
  }()

  /// Active storage to be used for app data. This is `shared` if possible, otherwise `nonShared`.
  /// - Important: `Manager.applicationGroupIdentifier` must be set before accessing this. Modifications to
  ///   `Manager.applicationGroupIdentifier` will not be reflected here.
  static let active: Storage! = {
    if let shared = shared {
      return shared
    }
    return nonShared
  }()
}

class Storage {
  let baseDir: URL
  let keyboardDir: URL
  let lexicalModelDir: URL
  let userDefaults: UserDefaults

  let kmwURL: URL
  let specialOSKFontURL: URL

  private init?(baseURL: URL, userDefaults: UserDefaults) {
    guard
      let baseDir = Storage.createSubdirectory(baseDir: baseURL, name: "keyman"),
      let keyboardDir = Storage.createSubdirectory(baseDir: baseDir, name: "keyboards"),
      let lexicalModelDir = Storage.createSubdirectory(baseDir: baseDir, name: "lexicalModels")
    else {
      return nil
    }

    self.baseDir = baseDir
    self.keyboardDir = keyboardDir
    self.lexicalModelDir = lexicalModelDir
    self.userDefaults = userDefaults
    kmwURL = baseDir.appendingPathComponent(Resources.kmwFilename)
    specialOSKFontURL = baseDir.appendingPathComponent(Resources.oskFontFilename)
  }

  private static func createSubdirectory(baseDir: URL, name: String) -> URL? {
    let newDir = baseDir.appendingPathComponent(name)
    do {
      try FileManager.default.createDirectory(at: newDir,
                                              withIntermediateDirectories: true,
                                              attributes: nil)
      return newDir
    } catch {
      SentryManager.captureAndLog(error, message: "Failed to create subdirectory at \(newDir)")
      return nil
    }
  }

  // Note:  legacy* methods are utilized for migrations through 14.0, at which point
  //        KeymanEngine transitioned entirely to using LanguageResource instances
  //        for path lookups.

  func legacyKeyboardDir(forID keyboardID: String) -> URL {
    return keyboardDir.appendingPathComponent(keyboardID)
  }

  func legacyLexicalModelDir(forID lexicalModelID: String) -> URL {
    return lexicalModelDir.appendingPathComponent(lexicalModelID)
  }

  func packageDir(forID packageID: String, ofType resourceType: LanguageResourceType) -> URL {
    switch resourceType {
      case .keyboard:
        return keyboardDir.appendingPathComponent(packageID)
      case .lexicalModel:
        return lexicalModelDir.appendingPathComponent(packageID)
    }
  }

  func packageDir(forKey key: KeymanPackage.Key) -> URL {
    return packageDir(forID: key.id, ofType: key.type)
  }

  func packageDir(for package: KeymanPackage) -> URL? {
    return packageDir(forID: package.id, ofType: package.resourceType())
  }

  func resourceDir(for resource: AnyLanguageResource) -> URL? {
    if let keyboard = resource as? InstallableKeyboard {
      return packageDir(forID: keyboard.packageID ?? keyboard.id, ofType: .keyboard)
    } else if let lexicalModel = resource as? InstallableLexicalModel {
      return packageDir(forID: lexicalModel.packageID ?? lexicalModel.id, ofType: .lexicalModel)
    } else {
      return nil
    }
  }
  
  func keyboardURL(for keyboard: InstallableKeyboard) -> URL {
    return resourceDir(for: keyboard)!.appendingPathComponent("\(keyboard.id).js")
  }
  
  func lexicalModelURL(for lexicalModel: InstallableLexicalModel) -> URL {
    return resourceDir(for: lexicalModel)!.appendingPathComponent("\(lexicalModel.id).model.js")
  }

  // Facilitates abstractions based on the LanguageResource protocol.
  func resourceURL(for resource: AnyLanguageResource) -> URL? {
    if let keyboard = resource as? InstallableKeyboard {
      return keyboardURL(for: keyboard)
    } else if let lexicalModel = resource as? InstallableLexicalModel {
      return lexicalModelURL(for: lexicalModel)
    } else {
      return nil
    }
  }

  // Facilitates abstractions based on the LanguageResource protocol.
  func legacyResourceURL(for resource: AnyLanguageResource) -> URL? {
    if let keyboard = resource as? InstallableKeyboard {
      return legacyKeyboardURL(forID: keyboard.id, version: keyboard.version)
    } else if let lexicalModel = resource as? InstallableLexicalModel {
      return legacyLexicalModelURL(forID: lexicalModel.id, version: lexicalModel.version)
    } else {
      return nil
    }
  }
  
  func lexicalModelPackageURL(for lexicalModel: InstallableLexicalModel) -> URL {
    return resourceDir(for: lexicalModel)!.appendingPathComponent("\(lexicalModel.id).model.kmp")
  }

  // TODO:  Once we drop cloud-keyboard downloads, remove this function.
  func cloudKeyboardURL(forID keyboardID: String) -> URL {
    // For now, we treat cloud keyboards as KMP-less packages, generating a kmp.json after download.
    return packageDir(forID: keyboardID, ofType: .keyboard).appendingPathComponent("\(keyboardID).js")
  }

  func legacyKeyboardURL(forID keyboardID: String, version: String) -> URL {
    // We no longer embed version numbers into filenames
    // This allows us to support existing installs and OEM products that
    // still include the version string, at reasonable cost
    // Note, that for installing a keyboard, it will no longer perform this
    // rename with this model, as the target file will obviously not already
    // exist.
    let filename = legacyKeyboardDir(forID: keyboardID).appendingPathComponent("\(keyboardID).js")
    if FileManager.default.fileExists(atPath: filename.path) {
      return filename
    }
    return legacyKeyboardDir(forID: keyboardID).appendingPathComponent("\(keyboardID)-\(version).js")
  }
  
  func legacyLexicalModelURL(forID lexicalModelID: String, version: String) -> URL {
    return legacyLexicalModelDir(forID: lexicalModelID).appendingPathComponent("\(lexicalModelID)-\(version).model.js")
  }
  
  func legacyLexicalModelPackageURL(forID lexicalModelID: String, version: String, asZip: Bool = false) -> URL {
    // Our unzipping dependency requires a .zip extension to function, so we append that here.
    return legacyLexicalModelDir(forID: lexicalModelID).appendingPathComponent("\(lexicalModelID)-\(version).model.kmp\(asZip ? ".zip" : "")")
  }

  func fontURL(forResource resource: AnyLanguageResource, filename: String) -> URL? {
    return resourceDir(for: resource)?.appendingPathComponent(filename)
  }

  var keyboardDirs: [URL]? {
    let contents: [URL]
    do {
      contents = try FileManager.default.contentsOfDirectory(at: keyboardDir,
                                                             includingPropertiesForKeys: [.isDirectoryKey])
    } catch {
      SentryManager.captureAndLog(error, message: "Failed to list contents at \(keyboardDir) with error \(error)")
      return nil
    }
    return contents.filter { url in
      do {
        let values = try url.resourceValues(forKeys: [.isDirectoryKey])
        return values.isDirectory ?? false
      } catch {
        SentryManager.captureAndLog(error)
        return false
      }
    }
  }
}

// MARK: - Copying
extension Storage {
  func copyKMWFiles(from bundle: Bundle) throws {
    try Storage.copy(from: bundle,
                     resourceName: Resources.kmwFilename,
                     dstDir: baseDir,
                     excludeFromBackup: true)
    try Storage.copy(from: bundle,
                     resourceName: "ios-host.js",
                     dstDir: baseDir,
                     excludeFromBackup: true)
    try Storage.copy(from: bundle,
                     resourceName: "keymanios.js",
                     dstDir: baseDir,
                     excludeFromBackup: true)
    try Storage.copy(from: bundle,
                     resourceName: "keyman-sentry.js",
                     dstDir: baseDir,
                     excludeFromBackup: true)
    // For debug compilations - IF we have a sourcemap file, copy that over too.
    if bundle.url(forResource: "keyman.js.map", withExtension: nil) != nil {
      try Storage.copy(from: bundle,
                       resourceName: "keyman.js.map",
                       dstDir: baseDir,
                       excludeFromBackup: true)
    }
    try Storage.copy(from: bundle,
                     resourceName: "kmwosk.css",
                     dstDir: baseDir,
                     excludeFromBackup: true)
    try Storage.copy(from: bundle,
                     resourceName: "keymanweb-osk.ttf",
                     dstDir: baseDir,
                     excludeFromBackup: true)
  }

  func installDefaultKeyboard(from bundle: Bundle) throws {
    let defaultKeyboardDir = self.resourceDir(for: Defaults.keyboard)!
    try FileManager.default.createDirectory(at: defaultKeyboardDir, withIntermediateDirectories: true)

    // Since we only want to do this installation the first time (rather than constantly force-reinstalling
    // the resource), we don't want this excluded from backup.
    try Storage.copy(from: bundle,
                     resourceName: "\(Defaults.keyboard.id).kmp",
                     dstDir: defaultKeyboardDir,
                     excludeFromBackup: false)

    do {
      // Perform an auto-install of the keyboard's KMP if not already installed.
      let defaultKMPFile = defaultKeyboardDir.appendingPathComponent("\(Defaults.keyboard.id).kmp")
      let package = try ResourceFileManager.shared.prepareKMPInstall(from: defaultKMPFile) as! KeyboardKeymanPackage
      try ResourceFileManager.shared.install(resourceWithID: Defaults.keyboard.fullID, from: package)
    } catch {
      SentryManager.captureAndLog(error, message: "Failed to install the default keyboard from the bundled KMP: \(error)")
    }
  }

  func installDefaultLexicalModel(from bundle: Bundle) throws {
    let defaultLexicalModelDir = self.resourceDir(for: Defaults.lexicalModel)!

    try FileManager.default.createDirectory(at: defaultLexicalModelDir, withIntermediateDirectories: true)

    // Since we only want to do this installation the first time (rather than constantly force-reinstalling
    // the resource), we don't want this excluded from backup.
    try Storage.copy(from: bundle,
                     resourceName: "\(Defaults.lexicalModel.id).model.kmp",
                     dstDir: defaultLexicalModelDir,
                     excludeFromBackup: false)

    do {
      // Perform an auto-install of the lexical model's KMP if not already installed.
      let defaultKMPFile = defaultLexicalModelDir.appendingPathComponent("\(Defaults.lexicalModel.id).model.kmp")
      let package = try ResourceFileManager.shared.prepareKMPInstall(from: defaultKMPFile) as! LexicalModelKeymanPackage
      
      // Install all languages for the model, not just the default-listed one.
      try ResourceFileManager.shared.install(resourcesWithIDs: package.installables[0].map { $0.fullID }, from: package)
    } catch {
      SentryManager.captureAndLog(error, message: "Failed to install the default lexical model from the bundled KMP: \(error)")
    }
  }

  func copyFiles(to dst: Storage) throws {
    if FileManager.default.fileExists(atPath: dst.baseDir.path) {
      SentryManager.breadcrumbAndLog("Deleting \(dst.baseDir) for copy from \(baseDir) to \(dst.baseDir)")
      try FileManager.default.removeItem(at: dst.baseDir)
    }
    try FileManager.default.copyItem(at: baseDir, to: dst.baseDir)
  }

  func copyUserDefaults(to dst: Storage, withKeys keys: [String], shouldOverwrite: Bool) {
    for key in keys {
      if shouldOverwrite || dst.userDefaults.object(forKey: key) == nil {
        dst.userDefaults.set(userDefaults.object(forKey: key), forKey: key)
      }
    }
    dst.userDefaults.synchronize()
  }

  private static func copy(from bundle: Bundle, resourceName: String, dstDir: URL, excludeFromBackup: Bool = false) throws {
    guard let srcURL = bundle.url(forResource: resourceName, withExtension: nil) else {
      throw KeymanError.missingFile(resourceName, true)
    }
    let dstURL = dstDir.appendingPathComponent(srcURL.lastPathComponent)

    return try Storage.copy(at: srcURL, to: dstURL, excludeFromBackup: true)
  }

  private static func copyDirectoryContents(at srcDir: URL, to dstDir: URL) throws {
    let srcContents = try FileManager.default.contentsOfDirectory(at: srcDir, includingPropertiesForKeys: [])
    for srcFile in srcContents {
      try Storage.copy(at: srcFile, to: dstDir.appendingPathComponent(srcFile.lastPathComponent), excludeFromBackup: true)
    }
  }

  private static func addSkipBackupAttribute(to url: URL) throws {
    var url = url
    var resourceValues = URLResourceValues()
    resourceValues.isExcludedFromBackup = true

    // Writes values to the backing store. It is not only mutating the URL in memory.
    try url.setResourceValues(resourceValues)
  }

  /// Copy and exclude from iCloud backup if `src` is newer than `dst`. Does nothing for a directory.
  static func copy(at src: URL,
                   to dst: URL,
                   shouldOverwrite: Bool = true,
                   excludeFromBackup: Bool = false) throws {
    let fm = FileManager.default

    var isDirectory: ObjCBool = false
    let fileExists = fm.fileExists(atPath: src.path, isDirectory: &isDirectory)
    if isDirectory.boolValue {
      return
    }

    if !fileExists {
      throw KeymanError.missingFile(src.path, false)
    }

    if !fm.fileExists(atPath: dst.path) {
      try fm.copyItem(at: src, to: dst)
    } else if shouldOverwrite {
      try fm.removeItem(at: dst)
      try fm.copyItem(at: src, to: dst)
    } else {
      return
    }

    if excludeFromBackup {
      try Storage.addSkipBackupAttribute(to: dst)
    }
  }
}
