//===----------------------------------------------------------------------===//
// Copyright © 2025-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 Foundation
import Logging
import SystemPackage
import Testing

@testable import ContainerPlugin

struct PluginLoaderTest {
    @Test
    func testFindAll() async throws {
        let tempURL = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
        defer { try? FileManager.default.removeItem(at: tempURL) }
        let factory = try setupMock(tempURL: tempURL)
        let loader = try PluginLoader(
            appRoot: tempURL,
            installRoot: URL(filePath: "/usr/local/"),
            logRoot: nil,
            pluginDirectories: [tempURL],
            pluginFactories: [factory]
        )
        let plugins = loader.findPlugins()

        #expect(Set(plugins.map { $0.name }) == Set(["cli", "service"]))
    }

    @Test
    func testFindAllSymlink() async throws {
        let tempURL = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
        defer { try? FileManager.default.removeItem(at: tempURL) }
        let factory = try setupMock(tempURL: tempURL)

        // move the CLI plugin elsewhere and symlink it
        let otherTempURL = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
        defer { try? FileManager.default.removeItem(at: otherTempURL) }
        try FileManager.default.createDirectory(at: otherTempURL, withIntermediateDirectories: true)
        let srcURL = tempURL.appendingPathComponent("cli")
        let dstURL = otherTempURL.appendingPathComponent("cli")
        try FileManager.default.moveItem(
            at: srcURL,
            to: dstURL
        )
        try FileManager.default.createSymbolicLink(
            at: srcURL,
            withDestinationURL: dstURL
        )

        let loader = try PluginLoader(
            appRoot: tempURL,
            installRoot: URL(filePath: "/usr/local/"),
            logRoot: nil,
            pluginDirectories: [tempURL],
            pluginFactories: [factory]
        )
        let plugins = loader.findPlugins()

        #expect(Set(plugins.map { $0.name }) == Set(["cli", "service"]))
    }

    @Test
    func testFindByName() async throws {
        let tempURL = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
        defer { try? FileManager.default.removeItem(at: tempURL) }
        let factory = try setupMock(tempURL: tempURL)
        let loader = try PluginLoader(
            appRoot: tempURL,
            installRoot: URL(filePath: "/usr/local/"),
            logRoot: nil,
            pluginDirectories: [tempURL],
            pluginFactories: [factory]
        )

        #expect(loader.findPlugin(name: "cli")?.name == "cli")
        #expect(loader.findPlugin(name: "service")?.name == "service")
        #expect(loader.findPlugin(name: "throw") == nil)
    }

    @Test
    func testFindPluginForExecutable() async throws {
        let tempURL = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
        defer { try? FileManager.default.removeItem(at: tempURL) }
        let (factory, cliBinaryURL, serviceBinaryURL) = try setupMockWithRealBinaries(tempURL: tempURL)
        let loader = try PluginLoader(
            appRoot: tempURL,
            installRoot: URL(filePath: "/usr/local/"),
            logRoot: nil,
            pluginDirectories: [tempURL],
            pluginFactories: [factory]
        )

        let cliMatch = loader.findPlugin(forExecutable: FilePath(cliBinaryURL.path(percentEncoded: false)))
        #expect(cliMatch?.name == "cli")

        let serviceMatch = loader.findPlugin(forExecutable: FilePath(serviceBinaryURL.path(percentEncoded: false)))
        #expect(serviceMatch?.name == "service")
    }

    @Test
    func testFindPluginForExecutableViaSymlinkedInput() async throws {
        let tempURL = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
        defer { try? FileManager.default.removeItem(at: tempURL) }
        let (factory, cliBinaryURL, _) = try setupMockWithRealBinaries(tempURL: tempURL)
        let loader = try PluginLoader(
            appRoot: tempURL,
            installRoot: URL(filePath: "/usr/local/"),
            logRoot: nil,
            pluginDirectories: [tempURL],
            pluginFactories: [factory]
        )

        // Simulate CommandLine.executablePath resolving to a symlink that
        // ultimately points at the plugin's real binary on disk.
        let otherTempURL = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
        defer { try? FileManager.default.removeItem(at: otherTempURL) }
        try FileManager.default.createDirectory(at: otherTempURL, withIntermediateDirectories: true)
        let symlinkURL = otherTempURL.appendingPathComponent("cli-symlink")
        try FileManager.default.createSymbolicLink(at: symlinkURL, withDestinationURL: cliBinaryURL)

        let match = loader.findPlugin(forExecutable: FilePath(symlinkURL.path(percentEncoded: false)))
        #expect(match?.name == "cli")
    }

