import Foundation

/**
 A class that handles the storage of the website data.

 BaseFolder
 - pages: Contains the markdown files of the localized pages, file name is the url
 - images: Contains the raw images
 - files: Contains additional files
 - videos: Contains raw video files
 - posts: Contains the markdown files for localized posts, file name is the post id

 - Note: The base folder and output folder are stored as security-scoped bookmarks in user defaults.
 */
final class Storage: ObservableObject {

    // MARK: Content folder structure

    private let filesFolderName = "files"

    private let fileInfoFolderName = "file-metadata"

    private let pagesFolderName = "pages"

    private let postsFolderName = "posts"

    private let tagsFolderName = "tags"

    private let videoThumbnailFolderName = "thumbnails"

    private let outputPathFileName = "outputPath.bin"

    private let settingsDataFileName = "settings.json"

    private let contentPathBookmarkKey = "contentPathBookmark"

    // MARK: Properties

#warning("Rework to make this non-optional by creating a wrapper class")
    @Published
    var contentScope: SecurityBookmark?

    @Published
    var outputScope: SecurityBookmark?

    var errorNotification: StorageErrorCallback?

    var writeNotification: ((String) -> Void)?

    /**
     Create the storage.
     */
    init() {
        loadContentScope()
        loadOutputScope()
    }

    // MARK: Pages

    private func pageContentFileName(_ id: String, _ language: ContentLanguage) -> String {
        "\(id)-\(language.rawValue).md"
    }

    private func pageContentPath(page pageId: String, language: ContentLanguage) -> String {
        pagesFolderName + "/" + pageContentFileName(pageId, language)
    }

    private func pageMetadataPath(page pageId: String) -> String {
        pagesFolderName + "/" + pageId + ".json"
    }

    private func pageFileName(_ id: String) -> String {
        id + ".json"
    }

    func save(pageContent: String, for pageId: String, in language: ContentLanguage) -> Bool {
        guard let contentScope else { return false }
        let path = pageContentPath(page: pageId, language: language)
        return contentScope.write(pageContent, to: path)
    }

    func remove(pageContent pageId: String, in language: ContentLanguage) -> Bool {
        guard let contentScope else { return false }
        let path = pageContentPath(page: pageId, language: language)
        return contentScope.deleteFile(at: path)
    }

    func save(page: Page.Data, for pageId: String) -> Bool {
        guard let contentScope else { return false }
        let path = pageMetadataPath(page: pageId)
        return contentScope.encode(page, to: path)
    }

    func loadAllPages() -> [String : Page.Data]? {
        contentScope?.decodeJsonFiles(in: pagesFolderName)
    }

    func pageContent(for pageId: String, language: ContentLanguage) -> String? {
        guard let contentScope else { return nil }
        let path = pageContentPath(page: pageId, language: language)
        return contentScope.readString(at: path)
    }

    func hasPageContent(for pageId: String, language: ContentLanguage) -> Bool {
        guard let contentScope else { return false }
        let path = pageContentPath(page: pageId, language: language)
        return contentScope.hasFile(at: path)
    }

    /**
     Delete all files associated with pages that are not in the given set
     - Note: This function requires a security scope for the content path
     */
    func deletePageFiles(notIn pages: [String]) -> Bool {
        guard let contentScope else { return false }
        var files = Set(pages.map(pageFileName))
        for language in ContentLanguage.allCases {
            files.formUnion(pages.map { pageContentFileName($0, language) })
        }
        guard let deleted = contentScope.deleteFiles(in: pagesFolderName, notIn: files) else {
            return false
        }
        deleted.forEach { print("Deleted unused page file \($0)") }
        return true
    }

    func move(page pageId: String, to newId: String) -> Bool {
        guard let contentScope else { return false }

        guard contentScope.move(pageMetadataPath(page: pageId), to: pageMetadataPath(page: newId)) else {
            print("Failed to move page file \(pageId)")
            return false
        }
        // Move the existing content files
        var result = true
        for language in ContentLanguage.allCases {
            // Copy as many files as possible, since metadata was already moved
            // Don't fail early
            if !contentScope.move(
                pageContentPath(page: pageId, language: language),
                to: pageContentPath(page: newId, language: language),
                failIfMissing: false) {
                print("Failed to move content file \(language) of page \(pageId)")
                result = false
            }
        }
        return result
    }

    /**
     Completely delete a post file from the content folder
     */
    func delete(page pageId: String) -> Bool {
        guard let contentScope else { return false }
        guard contentScope.deleteFile(at: pageMetadataPath(page: pageId)) else {
            return false
        }
        // Move the existing content files
        var result = true
        for language in ContentLanguage.allCases {
            // Copy as many files as possible, since metadata was already moved
            // Don't fail early
            if !contentScope.deleteFile(at: pageContentPath(page: pageId, language: language)) {
                print("Failed to delete content file \(language) of page \(pageId)")
                result = false
            }
        }
        return result
    }

