//===----------------------------------------------------------------------===//
// Copyright © 2025 Apple Inc. and the container project authors. All rights reserved.
//
// 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 ContainerizationOCI
import Foundation
import Testing

@testable import ContainerBuildIR
@testable import ContainerBuildReporting

struct GraphBuilderTests {

    // MARK: - Complex Multi-Stage Scenarios

    @Test func complexMultiStageNodeJSBuild() throws {
        guard let nodeRef = ImageReference(parsing: "node:18-alpine"),
            let nginxRef = ImageReference(parsing: "nginx:alpine")
        else {
            Issue.record("Failed to parse image references")
            return
        }

        let graph = try GraphBuilder.multiStage { builder in
            // Dependencies stage - install and cache node_modules
            try builder
                .stage(name: "deps", from: nodeRef)
                .workdir("/app")
                .copyFromContext(paths: ["package*.json"], to: "./")
                .run("npm ci --only=production && npm cache clean --force")

            // Build stage - compile TypeScript and build assets
            try builder
                .stage(name: "build", from: nodeRef)
                .workdir("/app")
                .copyFromStage(.named("deps"), paths: ["/app/node_modules"], to: "/app/node_modules")
                .copyFromContext(paths: ["tsconfig.json", "webpack.config.js", "src/"], to: "./")
                .run("npm run build")

            // Runtime stage - serve with nginx
            try builder
                .stage(name: "runtime", from: nginxRef)
                .copyFromStage(.named("build"), paths: ["/app/dist"], to: "/usr/share/nginx/html")
                .copyFromContext(paths: ["nginx.conf"], to: "/etc/nginx/nginx.conf")
                .expose([PortSpec(port: 80)])
                .cmd(.exec(["nginx", "-g", "daemon off;"]))
        }

        #expect(graph.stages.count == 3)
        #expect(graph.stages[0].name == "deps")
        #expect(graph.stages[1].name == "build")
        #expect(graph.stages[2].name == "runtime")

        // Verify stage dependencies
        let buildDeps = graph.stages[1].stageDependencies()
        #expect(buildDeps.contains(.named("deps")))

        let runtimeDeps = graph.stages[2].stageDependencies()
        #expect(runtimeDeps.contains(.named("build")))

        // Validate the complex graph
        let validator = StandardValidator()
        let result = validator.validate(graph)
        #expect(result.isValid, "Complex multi-stage build should be valid: \(result.errors)")
    }

    @Test func fourStageGoMicroserviceBuild() throws {
        guard let goRef = ImageReference(parsing: "golang:1.21-alpine"),
            let scratchRef = ImageReference(parsing: "scratch")
        else {
            Issue.record("Failed to parse image references")
            return
        }

        let graph = try GraphBuilder.multiStage { builder in
            // Base tools stage
            try builder
                .stage(name: "tools", from: goRef)
                .run("apk add --no-cache git ca-certificates")
                .workdir("/tools")
                .run("go install github.com/swaggo/swag/cmd/swag@latest")

            // Dependency stage
            try builder
                .stage(name: "deps", from: goRef)
                .workdir("/src")
                .copyFromContext(paths: ["go.mod", "go.sum"], to: "./")
                .run("go mod download")

            // Build stage
            try builder
                .stage(name: "build", from: goRef)
                .copyFromStage(.named("tools"), paths: ["/go/bin/swag"], to: "/usr/local/bin/")
                .workdir("/src")
                .copyFromStage(.named("deps"), paths: ["/go/pkg"], to: "/go/pkg")
                .copyFromContext(paths: [".", "!**/*_test.go"], to: "./")
                .run("swag init -g cmd/server/main.go")
                .run("CGO_ENABLED=0 GOOS=linux go build -ldflags='-w -s' -o /app cmd/server/main.go")

            // Runtime stage
            try builder
                .stage(from: scratchRef)
                .copyFromStage(.named("tools"), paths: ["/etc/ssl/certs/ca-certificates.crt"], to: "/etc/ssl/certs/")
                .copyFromStage(.named("build"), paths: ["/app"], to: "/app")
                .user(.uid(65534))  // nobody user
                .expose([PortSpec(port: 8080)])
                .entrypoint(.exec(["/app"]))
        }

        #expect(graph.stages.count == 4)

        // Verify complex dependency chain
        let buildStage = graph.stages[2]
        let buildDeps = buildStage.stageDependencies()
        #expect(buildDeps.contains(.named("tools")))
        #expect(buildDeps.contains(.named("deps")))

        let runtimeStage = graph.stages[3]
        let runtimeDeps = runtimeStage.stageDependencies()
        #expect(runtimeDeps.contains(.named("tools")))
        #expect(runtimeDeps.contains(.named("build")))
    }