    @Test
    func testFindPluginForExecutableWithSymlinkedPluginBinary() async throws {
        let tempURL = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
        defer { try? FileManager.default.removeItem(at: tempURL) }
        try FileManager.default.createDirectory(at: tempURL, withIntermediateDirectories: true)

        // The plugin's registered binaryURL is itself a symlink pointing at a
        // binary that lives elsewhere on disk (e.g. a dev-mode install).
        let realBinDir = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
        defer { try? FileManager.default.removeItem(at: realBinDir) }
        try FileManager.default.createDirectory(at: realBinDir, withIntermediateDirectories: true)
        let realBinaryURL = realBinDir.appendingPathComponent("cli-real")
        try Data().write(to: realBinaryURL)

        let symlinkBinaryURL = tempURL.appendingPathComponent("bin").appendingPathComponent("cli")
        try FileManager.default.createDirectory(at: symlinkBinaryURL.deletingLastPathComponent(), withIntermediateDirectories: true)
        try FileManager.default.createSymbolicLink(at: symlinkBinaryURL, withDestinationURL: realBinaryURL)

        let cliConfig = PluginConfig(abstract: "cli", author: "CLI", servicesConfig: nil)
        let cliPlugin = Plugin(binaryURL: symlinkBinaryURL, config: cliConfig)
        let factory = try MockPluginFactory(tempURL: tempURL, plugins: ["cli": cliPlugin])

        let loader = try PluginLoader(
            appRoot: tempURL,
            installRoot: URL(filePath: "/usr/local/"),
            logRoot: nil,
            pluginDirectories: [tempURL],
            pluginFactories: [factory]
        )

        let match = loader.findPlugin(forExecutable: FilePath(realBinaryURL.path(percentEncoded: false)))
        #expect(match?.name == "cli")
    }

    @Test
    func testFindPluginForExecutableNoMatch() async throws {
        let tempURL = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
        defer { try? FileManager.default.removeItem(at: tempURL) }
        let (factory, _, _) = try setupMockWithRealBinaries(tempURL: tempURL)
        let loader = try PluginLoader(
            appRoot: tempURL,
            installRoot: URL(filePath: "/usr/local/"),
            logRoot: nil,
            pluginDirectories: [tempURL],
            pluginFactories: [factory]
        )

        let unrelatedURL = tempURL.appendingPathComponent("unrelated-bin")
        try Data().write(to: unrelatedURL)

        let match = loader.findPlugin(forExecutable: FilePath(unrelatedURL.path(percentEncoded: false)))
        #expect(match == nil)
    }

    @Test
    func testFindPluginForExecutableNonexistentPath() async throws {
        let tempURL = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
        defer { try? FileManager.default.removeItem(at: tempURL) }
        let (factory, _, _) = try setupMockWithRealBinaries(tempURL: tempURL)
        let loader = try PluginLoader(
            appRoot: tempURL,
            installRoot: URL(filePath: "/usr/local/"),
            logRoot: nil,
            pluginDirectories: [tempURL],
            pluginFactories: [factory]
        )

        let missingURL = tempURL.appendingPathComponent("does-not-exist")
        let match = loader.findPlugin(forExecutable: FilePath(missingURL.path(percentEncoded: false)))
        #expect(match == nil)
    }

