//===----------------------------------------------------------------------===//
// 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

#if canImport(FoundationEssentials)
import FoundationEssentials
#else
import Foundation
#endif

/// `User` provides utilities to ensure that a given username exists in
/// /etc/passwd (and /etc/group). Largely inspired by runc (and moby's)
/// `user` packages.
public enum User {
    public static let passwdFilePath = URL(filePath: "/etc/passwd")
    public static let groupFilePath = URL(filePath: "/etc/group")

    private static let minID: UInt32 = 0
    private static let maxID: UInt32 = 2_147_483_647

    public struct ExecUser: Sendable {
        public var uid: UInt32
        public var gid: UInt32
        public var sgids: [UInt32]
        public var home: String
        public var shell: String

        public init(uid: UInt32, gid: UInt32, sgids: [UInt32], home: String, shell: String) {
            self.uid = uid
            self.gid = gid
            self.sgids = sgids
            self.home = home
            self.shell = shell
        }
    }

    public struct User {
        public var name: String
        public var password: String
        public var uid: UInt32
        public var gid: UInt32
        public var gecos: String
        public var home: String
        public var shell: String

        /// The argument `rawString` must follow the below format.
        /// Name:Password:Uid:Gid:Gecos:Home:Shell
        init(rawString: String) throws {
            let args = rawString.split(separator: ":", omittingEmptySubsequences: false)
            guard args.count == 7 else {
                throw Error.parseError("cannot parse User from '\(rawString)'")
            }
            guard let uid = UInt32(args[2]) else {
                throw Error.parseError("cannot parse uid from '\(args[2])'")
            }
            guard let gid = UInt32(args[3]) else {
                throw Error.parseError("cannot parse gid from '\(args[3])'")
            }
            self.name = String(args[0])
            self.password = String(args[1])
            self.uid = uid
            self.gid = gid
            self.gecos = String(args[4])
            self.home = String(args[5])
            self.shell = String(args[6])
        }
    }

    struct Group {
        var name: String
        var password: String
        var gid: UInt32
        var users: [String]

        /// The argument `rawString` must follow the below format.
        /// Name:Password:Gid:user1,user2
        init(rawString: String) throws {
            let args = rawString.split(separator: ":", omittingEmptySubsequences: false)
            guard args.count == 4 else {
                throw Error.parseError("cannot parse Group from '\(rawString)'")
            }
            guard let gid = UInt32(args[2]) else {
                throw Error.parseError("cannot parse gid from '\(args[2])'")
            }
            self.name = String(args[0])
            self.password = String(args[1])
            self.gid = gid
            self.users = args[3].split(separator: ",").map { String($0) }
        }
    }
}

// MARK: Private methods

extension User {
    private static func parse(file: URL, handler: (_ line: String) throws -> Void) throws {
        let fm = FileManager.default
        guard fm.fileExists(atPath: file.absolutePath()) else {
            throw Error.missingFile(file.absolutePath())
        }
        let content = try String(contentsOf: file, encoding: .ascii)
        let lines = content.components(separatedBy: .newlines)
        for line in lines {
            let trimmed = line.trimmingCharacters(in: .whitespaces)
            guard !trimmed.isEmpty else {
                continue
            }
            guard !trimmed.hasPrefix("#") else {
                continue
            }
            try handler(trimmed)
        }
    }

    /// Parse the contents of the passwd file with a provided filter function.
    static func parsePasswd(passwdFile: URL, filter: ((User) -> Bool)? = nil) throws -> [User] {
        var users: [User] = []
        try self.parse(file: passwdFile) { line in
            let user = try User(rawString: line)
            if let filter {
                guard filter(user) else {
                    return
                }
            }
            users.append(user)
        }
        return users
    }

    /// Parse the contents of the group file with a provided filter function.
    static func parseGroup(groupFile: URL, filter: ((Group) -> Bool)? = nil) throws -> [Group] {
        var groups: [Group] = []
        try self.parse(file: groupFile) { line in
            let group = try Group(rawString: line)
            if let filter {
                guard filter(group) else {
                    return
                }
            }
            groups.append(group)
        }
        return groups
    }
}

// MARK: Public methods

extension User {
    /// Looks up uid in the password file specified by `passwdPath`.
    public static func lookupUid(passwdPath: URL = Self.passwdFilePath, uid: UInt32) throws -> User {
        let users = try parsePasswd(
            passwdFile: passwdPath,
            filter: { u in
                u.uid == uid
            })
        if users.count == 0 {
            throw Error.noPasswdEntries
        }
        return users[0]
    }

    /// Parses a user string in any of the following formats:
    /// "user, uid, user:group, uid:gid, uid:group, user:gid"
    /// and returns an ExecUser type from the information.
    public static func getExecUser(
        userString: String,
        defaults: ExecUser? = nil,
        passwdPath: URL = Self.passwdFilePath,
        groupPath: URL = Self.groupFilePath
    ) throws -> ExecUser {
        let defaults = defaults ?? ExecUser(uid: 0, gid: 0, sgids: [], home: "/", shell: "")

        var user = ExecUser(
            uid: defaults.uid,
            gid: defaults.gid,
            sgids: defaults.sgids,
            home: defaults.home,
            shell: defaults.shell
        )

        let parts = userString.split(
            separator: ":",
            maxSplits: 1,
            omittingEmptySubsequences: false
        )
        let userArg = parts.isEmpty ? "" : String(parts[0])
        let groupArg = parts.count > 1 ? String(parts[1]) : ""

        let uidArg = UInt32(userArg)
        let notUID = uidArg == nil
        let gidArg = UInt32(groupArg)
        let notGID = gidArg == nil

        let users: [User]
        do {
            users = try parsePasswd(passwdFile: passwdPath) { u in
                if userArg.isEmpty {
                    return u.uid == user.uid
                }
                if !notUID {
                    return uidArg! == u.uid
                }
                return u.name == userArg
            }
        } catch Error.missingFile {
            users = []
        }

        var matchedUserName = ""
        if !users.isEmpty {
            let matchedUser = users[0]
            matchedUserName = matchedUser.name
            user.uid = matchedUser.uid
            user.gid = matchedUser.gid
            user.home = matchedUser.home
            user.shell = matchedUser.shell
        } else if !userArg.isEmpty {
            if notUID {
                throw Error.noPasswdEntries
            }

            user.uid = uidArg!
            if user.uid < minID || user.uid > maxID {
                throw Error.range
            }
        }

        if !groupArg.isEmpty || !matchedUserName.isEmpty {
            let groups: [Group]
            do {
                groups = try parseGroup(groupFile: groupPath) { g in
                    if groupArg.isEmpty {
                        return g.users.contains(matchedUserName)
                    }
                    if !notGID {
                        return gidArg! == g.gid
                    }
                    return g.name == groupArg
                }
            } catch Error.missingFile {
                groups = []
            }

            if !groupArg.isEmpty {
                if !groups.isEmpty {
                    user.gid = groups[0].gid
                } else {
                    if notGID {
                        throw Error.noGroupEntries
                    }

                    user.gid = gidArg!
                    if user.gid < minID || user.gid > maxID {
                        throw Error.range
                    }
                }
            }
            user.sgids = groups.map { $0.gid }
        }
        return user
    }

    public enum Error: Swift.Error {
        case missingFile(String)
        case range
        case noPasswdEntries
        case noGroupEntries
        case parseError(String)
    }
}