    // MARK: Posts

    private func postFileName(_ postId: String) -> String {
        postId + ".json"
    }

    private func postFilePath(post postId: String) -> String {
        postsFolderName + "/" + postFileName(postId)
    }

    func save(post: Post.Data, for postId: String) -> Bool {
        guard let contentScope else { return false }
        let path = postFilePath(post: postId)
        return contentScope.encode(post, to: path)
    }

    func loadAllPosts() -> [String : Post.Data]? {
        contentScope?.decodeJsonFiles(in: postsFolderName)
    }

    /**
     Delete all files associated with posts that are not in the given set
     - Note: This function requires a security scope for the content path
     */
    func deletePostFiles(notIn posts: [String]) -> Bool {
        guard let contentScope else { return false }
        let files = Set(posts.map(postFileName))
        guard let deleted = contentScope.deleteFiles(in: postsFolderName, notIn: files) else {
            return false
        }
        deleted.forEach { print("Deleted unused post file \($0)") }
        return true
    }

    func move(post postId: String, to newId: String) -> Bool {
        guard let contentScope else { return false }
        return contentScope.move(postFilePath(post: postId), to: postFilePath(post: newId))
    }

    /**
     Completely delete a post file from the content folder
     */
    func delete(post postId: String) -> Bool {
        guard let contentScope else { return false }
        return contentScope.deleteFile(at: postFilePath(post: postId))
    }

    // MARK: Tags

    private func tagFileName(tagId: String) -> String {
        tagId + ".json"
    }

    private func tagFilePath(tag tagId: String) -> String {
        tagsFolderName + "/" + tagFileName(tagId: tagId)
    }

    func save(tag: Tag.Data, for tagId: String) -> Bool {
        guard let contentScope else { return false }
        let path = tagFilePath(tag: tagId)
        return contentScope.encode(tag, to: path)
    }

    func loadAllTags() -> [String : Tag.Data]? {
        contentScope?.decodeJsonFiles(in: tagsFolderName)
    }

    /**
     Delete all files associated with tags that are not in the given set
     - Note: This function requires a security scope for the content path
     */
    func deleteTagFiles(notIn tags: [String]) -> Bool {
        guard let contentScope else { return false }
        let files = Set(tags.map { $0 + ".json" })
        guard let deleted = contentScope.deleteFiles(in: tagsFolderName, notIn: files) else {
            return false
        }
        deleted.forEach { print("Deleted unused tag file \($0)") }
        return true
    }

    func move(tag tagId: String, to newId: String) -> Bool {
        guard let contentScope else { return false }
        return contentScope.move(tagFilePath(tag: tagId), to: tagFilePath(tag: newId))
    }

    func delete(tag tagId: String) -> Bool {
        guard let contentScope else { return false }
        return contentScope.deleteFile(at: tagFilePath(tag: tagId))
    }

    // MARK: Files

    func size(of file: String) -> Int? {
        contentScope?.size(of: filePath(file: file))
    }

    /**
     The full path to a resource file in the content folder
     - Parameter file: The filename of the file
     - Note: Only for resource files, since path is relative to files folder
     */
    func path(toFile file: String) -> URL? {
        contentScope?.fullPath(to: filePath(file: file))
    }

    /**
     Completely delete a file resource from the content folder
     */
    func delete(file fileId: String) -> Bool {
        guard let contentScope else {
            return false
        }
        guard contentScope.deleteFile(at: filePath(file: fileId)) else {
            return false
        }
        guard contentScope.deleteFile(at: fileInfoPath(file: fileId)) else {
            return false
        }
        return contentScope.deleteFile(at: videoThumbnailPath(fileId))
    }

    /**
     Delete a file resource from the content folder, making it an external file
     */
    func removeFileContent(file fileId: String) -> Bool {
        guard let contentScope else {
            return false
        }
        guard contentScope.deleteFile(at: filePath(file: fileId)) else {
            return false
        }
        // Delete video thumbnail, which may not exist (not generated / not a video)
        return contentScope.deleteFile(at: videoThumbnailPath(fileId))
    }

    /**
     The full file path to a file in the output folder
     - Parameter relativePath: The path of the file relative to the output folder
     */
    func outputPath(to relativePath: String) -> URL? {
        outputScope?.fullPath(to: relativePath)
    }

    func openFinderWindow(withSelectedFile file: String) {
        contentScope?.openFinderWindow(relativePath: filePath(file: file))
    }

    private func filePath(file fileId: String) -> String {
        filesFolderName + "/" + fileId
    }