    // Confirms findPlugin(forExecutable:) works against plugins produced by
    // the real factories, not just MockPluginFactory's adhoc paths.
    @Test
    func testFindPluginForExecutableUnixLayout() async throws {
        let fm = FileManager.default
        let tempURL = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
        defer { try? fm.removeItem(at: tempURL) }
        let pluginsURL = tempURL.appendingPathComponent("plugins")
        let installURL = pluginsURL.appendingPathComponent("cli")
        let binaryDirURL = installURL.appendingPathComponent("bin")
        try fm.createDirectory(at: binaryDirURL, withIntermediateDirectories: true)
        let binaryURL = binaryDirURL.appendingPathComponent("cli")
        try Data().write(to: binaryURL)
        try "abstract = \"cli\"\nauthor = \"Apple\"".write(
            to: installURL.appendingPathComponent("config.toml"), atomically: true, encoding: .utf8)

        let loader = try PluginLoader(
            appRoot: tempURL,
            installRoot: URL(filePath: "/usr/local/"),
            logRoot: nil,
            pluginDirectories: [pluginsURL],
            pluginFactories: [DefaultPluginFactory(logger: Logger(label: "test"))]
        )

        let match = loader.findPlugin(forExecutable: FilePath(binaryURL.path(percentEncoded: false)))
        #expect(match?.name == "cli")
    }

    @Test
    func testFindPluginForExecutableAppBundleLayout() async throws {
        let fm = FileManager.default
        let tempURL = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
        defer { try? fm.removeItem(at: tempURL) }
        let pluginsURL = tempURL.appendingPathComponent("plugins")
        let installURL = pluginsURL.appendingPathComponent("cli.app")
        let macosURL = installURL.appendingPathComponent("Contents").appendingPathComponent("MacOS")
        let resourcesURL = installURL.appendingPathComponent("Contents").appendingPathComponent("Resources")
        try fm.createDirectory(at: macosURL, withIntermediateDirectories: true)
        try fm.createDirectory(at: resourcesURL, withIntermediateDirectories: true)
        let binaryURL = macosURL.appendingPathComponent("cli")
        try Data().write(to: binaryURL)
        try "abstract = \"cli\"\nauthor = \"Apple\"".write(
            to: resourcesURL.appendingPathComponent("config.toml"), atomically: true, encoding: .utf8)

        let loader = try PluginLoader(
            appRoot: tempURL,
            installRoot: URL(filePath: "/usr/local/"),
            logRoot: nil,
            pluginDirectories: [pluginsURL],
            pluginFactories: [AppBundlePluginFactory(logger: Logger(label: "test"))]
        )

        let match = loader.findPlugin(forExecutable: FilePath(binaryURL.path(percentEncoded: false)))
        #expect(match?.name == "cli")
    }

    @Test
    func testFilterEnvironmentWithContainerPrefix() async throws {
        let env = [
            "CONTAINER_FOO": "bar",
            "CONTAINER_BAZ": "qux",
            "OTHER_VAR": "value",
        ]
        let filtered = PluginLoader.filterEnvironment(env: env, additionalAllowKeys: [])

        #expect(filtered == ["CONTAINER_FOO": "bar", "CONTAINER_BAZ": "qux"])
    }

