//===----------------------------------------------------------------------===//
// 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 ContainerizationError
import ContainerizationExtras
import ContainerizationIO
import ContainerizationOCI
import Crypto
import Foundation

extension ImageStore {
    public struct ExportOperation: Sendable {
        let name: String
        let tag: String
        let contentStore: ContentStore
        let client: ContentClient
        let progress: ProgressHandler?

        public init(name: String, tag: String, contentStore: ContentStore, client: ContentClient, progress: ProgressHandler? = nil) {
            self.contentStore = contentStore
            self.client = client
            self.progress = progress
            self.name = name
            self.tag = tag
        }

        @discardableResult
        public func export(index: Descriptor, platforms: (Platform) -> Bool, filter: (Descriptor) -> Bool = { _ in true }) async throws -> Descriptor {
            var pushQueue: [[Descriptor]] = []
            var current: [Descriptor] = [index]
            while !current.isEmpty {
                let children = try await self.getChildren(descs: current)
                let matches = try filterPlatforms(matcher: platforms, children).uniqued { $0.digest }
                pushQueue.append(matches)
                current = matches
            }
            let localIndexData = try await self.createIndex(from: index, matching: platforms)

            await updatePushProgress(pushQueue: pushQueue, localIndexData: localIndexData)

            // We need to work bottom up when pushing an image.
            // First, the tar blobs / config layers, then, the manifests and so on...
            // When processing a given "level", the requests maybe made in parallel.
            // We need to ensure that the child level has been uploaded fully
            // before uploading the parent level.
            try await withThrowingTaskGroup(of: Void.self) { group in
                for layerGroup in pushQueue.reversed() {
                    for chunk in layerGroup.chunks(ofCount: 8) {
                        for desc in chunk.filter(filter) {
                            guard let content = try await self.contentStore.get(digest: desc.digest) else {
                                throw ContainerizationError(.notFound, message: "content with digest \(desc.digest)")
                            }
                            group.addTask {
                                let readStream = try ReadStream(url: content.path)
                                try await self.pushContent(descriptor: desc, stream: readStream)
                            }
                        }
                        try await group.waitForAll()
                    }
                }
            }

            // Lastly, we need to construct and push a new index, since we may
            // have pushed content only for specific platforms.
            let digest = SHA256.hash(data: localIndexData)
            // The descriptor's mediaType becomes the HTTP Content-Type in
            // RegistryClient.push and must match the mediaType field inside
            // localIndexData. Registries reject mismatches with MANIFEST_INVALID.
            let descriptor = Descriptor(
                mediaType: index.mediaType,
                digest: digest.digestString,
                size: Int64(localIndexData.count))
            let stream = ReadStream(data: localIndexData)
            try await self.pushContent(descriptor: descriptor, stream: stream)
            return descriptor
        }

        private func updatePushProgress(pushQueue: [[Descriptor]], localIndexData: Data) async {
            for layerGroup in pushQueue {
                for desc in layerGroup {
                    await progress?([
                        .addTotalSize(desc.size),
                        .addTotalItems(1),
                    ])
                }
            }
            await progress?([
                .addTotalSize(Int64(localIndexData.count)),
                .addTotalItems(1),
            ])
        }

        private func createIndex(from index: Descriptor, matching: (Platform) -> Bool) async throws -> Data {
            guard let content = try await self.contentStore.get(digest: index.digest) else {
                throw ContainerizationError(.notFound, message: "content with digest \(index.digest)")
            }
            var idx: Index = try content.decode()
            let manifests = idx.manifests
            var matchedManifests: [Descriptor] = []
            var skippedPlatforms = false
            for manifest in manifests {
                guard let p = manifest.platform else {
                    continue
                }
                if matching(p) {
                    matchedManifests.append(manifest)
                } else {
                    skippedPlatforms = true
                }
            }
            if !skippedPlatforms {
                return try content.data()
            }
            idx.manifests = matchedManifests
            return try JSONEncoder().encode(idx)
        }

        private func pushContent(descriptor: Descriptor, stream: ReadStream) async throws {
            do {
                let generator = {
                    try stream.reset()
                    return stream.stream
                }
                try await client.push(name: name, ref: tag, descriptor: descriptor, streamGenerator: generator, progress: progress)
                await progress?([
                    .addSize(descriptor.size),
                    .addItems(1),
                ])
            } catch let err as ContainerizationError {
                guard err.code != .exists else {
                    // We reported the total items and size and have to account for them in existing content.
                    await progress?([
                        .addSize(descriptor.size),
                        .addItems(1),
                    ])
                    return
                }
                throw err
            }
        }

        private func getChildren(descs: [Descriptor]) async throws -> [Descriptor] {
            var out: [Descriptor] = []
            for desc in descs {
                let mediaType = desc.mediaType
                guard let content = try await self.contentStore.get(digest: desc.digest) else {
                    throw ContainerizationError(.notFound, message: "content with digest \(desc.digest)")
                }
                switch mediaType {
                case MediaTypes.index, MediaTypes.dockerManifestList:
                    let index: Index = try content.decode()
                    out.append(contentsOf: index.manifests)
                case MediaTypes.imageManifest, MediaTypes.dockerManifest:
                    let manifest: Manifest = try content.decode()
                    out.append(manifest.config)
                    out.append(contentsOf: manifest.layers)
                default:
                    continue
                }
            }
            return out
        }
    }
}