    // MARK: - Build Arguments and Environment

    @Test func buildArgumentPropagation() throws {
        guard let nodeRef = ImageReference(parsing: "alpine:latest") else {
            Issue.record("Failed to parse image reference")
            return
        }

        let graph = try GraphBuilder.multiStage { builder in
            try builder
                .fromOnlyArg("BASE_IMAGE", defaultValue: "alpine:latest")
                .fromOnlyArg("BUILD_VERSION", defaultValue: "1.0.0")
                .stage(name: "build", from: nodeRef)
                .arg("NODE_ENV", defaultValue: "development")
                .arg("BUILD_VERSION")
                .arg("API_URL")
                .workdir("/app")
                .copyFromContext(paths: ["package.json"], to: "./")
                .run("npm install --omit=dev")
                .env("NODE_ENV", "production")
                .env("BUILD_VERSION", "1.0.0")
                .env("API_URL", "https://api.example.com")
                .copyFromContext(paths: ["src/"], to: "./src/")
                .run("npm run build")
        }

        // Verify global FROM-only ARG values
        #expect(graph.buildArgs.count == 2)
        #expect(graph.buildArgs["BASE_IMAGE"] == "alpine:latest")
        #expect(graph.buildArgs["BUILD_VERSION"] == "1.0.0")

        // Verify stage-local ARG values
        #expect(graph.stages.count == 1)

        let stage = graph.stages[0]
        #expect(getStageArg(stage, name: "BASE_IMAGE") == nil)
        #expect(getStageArg(stage, name: "NODE_ENV") == "development")
        #expect(getStageArg(stage, name: "BUILD_VERSION") == nil)
        #expect(getStageArg(stage, name: "API_URL") == nil)

        // Verify ENV instructions reference build args
        let envOps = stage.nodes.compactMap { node in
            node.operation as? MetadataOperation
        }.filter { meta in
            if case .setEnv = meta.action { return true }
            return false
        }
        #expect(envOps.count == 3)
    }

    @Test func testArgSubstitutionWithSimpleVariables() throws {
        let graphBuilder = GraphBuilder()
        graphBuilder.fromOnlyArg("DEFINED", defaultValue: "value")
        graphBuilder.fromOnlyArg("EMPTY", defaultValue: "")

        #expect(graphBuilder.substituteArgs("prefix-$DEFINED-$DEFINED-$DEFINED-suffix", inFromContext: true) == "prefix-value-value-value-suffix")
        #expect(graphBuilder.substituteArgs("prefix-$EMPTY-$EMPTY-$EMPTY-suffix", inFromContext: true) == "prefix----suffix")
        #expect(graphBuilder.substituteArgs("prefix-$UNDEFINED-$UNDEFINED-$UNDEFINED-suffix", inFromContext: true) == "prefix----suffix")
    }

    @Test func testArgSubstitution() throws {
        let graphBuilder = GraphBuilder()
        graphBuilder.fromOnlyArg("DEFINED", defaultValue: "value")
        graphBuilder.fromOnlyArg("EMPTY", defaultValue: "")

        #expect(graphBuilder.substituteArgs("prefix-${DEFINED}-${DEFINED}-${DEFINED}-suffix", inFromContext: true) == "prefix-value-value-value-suffix")
        #expect(graphBuilder.substituteArgs("prefix-${EMPTY}-${EMPTY}-${EMPTY}-suffix", inFromContext: true) == "prefix----suffix")
        #expect(graphBuilder.substituteArgs("prefix-${UNDEFINED}-${UNDEFINED}-${UNDEFINED}-suffix", inFromContext: true) == "prefix----suffix")
    }