    /// The path to a metadata file for a file resource
    private func fileInfoPath(file fileId: String) -> String {
        fileInfoFolderName + "/" + fileId + ".json"
    }

    @discardableResult
    func deleteInOutputFolder(_ relativePath: String) -> Bool {
        guard let outputScope else { return false }
        return outputScope.deleteFile(at: relativePath)
    }

    /**
     Copy an external file to the content folder
     */
    func importExternalFile(at url: URL, fileId: String) -> Bool {
        guard let contentScope else { return false }
        return contentScope.copy(externalFile: url, to: filePath(file: fileId))
    }

    /**
     Move (rename) a file resource (moves file content and file info)
     */
    func move(file fileId: String, to newId: String) -> Bool {
        guard let contentScope else { return false }
        guard contentScope.move(filePath(file: fileId), to: filePath(file: newId)) else {
            return false
        }
        return contentScope.move(fileInfoPath(file: fileId), to: fileInfoPath(file: newId))
    }

    /**
     Copy a file resource to a path relative to the output folder
     */
    func copy(file fileId: String, to relativeOutputPath: String) -> Bool {
        guard let contentScope, let outputScope else { return false }
        didWrite(outputFile: relativeOutputPath)
        return contentScope.transfer(
            file: filePath(file: fileId),
            to: relativeOutputPath, of: outputScope)
    }

    /**
     Load the file metadata from the content folder.

     - Returns: A dictionary with the file ids as keys and the metadata file as a value.
     */
    func loadAllFiles() -> [String : (data: FileResource.Data, isExternal: Bool)]? {
        guard let contentScope else {
            return nil
        }
        guard let list: [String : FileResource.Data] = contentScope.decodeJsonFiles(in: fileInfoFolderName) else {
            return nil
        }
        guard let existingFiles = contentScope.fileNames(inRelativeFolder: filesFolderName).map(Set.init) else {
            return nil
        }
        return Dictionary(uniqueKeysWithValues: list.map { fileId, data in
            let isExternal = !existingFiles.contains(fileId)
            return (fileId, (data: data, isExternal: isExternal))
        })
    }

    @discardableResult
    func save(fileResource: FileResource.Data, for fileId: String) -> Bool {
        guard let contentScope else { return false }
        let path = fileInfoPath(file: fileId)
        return contentScope.encode(fileResource, to: path)
    }

    /**
     Delete all file resources that are not in the given set
     - Note: This function requires a security scope for the content path
     */
    func deleteFileResources(notIn fileSet: [String]) -> Bool {
        guard let contentScope else { return false }
        guard let deleted = contentScope.deleteFiles(in: filesFolderName, notIn: Set(fileSet)) else {
            return false
        }
        deleted.forEach { print("Deleted unused file \($0)") }
        return true
    }

    func fileContent(for fileId: String) -> String? {
        guard let contentScope else { return nil }
        let path = filePath(file: fileId)
        return contentScope.readString(at: path)
    }

    func fileData(for fileId: String) -> Data? {
        guard let contentScope else { return nil }
        let path = filePath(file: fileId)
        return contentScope.readData(at: path)
    }

    func save(fileData: Data, for fileId: String) -> Bool {
        guard let contentScope else { return false }
        let path = filePath(file: fileId)
        return contentScope.write(fileData, to: path)
    }

    func save(fileContent: String, for fileId: String) -> Bool {
        guard let contentScope else { return false }
        let path = filePath(file: fileId)
        return contentScope.write(fileContent, to: path)
    }

    func with<T>(file fileId: String, perform operation: (URL) async -> T?) async -> T? {
        guard let contentScope else { return nil }
        let path = filePath(file: fileId)
        return await contentScope.with(relativePath: path, perform: operation)
    }

    func with<T>(file fileId: String, perform operation: (URL) -> T?) -> T? {
        guard let contentScope else { return nil }
        let path = filePath(file: fileId)
        return contentScope.with(relativePath: path, perform: operation)
    }

    // MARK: Video thumbnails

    func hasVideoThumbnail(for videoId: String) -> Bool {
        guard let contentScope else { return false }
        let path = videoThumbnailPath(videoId)
        return contentScope.hasFile(at: path)
    }

    private func videoThumbnailPath(_ videoId: String) -> String {
        guard !videoId.hasSuffix("jpg") else {
            return videoThumbnailFolderName + "/" + videoId
        }
        return videoThumbnailFolderName + "/" + videoId + ".jpg"
    }

    func save(thumbnail: Data, for videoId: String) -> Bool {
        guard let contentScope else { return false }
        let path = videoThumbnailPath(videoId)
        return contentScope.write(thumbnail, to: path)
    }

