import Foundation

enum SecurityScopeBookmark: String {

    case outputPath = "outputPathBookmark"

    case contentPath = "contentPathBookmark"
}

/**
 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
 -
 */
final class Storage {

    private(set) var baseFolder: URL

    private let encoder = JSONEncoder()

    private let decoder = JSONDecoder()

    private let fm = FileManager.default

    /**
     Create the storage.
     */
    init(baseFolder: URL) {
        self.baseFolder = baseFolder
        encoder.outputFormatting = [.prettyPrinted, .sortedKeys]
    }

    // MARK: Helper

    private func subFolder(_ name: String) -> URL {
        baseFolder.appending(path: name, directoryHint: .isDirectory)
    }

    private func files(in folder: URL) throws -> [URL] {
        do {
            return try fm.contentsOfDirectory(at: folder, includingPropertiesForKeys: [.isDirectoryKey])
                .filter { !$0.hasDirectoryPath }
        } catch {
            print("Failed to get files in folder \(folder.path): \(error)")
            throw error
        }
    }

    private func fileNames(in folder: URL) throws -> [String] {
        try fm.contentsOfDirectory(atPath: folder.path())
            .filter { !$0.hasPrefix(".") }
            .sorted()
    }

    private func files(in folder: URL, type: String) throws -> [URL] {
        try files(in: folder).filter { $0.pathExtension == type }
    }

    // MARK: Folders

    func update(baseFolder: URL) throws {
        self.baseFolder = baseFolder
        try createFolderStructure()
    }

    private func create(folder: URL) throws {
        guard !FileManager.default.fileExists(atPath: folder.path) else {
            return
        }
        try FileManager.default.createDirectory(at: folder, withIntermediateDirectories: true)
    }

    func createFolderStructure() throws {
        try create(folder: pagesFolder)
        try create(folder: filesFolder)
        try create(folder: postsFolder)
        try create(folder: tagsFolder)
    }

    // MARK: Pages

    /// The folder path where the markdown and metadata files of the pages are stored (by their id/url component)
    private var pagesFolder: URL { subFolder("pages") }

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

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

    private func pageContentUrl(pageId: String, language: ContentLanguage) -> URL {
        pagesFolder.appending(path: pageContentFileName(pageId, language), directoryHint: .notDirectory)
    }

    private func pageMetadataUrl(pageId: String) -> URL {
        pagesFolder.appending(path: pageFileName(pageId), directoryHint: .notDirectory)
    }

    @discardableResult
    func save(pageContent: String, for pageId: String, language: ContentLanguage) -> Bool {
        let contentUrl = pageContentUrl(pageId: pageId, language: language)
        return write(content: pageContent, to: contentUrl, type: "page", id: pageId)
    }

    @discardableResult
    func save(pageMetadata: PageFile, for pageId: String) -> Bool {
        let contentUrl = pageMetadataUrl(pageId: pageId)
        return write(pageMetadata, type: "page", id: pageId, to: contentUrl)
    }

    @discardableResult
    func copyPageContent(from url: URL, for pageId: String, language: ContentLanguage) -> Bool {
        let contentUrl = pageContentUrl(pageId: pageId, language: language)
        return copy(file: url, to: contentUrl, type: "page content", id: pageId)
    }

    func loadAllPages() throws -> [String : PageFile] {
        try loadAll(in: pagesFolder)
    }

    func pageContent(for pageId: String, language: ContentLanguage) -> String {
        let contentUrl = pageContentUrl(pageId: pageId, language: language)
        guard fm.fileExists(atPath: contentUrl.path()) else {
            print("No file at \(contentUrl.path())")
            return "New file"
        }
        do {
            return try String(contentsOf: contentUrl, encoding: .utf8)
        } catch {
            print("Failed to load page content for \(pageId) (\(language)): \(error)")
            return error.localizedDescription
        }
    }