    @Test func testArgSubstitutionWithDefaults() throws {
        let graphBuilder = GraphBuilder()
        graphBuilder.fromOnlyArg("DEFINED", defaultValue: "defined")
        graphBuilder.fromOnlyArg("EMPTY", defaultValue: "")

        #expect(graphBuilder.substituteArgs("${UNDEFINED:-default}", inFromContext: true) == "default")
        #expect(graphBuilder.substituteArgs("${UNDEFINED:-default with spaces}", inFromContext: true) == "default with spaces")
        #expect(graphBuilder.substituteArgs("${UNDEFINED:-}", inFromContext: true) == "")

        #expect(graphBuilder.substituteArgs("${DEFINED:-default}", inFromContext: true) == "defined")
        #expect(graphBuilder.substituteArgs("${EMPTY:-default}", inFromContext: true) == "")

        #expect(graphBuilder.substituteArgs("a=${UNDEFINED:-default} b=${DEFINED:-default} c=${EMPTY:-default}", inFromContext: true) == "a=default b=defined c=")
    }

    @Test func testArgSubstitutionWithUTF16Characters() throws {
        let graphBuilder = GraphBuilder()

        #expect(graphBuilder.substituteArgs("unicorn 🦄 unicorn ${UNDEFINED:-🦄}", inFromContext: true) == "unicorn 🦄 unicorn 🦄")
        #expect(graphBuilder.substituteArgs("🦄 ${UNDEFINED:-unicorn 🦄 unicorn}", inFromContext: true) == "🦄 unicorn 🦄 unicorn")
    }

    @Test func testArgSubstitutionWithSubstituteValues() throws {
        let graphBuilder = GraphBuilder()
        graphBuilder.fromOnlyArg("DEFINED", defaultValue: "defined")
        graphBuilder.fromOnlyArg("EMPTY", defaultValue: "")

        #expect(graphBuilder.substituteArgs("${DEFINED:+value}", inFromContext: true) == "value")
        #expect(graphBuilder.substituteArgs("${DEFINED:+value with spaces}", inFromContext: true) == "value with spaces")
        #expect(graphBuilder.substituteArgs("${DEFINED:+}", inFromContext: true) == "")
        #expect(graphBuilder.substituteArgs("${UNDEFINED:+value}", inFromContext: true) == "")
        #expect(graphBuilder.substituteArgs("${EMPTY:+value}", inFromContext: true) == "")

        #expect(graphBuilder.substituteArgs("a=${UNDEFINED:+value} b=${DEFINED:+value} c=${EMPTY:+value}", inFromContext: true) == "a= b=value c=")
    }

    @Test func testPredefinedArgVariables() throws {
        let graphBuilder = GraphBuilder()

        var originalValues: [String: String?] = [:]
        for varName in GraphBuilder.predefinedArgs {
            originalValues[varName] = ProcessInfo.processInfo.environment[varName]
            setenv(varName, "test-value-\(varName.lowercased())", 1)
        }

        defer {
            for (varName, originalValue) in originalValues {
                if let originalValue {
                    setenv(varName, originalValue, 1)
                } else {
                    unsetenv(varName)
                }
            }
        }

        for varName in GraphBuilder.predefinedArgs {
            let expectedValue = "test-value-\(varName.lowercased())"
            #expect(graphBuilder.substituteArgs("${\(varName)}", inFromContext: true) == expectedValue)
        }
    }

    // MARK: - Platform-Specific Builds

    @Test func multiPlatformBuild() throws {
        guard let baseRef = ImageReference(parsing: "alpine:latest") else {
            Issue.record("Failed to parse image reference")
            return
        }

        let graph = try GraphBuilder.singleStage(from: baseRef, platform: Platform.linuxAMD64) { builder in
            try builder
                .platforms(Platform.linuxAMD64, Platform.linuxARM64)
                .run("apk add --no-cache curl")
                .workdir("/app")
                .copyFromContext(paths: ["app.sh"], to: "/app/")
                .run("chmod +x /app/app.sh")
                .entrypoint(.exec(["/app/app.sh"]))
        }

        #expect(graph.targetPlatforms.count == 2)
        #expect(graph.targetPlatforms.contains(Platform.linuxAMD64))
        #expect(graph.targetPlatforms.contains(Platform.linuxARM64))

        // Verify base image has platform constraints
        let stage = graph.stages[0]
        let imageOp = stage.base
        #expect(imageOp.platform != nil)
    }

