//===----------------------------------------------------------------------===//
// Copyright © 2025-2026 Apple Inc. and the Containerization 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 CArchive
import Foundation
import SystemPackage

/// A class responsible for writing archives in various formats.
public final class ArchiveWriter {
    private static let chunkSize = 4 * 1024 * 1024

    var underlying: OpaquePointer?

    /// Initialize a new `ArchiveWriter` with the given configuration.
    /// This method attempts to initialize an empty archive in memory, failing which it throws a `unableToCreateArchive` error.
    public init(configuration: ArchiveWriterConfiguration) throws {
        // because for some bizarre reason, UTF8 paths won't work unless this process explicitly sets a locale like en_US.UTF-8
        try Self.attemptSetLocales(locales: configuration.locales)

        guard let underlying = archive_write_new() else { throw ArchiveError.unableToCreateArchive }
        self.underlying = underlying

        try setFormat(configuration.format)
        try addFilter(configuration.filter)
        try setOptions(configuration.options)
    }

    /// Initialize a new `ArchiveWriter` for writing into the specified file with the given configuration options.
    public convenience init(format: Format, filter: Filter, options: [Options] = [], locales: [String] = ArchiveWriterConfiguration.defaultLocales, file: URL) throws {
        let config = ArchiveWriterConfiguration(
            format: format,
            filter: filter,
            options: options,
            locales: locales
        )
        try self.init(configuration: config)
        try self.open(file: file)
    }

    /// Opens the given file for writing data into
    public func open(file: URL) throws {
        guard let underlying = underlying else { throw ArchiveError.noUnderlyingArchive }
        let res = archive_write_open_filename(underlying, file.path)
        try wrap(res, ArchiveError.unableToOpenArchive, underlying: underlying)
    }

    /// Opens the given fd for writing data into
    public func open(fileDescriptor: Int32) throws {
        guard let underlying = underlying else { throw ArchiveError.noUnderlyingArchive }
        let res = archive_write_open_fd(underlying, fileDescriptor)
        try wrap(res, ArchiveError.unableToOpenArchive, underlying: underlying)
    }

    /// Performs any necessary finalizations on the archive and releases resources.
    public func finishEncoding() throws {
        guard let u = underlying else { return }
        underlying = nil
        let r = archive_free(u)
        guard r == ARCHIVE_OK else {
            throw ArchiveError.unableToCloseArchive(r)
        }
    }

    deinit {
        if let u = underlying {
            archive_free(u)
            underlying = nil
        }
    }

    private static func attemptSetLocales(locales: [String]) throws {
        for locale in locales {
            if setlocale(LC_ALL, locale) != nil {
                return
            }
        }
        throw ArchiveError.failedToSetLocale(locales: locales)
    }
}

public class ArchiveWriterTransaction {
    private let writer: ArchiveWriter

    fileprivate init(writer: ArchiveWriter) {
        self.writer = writer
    }

    public func writeHeader(entry: WriteEntry) throws {
        try writer.writeHeader(entry: entry)
    }

    public func writeChunk(data: UnsafeRawBufferPointer) throws {
        try writer.writeData(data: data)
    }

    public func finish() throws {
        try writer.finishEntry()
    }
}

extension ArchiveWriter {
    public func makeTransactionWriter() -> ArchiveWriterTransaction {
        ArchiveWriterTransaction(writer: self)
    }

    /// Create a new entry in the archive with the given properties.
    /// - Parameters:
    ///   - entry: A `WriteEntry` object describing the metadata of the entry to be created
    ///            (e.g., name, modification date, permissions).
    ///   - data: The `Data` object containing the content for the new entry.
    public func writeEntry(entry: WriteEntry, data: Data) throws {
        try data.withUnsafeBytes { bytes in
            try writeEntry(entry: entry, data: bytes)
        }
    }

