import Crypto
import Foundation
import XCTest

@testable import NucleicMDM

final class MDMSignatureVerifierTests: XCTestCase {
    func testValidSignatureVerifies() async throws {
        let ca = try MDMCertificateAuthority.generate()
        let identity = try MDMDeviceIdentity.issue(from: ca)
        let body = Data("<plist>checkin</plist>".utf8)
        let sig = try MDMSignatureVerifier.sign(body: body, identity: identity)
        let ok = await MDMSignatureVerifier.verify(body: body, detachedCMS: sig, ca: ca)
        XCTAssertTrue(ok)
    }

    func testTamperedBodyFailsVerification() async throws {
        let ca = try MDMCertificateAuthority.generate()
        let identity = try MDMDeviceIdentity.issue(from: ca)
        let sig = try MDMSignatureVerifier.sign(body: Data("original".utf8), identity: identity)
        let ok = await MDMSignatureVerifier.verify(
            body: Data("tampered".utf8), detachedCMS: sig, ca: ca)
        XCTAssertFalse(ok)
    }

    func testSignatureFromForeignCAFails() async throws {
        let ca = try MDMCertificateAuthority.generate()
        let otherCA = try MDMCertificateAuthority.generate()
        let foreign = try MDMDeviceIdentity.issue(from: otherCA)
        let body = Data("body".utf8)
        let sig = try MDMSignatureVerifier.sign(body: body, identity: foreign)
        // Signature is cryptographically valid but does not chain to our CA.
        let ok = await MDMSignatureVerifier.verify(body: body, detachedCMS: sig, ca: ca)
        XCTAssertFalse(ok)
    }
}

final class MDMServerControllerTests: XCTestCase {
    func testStageArtifactsWritesAllThree() throws {
        let controller = try MDMServerController.makeFresh()
        let dir = FileManager.default.temporaryDirectory
            .appendingPathComponent("nucleic-mdm-artifacts-\(UUID().uuidString)")
        defer { try? FileManager.default.removeItem(at: dir) }
        let script = try controller.stageArtifacts(into: dir)

        XCTAssertTrue(
            FileManager.default.fileExists(
                atPath: dir.appendingPathComponent(MDMServerController.Artifact.caCert).path))
        // The STAGED profile is an UNSIGNED plist. It was CMS-signed until a live run failed the
        // install with "The certificate could not be verified (authentication error)" while the
        // server trace showed mdmclient had never connected — so the rejection was the profile's own
        // signing chain, which ends at a CA the guest cannot be made to trust, not the check-in TLS.
        // macOS installs an unsigned profile after flagging it "Not Signed"; that is the working case.
        let staged = try Data(
            contentsOf: dir.appendingPathComponent(MDMServerController.Artifact.profile))
        let dict = try MDMPlist.parse(staged)
        XCTAssertEqual(dict["PayloadType"] as? String, "Configuration")
        XCTAssertNotEqual(
            staged.first, 0x30, "a DER SEQUENCE here would mean the profile got signed again")

        // The staged profile must satisfy the SAME tool the guest's pre-flight runs on it. This is the
        // coupling that broke a live run: the profile was switched from CMS-signed to unsigned while
        // the pre-flight still gated on `security cms -D`, which cannot decode a plain plist — so the
        // build failed at its own gate, before ever attempting the install. Running the real tool here
        // ties the two ends together instead of asserting a shape twice.
        if FileManager.default.isExecutableFile(atPath: "/usr/bin/plutil") {
            let lint = Process()
            lint.executableURL = URL(fileURLWithPath: "/usr/bin/plutil")
            lint.arguments = [
                "-lint", dir.appendingPathComponent(MDMServerController.Artifact.profile).path,
            ]
            lint.standardOutput = Pipe()
            lint.standardError = Pipe()
            try lint.run()
            lint.waitUntilExit()
            XCTAssertEqual(
                lint.terminationStatus, 0,
                "the staged profile must pass the pre-flight's `plutil -lint` check")
        }

        let scriptText = try String(contentsOf: script, encoding: .utf8)
        XCTAssertTrue(scriptText.contains("/etc/hosts"))
        XCTAssertTrue(scriptText.contains(controller.config.serverDNSName))
        XCTAssertTrue(scriptText.contains("MDM_ENROLL_STAGED"))
    }