    @Test
    func testFilterEnvironmentWithProxyKeys() async throws {
        let env = [
            "http_proxy": "http://proxy:8080",
            "HTTP_PROXY": "http://proxy:8080",
            "https_proxy": "https://proxy:8443",
            "HTTPS_PROXY": "https://proxy:8443",
            "no_proxy": "localhost,127.0.0.1",
            "NO_PROXY": "localhost,127.0.0.1",
            "OTHER_VAR": "value",
        ]
        let filtered = PluginLoader.filterEnvironment(env: env)

        #expect(
            filtered == [
                "http_proxy": "http://proxy:8080",
                "HTTP_PROXY": "http://proxy:8080",
                "https_proxy": "https://proxy:8443",
                "HTTPS_PROXY": "https://proxy:8443",
                "no_proxy": "localhost,127.0.0.1",
                "NO_PROXY": "localhost,127.0.0.1",
            ])
    }

    @Test
    func testFilterEnvironmentWithBothContainerAndProxy() async throws {
        let env = [
            "CONTAINER_FOO": "bar",
            "http_proxy": "http://proxy:8080",
            "OTHER_VAR": "value",
            "ANOTHER_VAR": "value2",
        ]
        let filtered = PluginLoader.filterEnvironment(env: env)

        #expect(
            filtered == [
                "CONTAINER_FOO": "bar",
                "http_proxy": "http://proxy:8080",
            ])
    }

    @Test
    func testFilterEnvironmentWithCustomAllowKeys() async throws {
        let env = [
            "CONTAINER_FOO": "bar",
            "CUSTOM_KEY": "custom_value",
            "OTHER_VAR": "value",
        ]
        let filtered = PluginLoader.filterEnvironment(env: env, additionalAllowKeys: ["CUSTOM_KEY"])

        #expect(
            filtered == [
                "CONTAINER_FOO": "bar",
                "CUSTOM_KEY": "custom_value",
            ])
    }

    #if CONTAINER_COVERAGE
    @Test
    func testFilterEnvironmentWithLLVMProfileFile() async throws {
        let env = [
            "LLVM_PROFILE_FILE": "/tmp/coverage/%p-%m%c.profraw",
            "OTHER_VAR": "value",
        ]
        let filtered = PluginLoader.filterEnvironment(env: env)

        #expect(filtered == ["LLVM_PROFILE_FILE": "/tmp/coverage/%p-%m%c.profraw"])
    }
    #endif

    @Test
    func testFilterEnvironmentEmpty() async throws {
        let filtered = PluginLoader.filterEnvironment(env: [:])

        #expect(filtered.isEmpty)
    }

    @Test
    func testFilterEnvironmentNoMatches() async throws {
        let env = [
            "PATH": "/usr/bin",
            "HOME": "/Users/test",
            "USER": "testuser",
        ]
        let filtered = PluginLoader.filterEnvironment(env: env, additionalAllowKeys: [])

        #expect(filtered.isEmpty)
    }

    @Test
    func testRegisterWithLaunchdDebugTrue() async throws {
        let tempURL = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
        defer { try? FileManager.default.removeItem(at: tempURL) }
        let factory = try setupMock(tempURL: tempURL)
        let loader = try PluginLoader(
            appRoot: tempURL,
            installRoot: URL(filePath: "/usr/local/"),
            logRoot: nil,
            pluginDirectories: [tempURL],
            pluginFactories: [factory]
        )

        let plugin = loader.findPlugin(name: "service")!
        let stateRoot = tempURL.appendingPathComponent("test-state")
        try loader.registerWithLaunchd(plugin: plugin, pluginStateRoot: stateRoot, debug: true)

        let plistURL = stateRoot.appendingPathComponent("service.plist")
        #expect(FileManager.default.fileExists(atPath: plistURL.path))

        let plistData = try Data(contentsOf: plistURL)
        let plist = try PropertyListSerialization.propertyList(from: plistData, format: nil) as! [String: Any]
        let programArguments = plist["ProgramArguments"] as! [String]

        #expect(programArguments.contains("--debug"))
    }

    @Test
    func testRegisterWithLaunchdDebugFalse() async throws {
        let tempURL = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
        defer { try? FileManager.default.removeItem(at: tempURL) }
        let factory = try setupMock(tempURL: tempURL)
        let loader = try PluginLoader(
            appRoot: tempURL,
            installRoot: URL(filePath: "/usr/local/"),
            logRoot: nil,
            pluginDirectories: [tempURL],
            pluginFactories: [factory]
        )

        let plugin = loader.findPlugin(name: "service")!
        let stateRoot = tempURL.appendingPathComponent("test-state")
        try loader.registerWithLaunchd(plugin: plugin, pluginStateRoot: stateRoot, debug: false)

        let plistURL = stateRoot.appendingPathComponent("service.plist")
        #expect(FileManager.default.fileExists(atPath: plistURL.path))

        let plistData = try Data(contentsOf: plistURL)
        let plist = try PropertyListSerialization.propertyList(from: plistData, format: nil) as! [String: Any]
        let programArguments = plist["ProgramArguments"] as! [String]

        #expect(!programArguments.contains("--debug"))
    }

    @Test
    func testRegisterWithLaunchdDebugDefault() async throws {
        let tempURL = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
        defer { try? FileManager.default.removeItem(at: tempURL) }
        let factory = try setupMock(tempURL: tempURL)
        let loader = try PluginLoader(
            appRoot: tempURL,
            installRoot: URL(filePath: "/usr/local/"),
            logRoot: nil,
            pluginDirectories: [tempURL],
            pluginFactories: [factory]
        )

        let plugin = loader.findPlugin(name: "service")!
        let stateRoot = tempURL.appendingPathComponent("test-state")
        try loader.registerWithLaunchd(plugin: plugin, pluginStateRoot: stateRoot)

        let plistURL = stateRoot.appendingPathComponent("service.plist")
        #expect(FileManager.default.fileExists(atPath: plistURL.path))

        let plistData = try Data(contentsOf: plistURL)
        let plist = try PropertyListSerialization.propertyList(from: plistData, format: nil) as! [String: Any]
        let programArguments = plist["ProgramArguments"] as! [String]

        #expect(!programArguments.contains("--debug"))
    }

    private func setupMock(tempURL: URL) throws -> MockPluginFactory {
        let cliConfig = PluginConfig(abstract: "cli", author: "CLI", servicesConfig: nil)
        let cliPlugin: Plugin = Plugin(binaryURL: URL(filePath: "/bin/cli"), config: cliConfig)
        let serviceServicesConfig = PluginConfig.ServicesConfig(
            loadAtBoot: false,
            runAtLoad: false,
            services: [PluginConfig.Service(type: .runtime, description: nil)],
            defaultArguments: []
        )
        let serviceConfig = PluginConfig(abstract: "service", author: "SERVICE", servicesConfig: serviceServicesConfig)
        let servicePlugin: Plugin = Plugin(binaryURL: URL(filePath: "/bin/service"), config: serviceConfig)
        let mockPlugins = [
            "cli": cliPlugin,
            MockPluginFactory.throwSuffix: nil,
            "service": servicePlugin,
        ]

        return try MockPluginFactory(tempURL: tempURL, plugins: mockPlugins)
    }

    // Unlike `setupMock`, the plugins here are backed by real, empty files on
    // disk so `findPlugin(forExecutable:)` can resolve their paths with
    // `realpath`.
    private func setupMockWithRealBinaries(tempURL: URL) throws -> (factory: MockPluginFactory, cliBinaryURL: URL, serviceBinaryURL: URL) {
        // Binaries live under a "bin" subdirectory, distinct from the
        // per-plugin bookkeeping directories MockPluginFactory creates
        // directly under tempURL, and are named to match `Plugin.name`
        // (`binaryURL.lastPathComponent`).
        let binDir = tempURL.appendingPathComponent("bin")
        try FileManager.default.createDirectory(at: binDir, withIntermediateDirectories: true)
        let cliBinaryURL = binDir.appendingPathComponent("cli")
        let serviceBinaryURL = binDir.appendingPathComponent("service")
        try Data().write(to: cliBinaryURL)
        try Data().write(to: serviceBinaryURL)

        let cliConfig = PluginConfig(abstract: "cli", author: "CLI", servicesConfig: nil)
        let cliPlugin: Plugin = Plugin(binaryURL: cliBinaryURL, config: cliConfig)
        let serviceServicesConfig = PluginConfig.ServicesConfig(
            loadAtBoot: false,
            runAtLoad: false,
            services: [PluginConfig.Service(type: .runtime, description: nil)],
            defaultArguments: []
        )
        let serviceConfig = PluginConfig(abstract: "service", author: "SERVICE", servicesConfig: serviceServicesConfig)
        let servicePlugin: Plugin = Plugin(binaryURL: serviceBinaryURL, config: serviceConfig)
        let mockPlugins = [
            "cli": cliPlugin,
            MockPluginFactory.throwSuffix: nil,
            "service": servicePlugin,
        ]

        let factory = try MockPluginFactory(tempURL: tempURL, plugins: mockPlugins)
        return (factory, cliBinaryURL, serviceBinaryURL)
    }
}