    func deletePageFiles(notIn pages: [String]) throws {
        var files = Set(pages.map(pageFileName))
        for language in ContentLanguage.allCases {
            files.formUnion(pages.map { pageContentFileName($0, language) })
        }
        try deleteFiles(in: pagesFolder, notIn: files)
    }

    // MARK: Posts

    /// The folder path where the markdown files of the posts are stored (by their unique id/url component)
    private var postsFolder: URL { subFolder("posts") }

    private func postFileUrl(postId: String) -> URL {
        postsFolder.appending(path: postId, directoryHint: .notDirectory).appendingPathExtension("json")
    }

    @discardableResult
    func save(post: PostFile, for postId: String) -> Bool {
        let contentUrl = postFileUrl(postId: postId)
        return write(post, type: "post", id: postId, to: contentUrl)
    }

    func loadAllPosts() throws -> [String : PostFile] {
        try loadAll(in: postsFolder)
    }

    private func post(at url: URL) throws -> PostFile {
        try read(at: url)
    }

    private func postContent(for postId: String) throws -> PostFile {
        let url = postFileUrl(postId: postId)
        return try post(at: url)
    }

    func deletePostFiles(notIn posts: [String]) throws {
        let files = Set(posts.map { $0 + ".json" })
        try deleteFiles(in: postsFolder, notIn: files)
    }

    // MARK: Tags

    /// The folder path where the source images are stored (by their unique name)
    private var tagsFolder: URL { subFolder("tags") }

    private func tagFileUrl(tagId: String) -> URL {
        tagsFolder.appending(path: tagId, directoryHint: .notDirectory)
    }

    private func tagMetadataUrl(tagId: String) -> URL {
        tagFileUrl(tagId: tagId).appendingPathExtension("json")
    }

    @discardableResult
    func save(tagMetadata: TagFile, for tagId: String) -> Bool {
        let contentUrl = tagMetadataUrl(tagId: tagId)
        return write(tagMetadata, type: "tag", id: tagId, to: contentUrl)
    }

    func loadAllTags() throws -> [String : TagFile] {
        try loadAll(in: tagsFolder)
    }

    func deleteTagFiles(notIn tags: [String]) throws {
        let files = Set(tags.map { $0 + ".json" })
        try deleteFiles(in: tagsFolder, notIn: files)
    }

    // MARK: Files

    /// The folder path where other files are stored (by their unique name)
    var filesFolder: URL { subFolder("files") }

    private func fileUrl(file: String) -> URL {
        filesFolder.appending(path: file, directoryHint: .notDirectory)
    }

    @discardableResult
    func copyFile(at url: URL, fileId: String) -> Bool {
        let contentUrl = fileUrl(file: fileId)
        return copy(file: url, to: contentUrl, type: "file", id: fileId)
    }

    func loadAllFiles() throws -> [String : URL] {
        try files(in: filesFolder).reduce(into: [:]) { files, url in
            files[url.lastPathComponent] = url
        }
    }

    func deleteFiles(notIn fileSet: [String]) throws {
        try deleteFiles(in: filesFolder, notIn: Set(fileSet))
    }

    // MARK: Website data

    private var settingsDataUrl: URL {
        baseFolder.appending(path: "settings.json", directoryHint: .notDirectory)
    }

    func loadSettings() throws -> SettingsFile {
        try read(at: settingsDataUrl)
    }

    @discardableResult
    func save(settings: SettingsFile) -> Bool {
        write(settings, type: "Settings", id: "-", to: settingsDataUrl)
    }

    // MARK: Image generation data

    private var generatedImagesListUrl: URL {
        baseFolder.appending(component: "generated-images.json", directoryHint: .notDirectory)
    }

    func loadListOfGeneratedImages() -> [String : [String]] {
        let url = generatedImagesListUrl
        guard url.exists else {
            return [:]
        }
        do {
            return try read(at: url)
        } catch {
            print("Failed to read list of generated images: \(error)")
            return [:]
        }
    }

    func save(listOfGeneratedImages: [String : [String]]) -> Bool {
        write(listOfGeneratedImages, type: "generated images list", id: "-", to: generatedImagesListUrl)
    }