    /// The enroll script establishes NO trust of its own. Both routes it has historically tried are
    /// closed: `security add-trusted-cert` raises a SecurityAgent sheet a root CLI can never focus,
    /// and a manually installed root PROFILE has not been auto-trusted for TLS since macOS Ventura.
    /// Trust is written to the admin domain during the preceding minimal-agent boot, so all this
    /// script does is make the server's DNS name resolve.
    func testEnrollScriptOnlyPinsTheHostAndTrustsNothing() throws {
        let controller = try MDMServerController.makeFresh()
        let script = controller.enrollScript()

        // Checked against the script's EXECUTABLE lines only. The script carries a comment recording
        // why each of these routes is closed, and that record is worth more than the convenience of a
        // whole-file substring match — a future reader reaching for one of them should find the reason
        // in place rather than rediscover it over thirteen live runs.
        func assertNeverInvoked(_ needle: String, _ why: String) {
            for line in script.split(separator: "\n") {
                let code = line.drop(while: { $0 == " " })
                guard !code.hasPrefix("#") else { continue }
                XCTAssertFalse(code.contains(needle), "\(why): \(code)")
            }
        }
        assertNeverInvoked(
            "add-trusted-cert",
            "the CLI trust sheet is un-driveable; trust comes from the minimal-agent boot")
        assertNeverInvoked(
            MDMServerController.Artifact.profile,
            "the enrollment profile is opened by the host, after the pre-flight passes")
        // Synchronous: nothing prompts, so no backgrounding or sentinel.
        XCTAssertFalse(script.contains("disown"))
        XCTAssertFalse(script.contains("BGLOG"))
        XCTAssertTrue(script.contains("MDM_ENROLL_STAGED"))

        // What it must still do: pin the server name to the discovered gateway.
        XCTAssertTrue(script.contains("/etc/hosts"))
        XCTAssertTrue(script.contains(controller.config.serverDNSName))

        // Console-user canary present; script is not wrapped in sudo and tolerates `open`'s exit.
        XCTAssertTrue(script.contains("stat -f%Su /dev/console"))
        XCTAssertFalse(script.contains("set -euo pipefail"))
        XCTAssertTrue(script.contains("set -uo pipefail"))
    }

    /// The admin trust-settings file is the fallback for a platform with no non-interactive trust API
    /// left, so its shape is the whole contract — a wrong key name fails *silently* in the guest
    /// (trustd just ignores the entry) rather than erroring. Pinned against the layout Security itself
    /// writes for `add-trusted-cert -r trustRoot`.
    func testAdminTrustSettingsMatchesTheSecurityFrameworkLayout() throws {
        let controller = try MDMServerController.makeFresh()
        let plist = try controller.adminTrustSettingsPlist(now: Date(timeIntervalSince1970: 1_700_000))
        let root = try XCTUnwrap(
            try PropertyListSerialization.propertyList(from: plist, format: nil) as? [String: Any])

        XCTAssertEqual(root["trustVersion"] as? Int, 1)
        let trustList = try XCTUnwrap(root["trustList"] as? [String: Any])
        XCTAssertEqual(trustList.count, 1, "exactly one certificate is trusted")

        // Keyed by the certificate's SHA-1 fingerprint in upper-case hex.
        let key = try XCTUnwrap(trustList.keys.first)
        XCTAssertEqual(key.count, 40, "SHA-1 hex is 40 characters")
        XCTAssertEqual(key, key.uppercased())
        XCTAssertTrue(key.allSatisfy(\.isHexDigit))
        let expected = Insecure.SHA1.hash(data: try controller.caCertificateDER())
            .map { String(format: "%02X", $0) }.joined()
        XCTAssertEqual(key, expected, "the key must be the SHA-1 of the DER we stage as the .cer")

        let entry = try XCTUnwrap(trustList[key] as? [String: Any])
        XCTAssertNotNil(entry["issuerName"] as? Data)
        XCTAssertNotNil(entry["serialNumber"] as? Data)
        XCTAssertEqual(entry["modDate"] as? Date, Date(timeIntervalSince1970: 1_700_000))

        // A lone blanket result and no policy scoping — `add-trusted-cert` with no `-p` writes this,
        // and any `kSecTrustSettingsPolicy` here would narrow trust to one policy instead of all.
        let settings = try XCTUnwrap(entry["trustSettings"] as? [[String: Any]])
        XCTAssertEqual(settings.count, 1)
        XCTAssertEqual(settings[0]["kSecTrustSettingsResult"] as? Int, 1)  // ResultTrustRoot
        XCTAssertNil(settings[0]["kSecTrustSettingsPolicy"])

        // The root's own serial, so the entry actually describes the certificate we trust.
        let recordedSerial: [UInt8] = Array(try XCTUnwrap(entry["serialNumber"] as? Data))
        let actualSerial: [UInt8] = Array(controller.ca.rootCertificate.serialNumber.bytes)
        XCTAssertEqual(recordedSerial, actualSerial)
    }

