//===----------------------------------------------------------------------===//
// Copyright © 2026 Apple Inc. and the container project authors.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
//   https://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//===----------------------------------------------------------------------===//

import ContainerizationArchive
import ContainerizationOS
import CryptoKit
import Foundation

public final class Archiver: Sendable {
    public struct ArchiveEntryInfo: Sendable, Codable {
        public let pathOnHost: URL
        public let pathInArchive: URL

        public let owner: UInt32?
        public let group: UInt32?
        public let permissions: UInt16?

        public init(
            pathOnHost: URL,
            pathInArchive: URL,
            owner: UInt32? = nil,
            group: UInt32? = nil,
            permissions: UInt16? = nil
        ) {
            self.pathOnHost = pathOnHost
            self.pathInArchive = pathInArchive
            self.owner = owner
            self.group = group
            self.permissions = permissions
        }
    }

    public static func compress(
        source: URL,
        destination: URL,
        followSymlinks: Bool = false,
        writerConfiguration: ArchiveWriterConfiguration = ArchiveWriterConfiguration(format: .paxRestricted, filter: .gzip),
        closure: (URL) -> ArchiveEntryInfo?
    ) throws -> SHA256.Digest {
        let source = source.standardizedFileURL
        let destination = destination.standardizedFileURL

        let fileManager = FileManager.default
        try? fileManager.removeItem(at: destination)

        var hasher = SHA256()

        do {
            let directory = destination.deletingLastPathComponent()
            try fileManager.createDirectory(at: directory, withIntermediateDirectories: true)

            guard
                let enumerator = FileManager.default.enumerator(atPath: source.path)
            else {
                throw Error.fileDoesNotExist(source)
            }

            var entryInfo = [ArchiveEntryInfo]()
            if !source.isDirectory {
                if let info = closure(source) {
                    entryInfo.append(info)
                }
            } else {
                let relPaths = enumerator.compactMap { $0 as? String }
                for relPath in relPaths.sorted(by: { $0 < $1 }) {
                    let url = source.appending(path: relPath).standardizedFileURL
                    guard let info = closure(url) else {
                        continue
                    }
                    entryInfo.append(info)
                }
            }

            let archiver = try ArchiveWriter(
                configuration: writerConfiguration
            )
            try archiver.open(file: destination)

            let encoder = JSONEncoder()
            encoder.outputFormatting = .sortedKeys

            for info in entryInfo {
                guard let entry = try Self._createEntry(entryInfo: info) else {
                    throw Error.failedToCreateEntry
                }
                hasher.update(data: try encoder.encode(entry))
                try Self._compressFile(item: info.pathOnHost, entry: entry, archiver: archiver, hasher: &hasher)
            }
            try archiver.finishEncoding()
        } catch {
            try? fileManager.removeItem(at: destination)
            throw error
        }

        return hasher.finalize()
    }

    // MARK: private functions
    private static func _compressFile(item: URL, entry: WriteEntry, archiver: ArchiveWriter, hasher: inout SHA256) throws {
        let writer = archiver.makeTransactionWriter()
        let bufferSize = Int(1.mib())
        let readBuffer = UnsafeMutablePointer<UInt8>.allocate(capacity: bufferSize)
        defer { readBuffer.deallocate() }
        try writer.writeHeader(entry: entry)
        if entry.fileType == .regular {
            // We need to write the data into the archive only if its a regular file
            // Symlinks and directories require us to only write the archive header
            guard let stream = InputStream(url: item) else {
                throw Error.failedToCreateInputStream(item)
            }
            stream.open()
            defer { stream.close() }
            while true {
                let byteRead = stream.read(readBuffer, maxLength: bufferSize)
                if byteRead < 0 {
                    // stream.read returns -1 on error (e.g. TCC access denial under /Users/)
                    let streamError = stream.streamError
                    throw Error.failedToReadFile(item, streamError)
                }
                if byteRead == 0 {
                    break
                }
                let data = Data(bytesNoCopy: UnsafeMutableRawPointer(mutating: readBuffer), count: byteRead, deallocator: .none)
                hasher.update(data: data)
                try data.withUnsafeBytes { pointer in
                    try writer.writeChunk(data: pointer)
                }
            }
        }
        try writer.finish()
    }