    /// Creates a new entry in the archive with the given properties.
    ///
    /// This method performs the following:
    /// 1. Writes the archive header using the provided `WriteEntry` metadata.
    /// 2. Writes the content from the `UnsafeRawBufferPointer` into the archive.
    /// 3. Finalizes the entry in the archive.
    ///
    /// - Parameters:
    ///   - entry: A `WriteEntry` object describing the metadata of the entry to be created
    ///            (e.g., name, modification date, permissions, type).
    ///   - data: An optional `UnsafeRawBufferPointer` containing the raw bytes for the new entry's
    ///           content. Pass `nil` for entries that do not have content data (e.g., directories, symlinks).
    public func writeEntry(entry: WriteEntry, data: UnsafeRawBufferPointer?) throws {
        try writeHeader(entry: entry)
        if let data = data {
            try writeData(data: data)
        }
        try finishEntry()
    }

    fileprivate func writeHeader(entry: WriteEntry) throws {
        guard let underlying = self.underlying else { throw ArchiveError.noUnderlyingArchive }

        try wrap(
            archive_write_header(underlying, entry.underlying), ArchiveError.unableToWriteEntryHeader,
            underlying: underlying)
    }

    fileprivate func finishEntry() throws {
        guard let underlying = self.underlying else { throw ArchiveError.noUnderlyingArchive }

        archive_write_finish_entry(underlying)
    }

    fileprivate func writeData(data: UnsafeRawBufferPointer) throws {
        guard let underlying = self.underlying else { throw ArchiveError.noUnderlyingArchive }

        var offset = 0
        while offset < data.count {
            guard let baseAddress = data.baseAddress?.advanced(by: offset) else {
                throw ArchiveError.invalidBaseAddressArchiveWrite
            }
            let result = archive_write_data(underlying, baseAddress, data.count - offset)
            guard result > 0 else {
                throw ArchiveError.unableToWriteData(result)
            }
            offset += Int(result)
        }
    }
}

extension ArchiveWriter {
    private func archive(_ relativePath: FilePath, dirPath: FilePath) throws {
        let fm = FileManager.default

        let fullPath = dirPath.appending(relativePath.string)

        var statInfo = stat()
        guard lstat(fullPath.string, &statInfo) == 0 else {
            let errNo = errno
            let err = POSIXErrorCode(rawValue: errNo) ?? .EINVAL
            throw ArchiveError.failedToCreateArchive("lstat failed for '\(fullPath)': \(POSIXError(err))")
        }

        let mode = statInfo.st_mode
        let uid = statInfo.st_uid
        let gid = statInfo.st_gid
        var size: Int64 = 0
        let type: URLFileResourceType

        if (mode & S_IFMT) == S_IFREG {
            type = .regular
            size = Int64(statInfo.st_size)
        } else if (mode & S_IFMT) == S_IFDIR {
            type = .directory
        } else if (mode & S_IFMT) == S_IFLNK {
            type = .symbolicLink
        } else {
            return
        }

        #if os(macOS)
        let created = Date(timeIntervalSince1970: Double(statInfo.st_ctimespec.tv_sec))
        let access = Date(timeIntervalSince1970: Double(statInfo.st_atimespec.tv_sec))
        let modified = Date(timeIntervalSince1970: Double(statInfo.st_mtimespec.tv_sec))
        #else
        let created = Date(timeIntervalSince1970: Double(statInfo.st_ctim.tv_sec))
        let access = Date(timeIntervalSince1970: Double(statInfo.st_atim.tv_sec))
        let modified = Date(timeIntervalSince1970: Double(statInfo.st_mtim.tv_sec))
        #endif

        let entry = WriteEntry()
        if type == .symbolicLink {
            let targetPath = try fm.destinationOfSymbolicLink(atPath: fullPath.string)
            // Resolve the target relative to the symlink's parent, not the archive root.
            let symlinkParent = fullPath.removingLastComponent()
            let resolvedFull = symlinkParent.appending(targetPath).lexicallyNormalized()
            guard resolvedFull.starts(with: dirPath) else {
                return
            }
            entry.symlinkTarget = targetPath
        }

        entry.path = relativePath.string
        entry.size = size
        entry.creationDate = created
        entry.modificationDate = modified
        entry.contentAccessDate = access
        entry.fileType = type
        entry.group = gid
        entry.owner = uid
        entry.permissions = mode
        if type == .regular {
            let buf = UnsafeMutableRawBufferPointer.allocate(byteCount: Self.chunkSize, alignment: 1)
            guard let baseAddress = buf.baseAddress else {
                throw ArchiveError.failedToCreateArchive("cannot create temporary buffer of size \(Self.chunkSize)")
            }
            defer { buf.deallocate() }
            let fd = Foundation.open(fullPath.string, O_RDONLY)
            guard fd >= 0 else {
                let err = POSIXErrorCode(rawValue: errno) ?? .EINVAL
                throw ArchiveError.failedToCreateArchive("cannot open file \(fullPath.string) for reading: \(err)")
            }
            defer { close(fd) }
            try self.writeHeader(entry: entry)
            while true {
                let n = read(fd, baseAddress, Self.chunkSize)
                if n == 0 { break }
                if n < 0 {
                    let err = POSIXErrorCode(rawValue: errno) ?? .EIO
                    throw ArchiveError.failedToCreateArchive("failed to read from file \(fullPath.string): \(err)")
                }
                try self.writeData(data: UnsafeRawBufferPointer(start: baseAddress, count: n))
            }
            try self.finishEntry()
        } else {
            try self.writeEntry(entry: entry, data: nil)
        }
    }

