//===----------------------------------------------------------------------===//
// 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 ContainerTestSupport
import ContainerizationArchive
import ContainerizationOCI
import Foundation
import Testing

@Suite
struct TestCLIImagesCommand {
    private let alpine = WarmupImage.alpine320.rawValue  // ghcr.io/linuxcontainers/alpine:3.20
    private let alpine318 = WarmupImage.alpine318.rawValue  // ghcr.io/linuxcontainers/alpine:3.18
    private let busybox = WarmupImage.busybox136.rawValue  // ghcr.io/containerd/busybox:1.36

    /// Host architecture string for platform tests.
    private var hostArchitecture: String {
        #if arch(arm64)
        return "arm64"
        #else
        return "amd64"
        #endif
    }

    @Test func testPull() async throws {
        try await ContainerFixture.with { f in
            try f.doPull(alpine)
            #expect(try f.isImagePresent(alpine), "expected \(alpine) to be present")
        }
    }

    @Test func testPullMulti() async throws {
        try await ContainerFixture.with { f in
            try f.doPull(alpine)
            try f.doPull(busybox)
            #expect(try f.isImagePresent(alpine), "expected \(alpine) to be present")
            #expect(try f.isImagePresent(busybox), "expected \(busybox) to be present")
        }
    }

    @Test func testPullPlatform() async throws {
        try await ContainerFixture.with { f in
            let os = "linux"
            let arch = "amd64"
            try f.doPull(alpine, args: ["--platform", "\(os)/\(arch)"])
            let output = try f.doInspectImages(alpine)
            #expect(output.count == 1)
            #expect(
                output[0].variants.contains { $0.platform.os == os && $0.platform.architecture == arch },
                "expected variant for \(os)/\(arch) in \(output[0])")
        }
    }

    @Test func testPullOsArch() async throws {
        try await ContainerFixture.with { f in
            let os = "linux"
            let arch = "amd64"
            try f.doPull(alpine318, args: ["--os", os, "--arch", arch])
            let output = try f.doInspectImages(alpine318)
            #expect(output.count == 1)
            #expect(
                output[0].variants.contains { $0.platform.os == os && $0.platform.architecture == arch },
                "expected variant for \(os)/\(arch)")
        }
    }

    @Test func testPullOs() async throws {
        try await ContainerFixture.with { f in
            let os = "linux"
            let arch = hostArchitecture
            try f.doPull(alpine318, args: ["--os", os])
            let output = try f.doInspectImages(alpine318)
            #expect(output.count == 1)
            #expect(
                output[0].variants.contains { $0.platform.os == os && $0.platform.architecture == arch },
                "expected variant for \(os)/\(arch)")
        }
    }

    @Test func testPullArch() async throws {
        try await ContainerFixture.with { f in
            let os = "linux"
            let arch = "amd64"
            try f.doPull(alpine318, args: ["--arch", arch])
            let output = try f.doInspectImages(alpine318)
            #expect(output.count == 1)
            #expect(
                output[0].variants.contains { $0.platform.os == os && $0.platform.architecture == arch },
                "expected variant for \(os)/\(arch)")
        }
    }

    @Test func testPullRemoveSingle() async throws {
        try await ContainerFixture.with { f in
            try f.doPull(alpine)
            #expect(try f.isImagePresent(alpine))
            let tagged = "\((try Reference.parse(alpine)).name):testPullRemoveSingle"
            try f.doImageTag(alpine, newName: tagged)
            #expect(try f.isImagePresent(tagged))
            try f.doRemoveImages([tagged])
            #expect(!(try f.isImagePresent(tagged)), "expected \(tagged) to be removed")
        }
    }

    @Test func testImageTag() async throws {
        try await ContainerFixture.with { f in
            try f.doPull(alpine)
            let tagged = "\((try Reference.parse(alpine)).name):testImageTag"
            try f.doImageTag(alpine, newName: tagged)
            #expect(try f.isImagePresent(tagged))
        }
    }

    @Test func testImageSaveAndLoad() async throws {
        try await ContainerFixture.with { f in
            try f.doPull(alpine)
            try f.doPull(busybox)

            let alpineTagged = "\((try Reference.parse(alpine)).name):testImageSaveAndLoad"
            let busyboxTagged = "\((try Reference.parse(busybox)).name):testImageSaveAndLoad"
            try f.doImageTag(alpine, newName: alpineTagged)
            try f.doImageTag(busybox, newName: busyboxTagged)
            #expect(try f.isImagePresent(alpineTagged))
            #expect(try f.isImagePresent(busyboxTagged))

            let tempFile = f.testDir.appending("save-\(UUID().uuidString).tar")
            try f.run(["image", "save", alpineTagged, busyboxTagged, "--output", tempFile.string]).check()

            try f.doRemoveImages([alpineTagged, busyboxTagged])
            #expect(!(try f.isImagePresent(alpineTagged)))
            #expect(!(try f.isImagePresent(busyboxTagged)))

            try f.run(["image", "load", "-i", tempFile.string]).check()
            #expect(try f.isImagePresent(alpineTagged))
            #expect(try f.isImagePresent(busyboxTagged))
        }
    }

    @Test func testImageSaveToStdoutProducesCleanArchive() async throws {
        try await ContainerFixture.with { f in
            try f.doPull(alpine)
            let tagged = "\((try Reference.parse(alpine)).name):testImageSaveToStdout"
            try f.doImageTag(alpine, newName: tagged)
            f.addCleanup { try? f.doRemoveImages([tagged]) }

            let result = try f.run(["image", "save", tagged])
            try result.check("save to stdout failed")

            #expect(result.outputData.count >= 1024, "stdout archive too small to contain tar EOF marker")
            let trailer = result.outputData.suffix(1024)
            #expect(trailer.allSatisfy { $0 == 0 }, "stdout archive has trailing non-archive bytes after tar EOF marker")
            #expect(result.error.contains(tagged), "expected saved image reference on stderr")
        }
    }

    @Test func testImageSaveMissingPlatform() async throws {
        try await ContainerFixture.with { f in
            try f.doPull(alpine)
            let tagged = "\((try Reference.parse(alpine)).name):testImageSaveMissingPlatform"
            try f.doImageTag(alpine, newName: tagged)
            f.addCleanup { try? f.doRemoveImages([tagged]) }

            let tempFile = f.testDir.appending("save-missing.tar")
            let result = try f.run([
                "image", "save", tagged,
                "--platform", "linux/arm/v5",
                "--output", tempFile.string,
            ])
            #expect(result.status != 0, "expected save to fail for missing platform")
            #expect(result.error.contains("has no content for platform"))
            #expect(result.error.contains("available platforms:"))
        }
    }

    @Test func testMaxConcurrentDownloadsValidation() async throws {
        try await ContainerFixture.with { f in
            let result = try f.run(["image", "pull", "--max-concurrent-downloads", "0", "alpine:latest"])
            #expect(result.status != 0)
            #expect(result.error.contains("maximum number of concurrent downloads must be greater than 0"))
        }
    }

    @Test func testImageLoadRejectsInvalidMembersWithoutForce() async throws {
        try await ContainerFixture.with { f in
            let maliciousFilename = "pwned-\(UUID().uuidString).txt"
            try f.doPull(alpine)
            let tagged = "\((try Reference.parse(alpine)).name):testImageLoadRejectsInvalidMembers"
            try f.doImageTag(alpine, newName: tagged)
            #expect(try f.isImagePresent(tagged))

            let tempFile = f.testDir.appending("save.tar")
            try f.run(["image", "save", tagged, "--output", tempFile.string]).check()
            try addInvalidMemberToTar(tarPath: tempFile.string, maliciousFilename: maliciousFilename)

            try f.doRemoveImages([tagged])
            #expect(!(try f.isImagePresent(tagged)))

            let loadResult = try f.run(["image", "load", "-i", tempFile.string])
            #expect(loadResult.status != 0, "expected load to fail without force flag")
            #expect(loadResult.error.contains("rejected paths") || loadResult.error.contains(maliciousFilename))
            #expect(
                !FileManager.default.fileExists(atPath: "/tmp/\(maliciousFilename)"),
                "malicious file should not have been created")
        }
    }

    @Test func testImageLoadAcceptsInvalidMembersWithForce() async throws {
        try await ContainerFixture.with { f in
            let maliciousFilename = "pwned-\(UUID().uuidString).txt"
            try f.doPull(alpine)
            let tagged = "\((try Reference.parse(alpine)).name):testImageLoadAcceptsInvalidMembers"
            try f.doImageTag(alpine, newName: tagged)
            f.addCleanup { try? f.doRemoveImages([tagged]) }

            let tempFile = f.testDir.appending("save.tar")
            try f.run(["image", "save", tagged, "--output", tempFile.string]).check()
            try addInvalidMemberToTar(tarPath: tempFile.string, maliciousFilename: maliciousFilename)

            try f.doRemoveImages([tagged])
            let loadResult = try f.run(["image", "load", "-i", tempFile.string, "--force"])
            #expect(loadResult.status == 0, "expected load to succeed with force flag")
            #expect(loadResult.error.contains("invalid members") || loadResult.error.contains(maliciousFilename))
            #expect(try f.isImagePresent(tagged))
            #expect(
                !FileManager.default.fileExists(atPath: "/tmp/\(maliciousFilename)"),
                "malicious file should not have been created")
        }
    }

    @Test func testImageSaveAndLoadStdinStdout() async throws {
        try await ContainerFixture.with { f in
            try f.doPull(alpine)
            try f.doPull(busybox)

            let alpineTagged = "\((try Reference.parse(alpine)).name):testImageSaveAndLoadStdinStdout"
            let busyboxTagged = "\((try Reference.parse(busybox)).name):testImageSaveAndLoadStdinStdout"
            try f.doImageTag(alpine, newName: alpineTagged)
            try f.doImageTag(busybox, newName: busyboxTagged)
            #expect(try f.isImagePresent(alpineTagged))
            #expect(try f.isImagePresent(busyboxTagged))

            let saveResult = try f.run(["image", "save", alpineTagged, busyboxTagged]).check()
            try f.doRemoveImages([alpineTagged, busyboxTagged])
            #expect(!(try f.isImagePresent(alpineTagged)))
            #expect(!(try f.isImagePresent(busyboxTagged)))

            try f.run(["image", "load"], stdin: saveResult.outputData).check()
            #expect(try f.isImagePresent(alpineTagged))
            #expect(try f.isImagePresent(busyboxTagged))
        }
    }

    @Test func testImageVariantSizeFieldExists() async throws {
        try await ContainerFixture.with { f in
            try f.doPull(alpine)
            let result = try f.run(["image", "ls", "--format", "json"]).check()
            guard let json = try JSONSerialization.jsonObject(with: result.outputData) as? [[String: Any]],
                let image = json.first
            else {
                Issue.record("failed to parse image list JSON or no images found")
                return
            }
            let variants = image["variants"] as? [[String: Any]] ?? []
            #expect(!variants.isEmpty, "expected at least one variant")
            #expect(
                variants.contains { ($0["size"] as? Int ?? 0) > 0 },
                "expected at least one variant with non-zero size")
        }
    }

    @Test func testImageListTableFormat() async throws {
        try await ContainerFixture.with { f in
            try f.doPull(alpine)
            let result = try f.run(["image", "ls"]).check()
            #expect(["NAME", "TAG", "DIGEST"].allSatisfy { result.output.contains($0) })
            #expect(result.output.contains("alpine"))
        }
    }

    @Test func testInspectMissingImageFails() async throws {
        try await ContainerFixture.with { f in
            let result = try f.run(["image", "inspect", "definitely-missing-image:latest"])
            #expect(result.status != 0)
            #expect(result.error.contains("image not found"))
        }
    }

    @Test func testImageLoadMissingFileErrorToStderr() async throws {
        try await ContainerFixture.with { f in
            let missingPath = "/path/that/does/not/exist-\(UUID().uuidString)"
            let result = try f.run(["image", "load", "-i", missingPath])
            #expect(result.status != 0)
            #expect(result.output.isEmpty, "stdout should be empty")
            #expect(result.error.contains("file does not exist") && result.error.contains(missingPath))
        }
    }

    // MARK: - Private helpers

    private func addInvalidMemberToTar(tarPath: String, maliciousFilename: String) throws {
        let evilEntryName = "../../../../../../../../../../../tmp/\(maliciousFilename)"
        let evilEntryContent = "pwned\n".data(using: .utf8)!
        let tempModifiedTar = URL(filePath: tarPath + ".modified")

        let writer = try ArchiveWriter(format: .pax, filter: .none, file: tempModifiedTar)
        let reader = try ArchiveReader(file: URL(fileURLWithPath: tarPath))
        for (entry, data) in reader {
            if entry.fileType == .regular {
                try writer.writeEntry(entry: entry, data: data)
            } else {
                try writer.writeEntry(entry: entry, data: nil)
            }
        }
        let evilEntry = WriteEntry()
        evilEntry.path = evilEntryName
        evilEntry.size = Int64(evilEntryContent.count)
        evilEntry.modificationDate = Date()
        evilEntry.fileType = .regular
        evilEntry.permissions = 0o644
        try writer.writeEntry(entry: evilEntry, data: evilEntryContent)
        try writer.finishEncoding()

        try FileManager.default.removeItem(atPath: tarPath)
        try FileManager.default.moveItem(at: tempModifiedTar, to: URL(fileURLWithPath: tarPath))
    }
}