    private static func _createEntry(entryInfo: ArchiveEntryInfo, pathPrefix: String = "") throws -> WriteEntry? {
        let entry = WriteEntry()
        let fileManager = FileManager.default
        let attributes = try fileManager.attributesOfItem(atPath: entryInfo.pathOnHost.path)

        if let fileType = attributes[.type] as? FileAttributeType {
            switch fileType {
            case .typeBlockSpecial, .typeCharacterSpecial, .typeSocket:
                return nil
            case .typeDirectory:
                entry.fileType = .directory
            case .typeRegular:
                entry.fileType = .regular
            case .typeSymbolicLink:
                entry.fileType = .symbolicLink
                let symlinkTarget = try fileManager.destinationOfSymbolicLink(atPath: entryInfo.pathOnHost.path)
                entry.symlinkTarget = symlinkTarget
            default:
                return nil
            }
        }
        if let posixPermissions = attributes[.posixPermissions] as? NSNumber {
            #if os(macOS)
            entry.permissions = posixPermissions.uint16Value
            #else
            entry.permissions = posixPermissions.uint32Value
            #endif
        }
        if let fileSize = attributes[.size] as? UInt64 {
            entry.size = Int64(fileSize)
        }
        if let uid = attributes[.ownerAccountID] as? NSNumber {
            entry.owner = uid.uint32Value
        }
        if let gid = attributes[.groupOwnerAccountID] as? NSNumber {
            entry.group = gid.uint32Value
        }
        if let creationDate = attributes[.creationDate] as? Date {
            entry.creationDate = creationDate
        }
        if let modificationDate = attributes[.modificationDate] as? Date {
            entry.modificationDate = modificationDate
        }

        // Apply explicit overrides from ArchiveEntryInfo when provided
        if let overrideOwner = entryInfo.owner {
            entry.owner = overrideOwner
        }
        if let overrideGroup = entryInfo.group {
            entry.group = overrideGroup
        }
        if let overridePerm = entryInfo.permissions {
            #if os(macOS)
            entry.permissions = overridePerm
            #else
            entry.permissions = UInt32(overridePerm)
            #endif
        }

        let pathTrimmed = Self._trimPathPrefix(entryInfo.pathInArchive.relativePath, pathPrefix: pathPrefix)
        entry.path = pathTrimmed
        return entry
    }

    private static func _trimPathPrefix(_ path: String, pathPrefix: String) -> String {
        guard !path.isEmpty && !pathPrefix.isEmpty else {
            return path
        }

        let decodedPath = path.removingPercentEncoding ?? path

        guard decodedPath.hasPrefix(pathPrefix) else {
            return decodedPath
        }
        let trimmedPath = String(decodedPath.suffix(from: pathPrefix.endIndex))
        return trimmedPath
    }
}

extension Archiver {
    public enum Error: Swift.Error, CustomStringConvertible {
        case failedToCreateEntry
        case fileDoesNotExist(_ url: URL)
        case failedToCreateInputStream(_ url: URL)
        case failedToReadFile(_ url: URL, _ underlying: Swift.Error?)

        public var description: String {
            switch self {
            case .failedToCreateEntry:
                return "failed to create entry"
            case .fileDoesNotExist(let url):
                return "file \(url.path) does not exist"
            case .failedToCreateInputStream(let url):
                return "failed to create input stream for \(url.path)"
            case .failedToReadFile(let url, let underlying):
                if let underlying {
                    return "failed to read file \(url.path): \(underlying)"
                }
                return "failed to read file \(url.path)"
            }
        }
    }
}

extension WriteEntry: @retroactive Encodable {
    enum CodingKeys: String, CodingKey {
        case path
        case fileType
        case size
        case permissions
        case owner
        case group
        case symlinkTarget
        case hardlink
        case creationDate
        case modificationDate
        case contentAccessDate
    }

    public func encode(to encoder: any Encoder) throws {
        var container = encoder.container(keyedBy: CodingKeys.self)
        try container.encodeIfPresent(fileType.rawValue, forKey: .fileType)
        try container.encodeIfPresent(permissions, forKey: .permissions)
        try container.encodeIfPresent(path, forKey: .path)
        try container.encodeIfPresent(size, forKey: .size)
        try container.encodeIfPresent(owner, forKey: .owner)
        try container.encodeIfPresent(group, forKey: .group)
        try container.encodeIfPresent(symlinkTarget, forKey: .symlinkTarget)
        try container.encodeIfPresent(hardlink, forKey: .hardlink)
        try container.encodeIfPresent(creationDate, forKey: .creationDate)
        try container.encodeIfPresent(modificationDate, forKey: .modificationDate)
        try container.encodeIfPresent(contentAccessDate, forKey: .contentAccessDate)
    }
}