    /// Recursively archives the content of a directory. Regular files, symlinks and directories are added into the archive.
    /// Note: Symlinks are added to the archive if both the source and target for the symlink are both contained in the top level directory.
    public func archiveDirectory(_ dir: URL) throws {
        let fm = FileManager.default
        let dirPath = FilePath(dir.path)

        guard let enumerator = fm.enumerator(atPath: dirPath.string) else {
            throw POSIXError(.ENOTDIR)
        }

        // Emit a leading "./" entry for the root directory, matching GNU/BSD tar behavior.
        var rootStat = stat()
        guard lstat(dirPath.string, &rootStat) == 0 else {
            let err = POSIXErrorCode(rawValue: errno) ?? .EINVAL
            throw ArchiveError.failedToCreateArchive("lstat failed for '\(dirPath)': \(POSIXError(err))")
        }
        let rootEntry = WriteEntry()
        rootEntry.path = "./"
        rootEntry.size = 0
        rootEntry.fileType = .directory
        rootEntry.owner = rootStat.st_uid
        rootEntry.group = rootStat.st_gid
        rootEntry.permissions = rootStat.st_mode
        #if os(macOS)
        rootEntry.creationDate = Date(timeIntervalSince1970: Double(rootStat.st_ctimespec.tv_sec))
        rootEntry.contentAccessDate = Date(timeIntervalSince1970: Double(rootStat.st_atimespec.tv_sec))
        rootEntry.modificationDate = Date(timeIntervalSince1970: Double(rootStat.st_mtimespec.tv_sec))
        #else
        rootEntry.creationDate = Date(timeIntervalSince1970: Double(rootStat.st_ctim.tv_sec))
        rootEntry.contentAccessDate = Date(timeIntervalSince1970: Double(rootStat.st_atim.tv_sec))
        rootEntry.modificationDate = Date(timeIntervalSince1970: Double(rootStat.st_mtim.tv_sec))
        #endif
        try self.writeHeader(entry: rootEntry)

        for case let relativePath as String in enumerator {
            try archive(FilePath(relativePath), dirPath: dirPath)
        }
    }

    public func archive(_ paths: [FilePath], base: FilePath) throws {
        let fm = FileManager.default
        let base = base.lexicallyNormalized()

        for path in paths {
            guard path.starts(with: base) else {
                throw ArchiveError.failedToCreateArchive("'\(path.string)' is not under '\(base.string)'")
            }

            let relativePath = path.components.dropFirst(base.components.count)
                .reduce(into: FilePath("")) { $0.append($1) }

            var isDir: ObjCBool = false
            _ = fm.fileExists(atPath: path.string, isDirectory: &isDir)
            if isDir.boolValue {
                guard let enumerator = fm.enumerator(atPath: path.string) else {
                    throw POSIXError(.ENOTDIR)
                }

                try archive(relativePath, dirPath: base)
                for case let child as String in enumerator {
                    let childPath = relativePath.appending(child)

                    try archive(childPath, dirPath: base)
                }
            } else {
                try archive(relativePath, dirPath: base)
            }
        }
    }
}