    func getVideoThumbnail(for videoId: String) -> Data? {
        guard let contentScope else { return nil }
        let path = videoThumbnailPath(videoId)
        return contentScope.readData(at: path)
    }

    // MARK: Settings

    func loadSettings() -> Settings.Data? {
        guard let contentScope else { return nil }
        return contentScope.decode(at: settingsDataFileName)
    }

    func save(settings: Settings.Data) -> Bool {
        guard let contentScope else { return false }
        return contentScope.encode(settings, to: settingsDataFileName)
    }

    // MARK: Output files

    /**
     Write the content of a file to a relative path of the output folder.
     */
    @discardableResult
    func write(_ content: String, to relativeOutputPath: String) -> Bool {
        guard let outputScope else { return false }
        didWrite(outputFile: relativeOutputPath)
        return outputScope.write(content, to: relativeOutputPath)
    }

    /**
     Write the data of a file to a relative path of the output folder.
     */
    func write(_ data: Data, to relativeOutputPath: String) -> Bool {
        guard let outputScope else { return false }
        didWrite(outputFile: relativeOutputPath)
        return outputScope.write(data, to: relativeOutputPath)
    }

    func hasFileInOutputFolder(_ relativeOutputPath: String) -> Bool {
        guard let outputScope else { return false }
        return outputScope.hasFile(at: relativeOutputPath)
    }

    // MARK: Security bookmarks

    /**
     Save the content path url from a folder selection dialog,
     which contains a security scope.

     The security scope bookmark is saved in UserDefaults under the ``contentPathBookmarkKey`` key
     - Returns: True, if the bookmark was saved
     - Note: Updates ``canSave``, ``contentPathUrlIsStale``, and ``contentPath``
     */
    @discardableResult
    func save(contentPath: URL) -> Bool {
        guard let bookmarkData = encode(url: contentPath) else {
            return false
        }
        UserDefaults.standard.set(bookmarkData, forKey: contentPathBookmarkKey)
        return loadContentScope()
    }

    /**
     Attempts to load the content path url from UserDefaults.

     The url is loaded from UserDefaults under the ``contentPathBookmarkKey`` key

     - Note: Updates ``canSave``, ``contentPathUrlIsStale``, and ``contentPath``
     - Returns: `true`, if the url was loaded.
     */
    @discardableResult
    private func loadContentScope() -> Bool {
        guard let bookmarkData = UserDefaults.standard.data(forKey: contentPathBookmarkKey) else {
            print("No content path bookmark found")
            contentScope = nil
            return false
        }
        contentScope = decode(bookmark: bookmarkData)
        // Propagate errors
        contentScope?.errorNotification = { [weak self] error in
            self?.errorNotification?(error)
        }
        return contentScope != nil
    }

    @discardableResult
    private func loadOutputScope() -> Bool {
        guard let contentScope else { return false }
        guard let data = contentScope.readData(at: outputPathFileName) else {
            return false
        }
        outputScope = decode(bookmark: data)
        // Propagate errors
        outputScope?.errorNotification = { [weak self] error in
            self?.errorNotification?(error)
        }
        return outputScope != nil
    }

    func clearContentPath() {
        UserDefaults.standard.removeObject(forKey: contentPathBookmarkKey)
        contentScope = nil
        outputScope = nil
    }

    /**
     Decode the security scope data to get a url.
     */
    private func decode(bookmark: Data) -> SecurityBookmark? {
        do {
            var isStale = false
            let url = try URL(
                resolvingBookmarkData: bookmark,
                options: .withSecurityScope,
                relativeTo: nil,
                bookmarkDataIsStale: &isStale)
            return SecurityBookmark(url: url, isStale: isStale)
        } catch {
            print("Failed to resolve bookmark: \(error)")
            return nil
        }
    }

    private func encode(url: URL) -> Data? {
        do {
            return try url.bookmarkData(
                options: .withSecurityScope,
                includingResourceValuesForKeys: nil,
                relativeTo: nil)
        } catch {
            print("Failed to create security-scoped bookmark: \(error)")
            return nil
        }
    }

    @discardableResult
    func save(outputPath: URL) -> Bool {
        guard let contentScope,
              let bookmarkData = encode(url: outputPath),
              contentScope.write(bookmarkData, to: outputPathFileName) else {
            outputScope = nil
            return false
        }
        // TODO: Check if stale
        outputScope = SecurityBookmark(url: outputPath, isStale: false)
        // Propagate errors
        outputScope?.errorNotification = { [weak self] error in
            self?.errorNotification?(error)
        }
        return true
    }

    // MARK: Output notifications

    func didWrite(outputFile: String) {
        writeNotification?(outputFile)
    }

    func getAllOutputFiles() -> Set<String> {
        guard let outputScope else { return [] }
        return outputScope.getAllFiles()
    }
}