    func testCACertificateDERIsAValidCertificate() throws {
        let controller = try MDMServerController.makeFresh()
        let der = try controller.caCertificateDER()
        XCTAssertEqual(der.first, 0x30)  // SEQUENCE
        XCTAssertGreaterThan(der.count, 300)
    }

    func testQueueInstallProfilesEnqueuesAndReturnsBatchIDs() async throws {
        let controller = try MDMServerController.makeFresh()
        let ids = await controller.queueInstallProfiles([Data("a".utf8), Data("b".utf8)])
        let count = await controller.queue.pendingCount
        XCTAssertEqual(count, 2)
        XCTAssertEqual(Set(ids).count, 2)
    }

    func testPersistedControllerRoundTripsIdentityAndRestrictsFilePermissions() async throws {
        let controller = try MDMServerController.makeFresh(
            config: .generate(port: 9443, topicUUID: UUID(
                uuidString: "11111111-2222-3333-4444-555555555555")!))
            .withProfileIdentifiers([
                "xyz.blakeslee.nucleic.pppc.vmagent",
                "xyz.blakeslee.nucleic.notifications.suppress",
            ])
        let dir = FileManager.default.temporaryDirectory
            .appendingPathComponent("nucleic-mdm-state-\(UUID().uuidString)")
        defer { try? FileManager.default.removeItem(at: dir) }
        let stateURL = dir.appendingPathComponent("mdm-state.json")

        try controller.writePersistedState(to: stateURL)
        let restored = try MDMServerController.loadPersistedState(from: stateURL)

        XCTAssertEqual(restored.config, controller.config)
        XCTAssertEqual(try restored.caCertificateDER(), try controller.caCertificateDER())
        XCTAssertEqual(
            try restored.deviceIdentity.persisted().certificatePEM,
            try controller.deviceIdentity.persisted().certificatePEM)
        XCTAssertEqual(restored.deviceIdentity.password, controller.deviceIdentity.password)
        XCTAssertEqual(restored.profileIdentifiers, controller.profileIdentifiers)

        // The reloaded private key still signs as the same device identity under the persisted CA.
        let body = Data("persisted identity".utf8)
        let signature = try MDMSignatureVerifier.sign(body: body, identity: restored.deviceIdentity)
        let signatureVerified = await MDMSignatureVerifier.verify(
            body: body, detachedCMS: signature, ca: restored.ca)
        XCTAssertTrue(signatureVerified)

        let attrs = try FileManager.default.attributesOfItem(atPath: stateURL.path)
        let permissions = (attrs[.posixPermissions] as? NSNumber)?.intValue
        XCTAssertEqual(permissions.map { $0 & 0o777 }, 0o600)
    }

    func testPersistedControllerRejectsUnknownVersion() throws {
        let controller = try MDMServerController.makeFresh()
        var state = try controller.persisted()
        state.version = 999
        XCTAssertThrowsError(try MDMServerController(persisted: state)) { error in
            XCTAssertTrue(String(describing: error).contains("version 999"))
        }
    }
}