    @Test func platformSpecificStages() throws {
        guard let alpineRef = ImageReference(parsing: "alpine:latest") else {
            Issue.record("Failed to parse image reference")
            return
        }

        let graph = try GraphBuilder.multiStage { builder in
            // Linux AMD64 optimized stage
            try builder
                .stage(name: "linux-amd64", from: alpineRef, platform: Platform.linuxAMD64)
                .run("apk add --no-cache glibc-compat")
                .copyFromContext(paths: ["bin/app-linux-amd64"], to: "/usr/local/bin/app")

            // Linux ARM64 stage
            try builder
                .stage(name: "linux-arm64", from: alpineRef, platform: Platform.linuxARM64)
                .run("apk add --no-cache ca-certificates")
                .copyFromContext(paths: ["bin/app-linux-arm64"], to: "/usr/local/bin/app")

            // Final stage that copies from platform-specific stage
            try builder
                .stage(from: alpineRef)
                .copyFromStage(.named("linux-amd64"), paths: ["/usr/local/bin/app"], to: "/usr/local/bin/")
                .copyFromStage(.named("linux-arm64"), paths: ["/usr/local/bin/app"], to: "/usr/local/bin/")
                .entrypoint(.exec(["/usr/local/bin/app"]))
        }

        #expect(graph.stages.count == 3)

        // Verify platform-specific stages
        #expect(graph.stages[0].name == "linux-amd64")
        #expect(graph.stages[1].name == "linux-arm64")

        let amd64ImageOp = graph.stages[0].base
        #expect(amd64ImageOp.platform == Platform.linuxAMD64)

        let arm64ImageOp = graph.stages[1].base
        #expect(arm64ImageOp.platform == Platform.linuxARM64)
    }

    // MARK: - Error Conditions