    // MARK: Folder access

    func save(folderUrl url: URL, in bookmark: SecurityScopeBookmark) {
        do {
            let bookmarkData = try url.bookmarkData(options: .withSecurityScope, includingResourceValuesForKeys: nil, relativeTo: nil)
            UserDefaults.standard.set(bookmarkData, forKey: bookmark.rawValue)
        } catch {
            print("Failed to create security-scoped bookmark: \(error)")
        }
    }

    func write(in scope: SecurityScopeBookmark, operation: (URL) -> Bool) -> Bool {
        guard let bookmarkData = UserDefaults.standard.data(forKey: scope.rawValue) else {
            print("No bookmark data to access folder")
            return false
        }
        var isStale = false
        let folderURL: URL
        do {
            // Resolve the bookmark to get the folder URL
            folderURL = try URL(resolvingBookmarkData: bookmarkData, options: .withSecurityScope, relativeTo: nil, bookmarkDataIsStale: &isStale)
        } catch {
            print("Failed to resolve bookmark: \(error)")
            return false
        }

        if isStale {
            print("Bookmark is stale, consider saving a new bookmark.")
        }

        // Start accessing the security-scoped resource
        if folderURL.startAccessingSecurityScopedResource() {
            let result = operation(folderURL)
            folderURL.stopAccessingSecurityScopedResource()
            return result
        } else {
            print("Failed to access folder: \(folderURL.path)")
            return false
        }
    }

    // MARK: Writing files

    private func deleteFiles(in folder: URL, notIn fileSet: Set<String>) throws {
        let filesToDelete = try files(in: folder)
            .filter { !fileSet.contains($0.lastPathComponent) }

        for file in filesToDelete {
            try fm.removeItem(at: file)
            print("Deleted \(file.path())")
        }
    }

    /**
     Encode a value and write it to a file, if the content changed
     */
    private func write<T>(_ value: T, type: String, id: String, to file: URL) -> Bool where T: Encodable {
        let content: Data
        do {
            content = try encoder.encode(value)
        } catch {
            print("Failed to encode content of \(type) '\(id)': \(error)")
            return false
        }
        return write(data: content, type: type, id: id, to: file)
    }

    /**
     Write data to a file if the content changed
     */
    private func write(data: Data, type: String, id: String, to file: URL) -> Bool {
        if fm.fileExists(atPath: file.path()) {
            // Check if content is the same, to prevent unnecessary writes
            do {
                let oldData = try Data(contentsOf: file)
                if data == oldData {
                    // File is the same, don't write
                    return true
                }
            } catch {
                print("Failed to read file \(file.path()) for equality check: \(error)")
                // No check possible, write file
            }
        } else {
            print("Writing new file \(file.path())")
        }
        do {
            try data.write(to: file, options: .atomic)
            print("Saved file \(file.path())")
            return true
        } catch {
            print("Failed to save content for \(type) '\(id)': \(error)")
            return false
        }
    }

    private func copy(file: URL, to destination: URL, type: String, id: String) -> Bool {
        do {
            try fm.copyItem(at: file, to: destination)
            return true
        } catch {
            print("Failed to copy content file for \(type) '\(id)': \(error)")
            return false
        }
    }

    private func write(content: String, to file: URL, type: String, id: String) -> Bool {
        guard let data = content.data(using: .utf8) else {
            print("Failed to convert string to data for \(type) '\(id)'")
            return false
        }
        return write(data: data, type: type, id: id, to: file)
    }

    private func read<T>(at url: URL) throws -> T where T: Decodable {
        let data = try Data(contentsOf: url)
        return try decoder.decode(T.self, from: data)
    }

    private func loadAll<T>(in folder: URL) throws -> [String : T] where T: Decodable {
        try files(in: folder, type: "json").reduce(into: [:]) { items, url in
            let id = url.deletingPathExtension().lastPathComponent
            let item: T = try read(at: url)
            items[id] = item
        }
    }
}