    @Test func invalidStageReference() throws {
        guard let alpineRef = ImageReference(parsing: "alpine") else {
            Issue.record("Failed to parse image reference")
            return
        }

        #expect(throws: Error.self) {
            try GraphBuilder.multiStage { builder in
                try builder
                    .stage(name: "base", from: alpineRef)
                    .run("echo hello")

                try builder
                    .stage(name: "app", from: alpineRef)
                    .copyFromStage(.named("nonexistent"), paths: ["/file"], to: "/")
            }
        }
    }

    @Test func circularStageDependency() throws {
        guard let alpineRef = ImageReference(parsing: "alpine") else {
            Issue.record("Failed to parse image reference")
            return
        }

        // This should create a circular dependency through stage references
        // GraphBuilder should throw BuildGraphError for cyclic dependencies
        #expect(throws: BuildGraphError.self) {
            try GraphBuilder.multiStage { builder in
                try builder
                    .stage(name: "stage1", from: alpineRef)
                    .copyFromStage(.named("stage2"), paths: ["/file1"], to: "/file1")

                try builder
                    .stage(name: "stage2", from: alpineRef)
                    .copyFromStage(.named("stage1"), paths: ["/file2"], to: "/file2")
            }
        }
    }

    @Test func validEntrypointAndCmdSequence() throws {
        guard let alpineRef = ImageReference(parsing: "alpine") else {
            Issue.record("Failed to parse image reference")
            return
        }

        // CMD after ENTRYPOINT is a valid and common Docker pattern
        // ENTRYPOINT defines the executable, CMD provides default arguments
        let graph = try GraphBuilder.singleStage(from: alpineRef) { builder in
            try builder
                .entrypoint(.exec(["/app"]))  // Set entrypoint first
                .cmd(.shell("echo override"))  // CMD after ENTRYPOINT is valid
        }

        #expect(graph.stages.count == 1)
        #expect(graph.stages[0].nodes.count == 2)

        // Verify the operations exist and are in the correct order
        let nodes = graph.stages[0].nodes

        let entrypointNode = nodes.first { node in
            if let metaOp = node.operation as? MetadataOperation,
                case .setEntrypoint = metaOp.action
            {
                return true
            }
            return false
        }
        #expect(entrypointNode != nil, "Should have ENTRYPOINT operation")

        let cmdNode = nodes.first { node in
            if let metaOp = node.operation as? MetadataOperation,
                case .setCmd = metaOp.action
            {
                return true
            }
            return false
        }
        #expect(cmdNode != nil, "Should have CMD operation")

        // Validate the graph structure
        let validator = StandardValidator()
        let result = validator.validate(graph)
        #expect(result.isValid, "ENTRYPOINT + CMD sequence should be valid: \(result.errors)")
    }

    @Test func emptyStage() throws {
        guard let alpineRef = ImageReference(parsing: "alpine") else {
            Issue.record("Failed to parse image reference")
            return
        }

        // Empty stage should be valid but might generate warnings
        let graph = try GraphBuilder.singleStage(from: alpineRef) { builder in
            // Intentionally empty - just the base image
        }

        #expect(graph.stages.count == 1)
        #expect(graph.stages[0].nodes.isEmpty, "Empty stage should have no nodes")

        let validator = StandardValidator()
        let result = validator.validate(graph)
        #expect(result.isValid, "Empty stage should be structurally valid")
    }

    // MARK: - Advanced GraphBuilder Features

    @Test func conditionalInstructions() throws {
        guard let nodeRef = ImageReference(parsing: "node:18") else {
            Issue.record("Failed to parse image reference")
            return
        }

        let isDevelopment = false

        let graph = try GraphBuilder.singleStage(from: nodeRef) { builder in
            try builder
                .workdir("/app")
                .copyFromContext(paths: ["package.json"], to: "./")
                .run("npm install" + (isDevelopment ? "" : " --omit=dev"))
                .copyFromContext(paths: ["src/"], to: "./src/")

            if isDevelopment {
                try builder
                    .env("NODE_ENV", "development")
                    .run("npm run test")
                    .cmd(.shell("npm run dev"))
            } else {
                try builder
                    .env("NODE_ENV", "production")
                    .run("npm run build")
                    .cmd(.exec(["node", "dist/index.js"]))
            }
        }

        // Verify production build path was taken
        let envOps = graph.stages[0].nodes.compactMap { node in
            node.operation as? MetadataOperation
        }.compactMap { meta in
            if case .setEnv(let key, let value) = meta.action,
                key == "NODE_ENV"
            {
                return value
            }
            return nil
        }

        #expect(envOps.contains(.literal("production")))
        #expect(!envOps.contains(.literal("development")))
    }

    @Test func stageWithCustomMetadata() throws {
        guard let alpineRef = ImageReference(parsing: "alpine") else {
            Issue.record("Failed to parse image reference")
            return
        }

        let graph = try GraphBuilder.singleStage(from: alpineRef) { builder in
            try builder
                .label("version", "1.0.0")
                .label("maintainer", "team@example.com")
                .label("description", "Sample application")
                .workdir("/app")
                .user(.named("appuser"))
                .expose([PortSpec(port: 8080)])
        }

        // Verify all metadata operations were added
        let metadataOps = graph.stages[0].nodes.compactMap { $0.operation as? MetadataOperation }

        let labelOps = metadataOps.filter {
            if case .setLabel = $0.action { return true }
            return false
        }
        #expect(labelOps.count == 3)

        let userOps = metadataOps.filter {
            if case .setUser = $0.action { return true }
            return false
        }
        #expect(userOps.count == 1)

        let exposeOps = metadataOps.filter {
            if case .expose = $0.action { return true }
            return false
        }
        #expect(exposeOps.count == 1)
    }

    @Test func complexMountOperations() throws {
        guard let alpineRef = ImageReference(parsing: "alpine") else {
            Issue.record("Failed to parse image reference")
            return
        }

        let graph = try GraphBuilder.singleStage(from: alpineRef) { builder in
            try builder
                .workdir("/app")
                .run(
                    "apk add --no-cache make gcc",
                    mounts: [
                        Mount(type: .cache, target: "/var/cache/apk"),
                        Mount(type: .secret, target: "/run/secrets/github-token", source: .secret("github-token"), options: MountOptions(mode: 0o400)),
                        Mount(type: .bind, target: "/app/.cache", source: .local("build-cache")),
                    ]
                )
                .copyFromContext(paths: ["Makefile", "src/"], to: "./")
                .run(
                    "make build",
                    mounts: [
                        Mount(type: .cache, target: "/app/.cache", options: MountOptions(sharing: .shared)),
                        Mount(type: .tmpfs, target: "/tmp", options: MountOptions(size: 100_000_000)),
                    ]
                )
        }

        // Verify mount operations exist
        let execOps = graph.stages[0].nodes.compactMap { $0.operation as? ExecOperation }

        let firstRunOp = execOps.first { op in
            op.command.displayString.contains("apk add")
        }
        #expect(firstRunOp?.mounts.count == 3)

        let secondRunOp = execOps.first { op in
            op.command.displayString.contains("make build")
        }
        #expect(secondRunOp?.mounts.count == 2)
    }

    // MARK: - Graph Modification and Builder State

    @Test func builderStatePreservation() throws {
        guard let alpineRef = ImageReference(parsing: "alpine") else {
            Issue.record("Failed to parse image reference")
            return
        }

        let graph1 = try GraphBuilder.singleStage(from: alpineRef) { stageBuilder in
            try stageBuilder
                .arg("VERSION", defaultValue: "1.0.0")
                .platforms(Platform.linuxAMD64)
                .run("echo 'Build 1'")
        }

        // Verify first graph has our settings
        #expect(graph1.targetPlatforms == [Platform.linuxAMD64])
        #expect(graph1.stages.count == 1)
        #expect(getStageArg(graph1.stages[0], name: "VERSION") == "1.0.0")

        // Create second graph with different settings
        let graph2 = try GraphBuilder.singleStage(from: alpineRef) { stageBuilder in
            try stageBuilder
                .arg("VERSION", defaultValue: "2.0.0")
                .platforms(Platform.linuxARM64)
                .run("echo 'Build 2'")
        }

        // Verify second graph has updated settings
        #expect(graph2.targetPlatforms == [Platform.linuxARM64])
        #expect(graph2.stages.count == 1)
        #expect(getStageArg(graph2.stages[0], name: "VERSION") == "2.0.0")

        // Verify first graph unchanged
        #expect(graph1.targetPlatforms == [Platform.linuxAMD64])
        #expect(graph1.stages.count == 1)
        #expect(getStageArg(graph1.stages[0], name: "VERSION") == "1.0.0")
    }

    @Test func incrementalGraphBuilding() throws {
        guard let alpineRef = ImageReference(parsing: "alpine") else {
            Issue.record("Failed to parse image reference")
            return
        }

        let graph = try GraphBuilder.multiStage { builder in
            // Build base stage
            try builder
                .stage(name: "base", from: alpineRef)
                .run("apk add --no-cache ca-certificates")
                .workdir("/app")

            // Add dependency stage
            try builder
                .stage(name: "deps", from: alpineRef)
                .copyFromStage(.named("base"), paths: ["/etc/ssl"], to: "/etc/ssl")
                .run("apk add --no-cache curl")

            // Add final stage
            try builder
                .stage(from: alpineRef)
                .copyFromStage(.named("base"), paths: ["/app"], to: "/app")
                .copyFromStage(.named("deps"), paths: ["/usr/bin/curl"], to: "/usr/local/bin/")
                .entrypoint(.exec(["/app/start.sh"]))
        }

        #expect(graph.stages.count == 3)
        #expect(graph.stages[0].name == "base")
        #expect(graph.stages[1].name == "deps")
        #expect(graph.stages[2].name == nil)  // Final stage

        // Verify dependencies are correct
        let finalStageDeps = graph.stages[2].stageDependencies()
        #expect(finalStageDeps.contains(.named("base")))
        #expect(finalStageDeps.contains(.named("deps")))
    }
}

func getStageArg(_ stage: BuildStage, name: String, globalBuildArgs: [String: String] = [:]) -> String? {
    for node in stage.nodes {
        if let metadataOp = node.operation as? MetadataOperation,
            case .declareArg(let argName, let defaultValue) = metadataOp.action,
            argName == name
        {
            return defaultValue ?? globalBuildArgs[name]
        }
    }
    return nil
}
