import Foundation

final class FutureModificationDateClamp: @unchecked Sendable {
    private let lock = NSLock()
    private var clampDate: Date?

    init(clampDate: Date? = nil) {
        self.clampDate = clampDate
    }

    func clamp(url _: URL, modifiedAt: Date, now: Date) -> Date {
        guard modifiedAt > now else { return modifiedAt }
        return self.lock.withLock {
            if let clampDate = self.clampDate {
                return min(clampDate, now)
            }
            self.clampDate = now
            return now
        }
    }
}

public struct LocalAgentSessionScanner: Sendable {
    typealias ProcessOutputProvider = @Sendable ([String: String]) async -> String
    typealias CWDProvider = @Sendable ([Int32], [String: String]) async -> [Int32: String]

    private struct Rollout: Sendable {
        let url: URL
        let modifiedAt: Date
        let metadata: CodexRolloutMetadata
    }

    private struct ScanContext: Sendable {
        let homeDirectory: URL
        let host: String
        let now: Date
        let codexAppServerPresent: Bool
        let includeFileOnlySessions: Bool
        let threadMetadata: [String: CodexThreadMetadata]
        let piFamilySessions: [AgentSession]
    }

    public let config: SessionScanConfig
    private let futureModificationDateClamp = FutureModificationDateClamp()
    private let processOutputProvider: ProcessOutputProvider?
    private let cwdProvider: CWDProvider?
    private let didVisitDirectoryEntry: (@Sendable () -> Void)?

    public init(config: SessionScanConfig = SessionScanConfig()) {
        self.config = config
        self.processOutputProvider = nil
        self.cwdProvider = nil
        self.didVisitDirectoryEntry = nil
    }

    init(
        config: SessionScanConfig = SessionScanConfig(),
        processOutputProvider: @escaping ProcessOutputProvider,
        cwdProvider: @escaping CWDProvider,
        didVisitDirectoryEntry: (@Sendable () -> Void)? = nil)
    {
        self.config = config
        self.processOutputProvider = processOutputProvider
        self.cwdProvider = cwdProvider
        self.didVisitDirectoryEntry = didVisitDirectoryEntry
    }

    @concurrent
    public func scan(
        now: Date = Date(),
        environment: [String: String] = ProcessInfo.processInfo.environment,
        includeFileOnlySessions: Bool = true) async -> [AgentSession]
    {
        let allProcesses = if let processOutputProvider = self.processOutputProvider {
            await AgentPSOutputParser.parse(processOutputProvider(environment))
        } else {
            await self.processRecords(environment: environment)
        }
        let processes = Array(AgentSessionCorrelation.newestProcessesFirst(
            AgentPSOutputParser.agentProcesses(from: allProcesses))
            .prefix(max(0, self.config.maxProcessCount)))
        guard Self.shouldScanSessionMetadata(
            hasAgentProcesses: !processes.isEmpty,
            includeFileOnlySessions: includeFileOnlySessions)
        else { return [] }
        let codexAppServerPresent = AgentPSOutputParser.hasCodexAppServer(in: allProcesses)
        let cwdByPID = if let cwdProvider = self.cwdProvider {
            await cwdProvider(processes.map(\ .pid), environment)
        } else {
            await self.cwdByPID(processes.map(\ .pid), environment: environment)
        }
        let codexCWDs = processes.compactMap { process -> String? in
            guard AgentPSOutputParser.provider(for: process) == .codex else { return nil }
            return cwdByPID[process.pid]
        }
        let homeDirectory = URL(fileURLWithPath: environment["HOME"] ?? NSHomeDirectory(), isDirectory: true)
        let codexHomeDirectory = URL(
            fileURLWithPath: environment["CODEX_HOME"] ?? homeDirectory.appendingPathComponent(".codex").path,
            isDirectory: true)
        let host = ProcessInfo.processInfo.hostName
        var directoryBudget = DirectoryMetadataScanBudget(
            maxEntryCount: self.config.maxDirectoryEntryCount,
            maxDepth: self.config.maxDirectoryDepth,
            timeLimit: includeFileOnlySessions
                ? self.config.directoryScanBudget
                : min(self.config.directoryScanBudget, self.config.adaptiveDirectoryScanBudget),
            didVisitEntry: self.didVisitDirectoryEntry)
        var piFamilyDirectoryBudget = DirectoryMetadataScanBudget(
            maxEntryCount: self.config.maxDirectoryEntryCount,
            maxDepth: self.config.maxDirectoryDepth,
            timeLimit: includeFileOnlySessions
                ? self.config.directoryScanBudget
                : min(self.config.directoryScanBudget, self.config.adaptiveDirectoryScanBudget),
            didVisitEntry: self.didVisitDirectoryEntry)
        let piFamilySessions = PiFamilySessionScanner.scan(
            input: PiFamilySessionScanner.ScanInput(
                processes: processes,
                cwdByPID: cwdByPID,
                environment: environment,
                now: now,
                host: host,
                config: self.config),
            directoryBudget: &piFamilyDirectoryBudget)
        let rollouts: [Rollout] = if includeFileOnlySessions || !codexCWDs.isEmpty {
            self.codexRollouts(
                now: now,
                codexHomeDirectory: codexHomeDirectory,
                matchingCWDs: includeFileOnlySessions ? nil : codexCWDs,
                directoryBudget: &directoryBudget)
        } else {
            []
        }
        let threadMetadata = Self.codexThreadMetadata(
            rollouts: rollouts,
            codexHomeDirectory: codexHomeDirectory,
            environment: environment)
        return self.sessions(
            processes: processes,
            cwdByPID: cwdByPID,
            rollouts: rollouts,
            context: ScanContext(
                homeDirectory: homeDirectory,
                host: host,
                now: now,
                codexAppServerPresent: codexAppServerPresent,
                includeFileOnlySessions: includeFileOnlySessions,
                threadMetadata: threadMetadata,
                piFamilySessions: piFamilySessions),
            directoryBudget: &directoryBudget)
    }

    public static func shouldScanSessionMetadata(
        hasAgentProcesses: Bool,
        includeFileOnlySessions: Bool) -> Bool
    {
        hasAgentProcesses || includeFileOnlySessions
    }

    private static func codexThreadMetadata(
        rollouts: [Rollout],
        codexHomeDirectory: URL,
        environment: [String: String])
        -> [String: CodexThreadMetadata]
    {
        let sessionIDs = Set(rollouts.map(\.metadata.sessionID))
        let indexedNames = CodexThreadMetadataReader.indexedThreadNames(
            codexHomeDirectory: codexHomeDirectory,
            sessionIDs: sessionIDs)
        var groups: [String: (reader: CodexThreadMetadataReader, sessionIDs: Set<String>)] = [:]
        for rollout in rollouts {
            let resolvedWorkingDirectory = rollout.metadata.cwd.map {
                URL(fileURLWithPath: $0, isDirectory: true).standardizedFileURL
            }
            let reader = CodexThreadMetadataReader(
                codexHomeDirectory: codexHomeDirectory,
                environment: environment,
                resolvedWorkingDirectory: resolvedWorkingDirectory)
            let key = reader.databaseURL.path
            if var group = groups[key] {
                group.sessionIDs.insert(rollout.metadata.sessionID)
                groups[key] = group
            } else {
                groups[key] = (reader, [rollout.metadata.sessionID])
            }
        }

        var metadata: [String: CodexThreadMetadata] = [:]
        for group in groups.values {
            metadata.merge(group.reader.metadata(for: group.sessionIDs, indexedNames: indexedNames)) { _, latest in
                latest
            }
        }
        return metadata
    }

    private func sessions(
        processes: [AgentProcessRecord],
        cwdByPID: [Int32: String],
        rollouts: [Rollout],
        context: ScanContext,
        directoryBudget: inout DirectoryMetadataScanBudget) -> [AgentSession]
    {
        var sessions: [AgentSession] = []
        var matchedRolloutPaths = Set<String>()
        let claudeProcesses = processes.filter { AgentPSOutputParser.provider(for: $0) == .claude }
        let claudeCWDs = Set(claudeProcesses.compactMap { cwdByPID[$0.pid] })
        var claudeTranscriptsByCWD: [String: [ClaudeSessionProjectMapper.Transcript]] = [:]
        for cwd in claudeCWDs {
            claudeTranscriptsByCWD[cwd] = ClaudeSessionProjectMapper.transcripts(
                cwd: cwd,
                homeDirectory: context.homeDirectory,
                limit: self.config.maxClaudeTranscriptCountPerProject,
                now: context.now,
                budget: &directoryBudget,
                clampModificationDate: self.futureModificationDateClamp.clamp)
        }
        let claudeTranscripts = AgentSessionCorrelation.assignClaudeTranscripts(
            processes: claudeProcesses,
            cwdByPID: cwdByPID,
            transcriptsByCWD: claudeTranscriptsByCWD)
        let codexProcesses = processes.filter { AgentPSOutputParser.provider(for: $0) == .codex }
        let codexDescriptiveNamePIDs = AgentSessionCorrelation.unambiguousProcessIDs(
            processes: codexProcesses,
            cwdByPID: cwdByPID)

        for process in processes {
            guard let provider = AgentPSOutputParser.provider(for: process) else { continue }
            let cwd = cwdByPID[process.pid]
            switch provider {
            case .claude:
                let transcript = claudeTranscripts[process.pid]
                sessions.append(AgentSession(
                    id: transcript?.url.deletingPathExtension().lastPathComponent ?? "pid:\(process.pid)",
                    provider: .claude,
                    source: AgentPSOutputParser.source(for: process),
                    state: self.config.state(
                        lastActivityAt: transcript?.modifiedAt,
                        now: context.now,
                        hasLiveProcess: true),
                    pid: process.pid,
                    cwd: cwd,
                    projectName: Self.projectName(cwd),
                    startedAt: process.startedAt,
                    lastActivityAt: transcript?.modifiedAt,
                    transcriptPath: transcript?.url.path,
                    host: context.host))
            case .codex:
                let rollout = rollouts.first { candidate in
                    !matchedRolloutPaths.contains(candidate.url.path) &&
                        AgentSessionCorrelation.codexWorkingDirectoriesMatch(candidate.metadata.cwd, cwd)
                }
                if let rollout {
                    matchedRolloutPaths.insert(rollout.url.path)
                }
                let rolloutSource = rollout?.metadata.sessionSource
                sessions.append(AgentSession(
                    id: rollout?.metadata.sessionID ?? "pid:\(process.pid)",
                    provider: .codex,
                    source: rolloutSource == nil || rolloutSource == .unknown ? .cli : rolloutSource ?? .cli,
                    state: self.config.state(
                        lastActivityAt: rollout?.modifiedAt,
                        now: context.now,
                        hasLiveProcess: true),
                    pid: process.pid,
                    cwd: cwd ?? rollout?.metadata.cwd,
                    projectName: Self.projectName(cwd ?? rollout?.metadata.cwd),
                    sessionName: codexDescriptiveNamePIDs.contains(process.pid)
                        ? rollout?.metadata.descriptiveName(
                            threadMetadata: rollout.flatMap { context.threadMetadata[$0.metadata.sessionID] })
                        : nil,
                    startedAt: process.startedAt,
                    lastActivityAt: rollout?.modifiedAt,
                    transcriptPath: rollout?.url.path,
                    host: context.host))
            case .pi:
                continue
            }
        }

        for rollout in rollouts
            where context.includeFileOnlySessions && !matchedRolloutPaths.contains(rollout.url.path)
        {
            guard var session = CodexRolloutFirstLineParser.makeSession(
                metadata: rollout.metadata,
                transcriptURL: rollout.url,
                modifiedAt: rollout.modifiedAt,
                host: context.host,
                config: self.config,
                now: context.now)
            else { continue }
            session.sessionName = rollout.metadata.descriptiveName(
                threadMetadata: context.threadMetadata[rollout.metadata.sessionID])
            session.source = AgentSessionCorrelation.fileOnlyCodexSource(
                metadataSource: session.source,
                appServerPresent: context.codexAppServerPresent)
            sessions.append(session)
        }
        sessions.append(contentsOf: context.piFamilySessions)

        var seen = Set<String>()
        return sessions
            .sorted { lhs, rhs in
                if lhs.state != rhs.state {
                    return lhs.state == .active
                }
                return (lhs.lastActivityAt ?? lhs.startedAt ?? .distantPast) >
                    (rhs.lastActivityAt ?? rhs.startedAt ?? .distantPast)
            }
            .filter { seen.insert("\($0.host):\($0.id)").inserted }
    }

    private func processRecords(environment: [String: String]) async -> [AgentProcessRecord] {
        #if canImport(Darwin)
        return DarwinProcessEnumerator.allPIDs().compactMap { pid in
            guard let bsdInfo = DarwinProcessEnumerator.bsdInfo(pid: pid),
                  let executablePath = DarwinProcessEnumerator.executablePath(pid: pid)
            else { return nil }
            let command = DarwinProcessEnumerator.commandLine(pid: pid) ?? executablePath
            return AgentProcessRecord(
                pid: pid,
                ppid: bsdInfo.ppid,
                startedAt: bsdInfo.startTime,
                command: command)
        }
        #else
        return await AgentPSOutputParser.parse(self.processOutput(environment: environment))
        #endif
    }

    #if !canImport(Darwin)
    private func processOutput(environment: [String: String]) async -> String {
        let binary = ["/bin/ps", "/usr/bin/ps"].first { FileManager.default.isExecutableFile(atPath: $0) }
        guard let binary,
              let result = try? await SubprocessRunner.run(
                  binary: binary,
                  arguments: ["-axo", "pid=,ppid=,lstart=,command="],
                  environment: environment,
                  timeout: 5,
                  label: "agent session process scan")
        else { return "" }
        return result.stdout
    }
    #endif

    private func cwdByPID(_ pids: [Int32], environment: [String: String]) async -> [Int32: String] {
        guard !pids.isEmpty else { return [:] }
        #if canImport(Darwin)
        return Dictionary(uniqueKeysWithValues: pids.compactMap { pid in
            DarwinProcessEnumerator.currentWorkingDirectory(pid: pid).map { (pid, $0) }
        })
        #else
        if let lsof = self.findExecutable("lsof", environment: environment) {
            let joinedPIDs = pids.map(String.init).joined(separator: ",")
            if let result = try? await SubprocessRunner.run(
                binary: lsof,
                arguments: ["-a", "-d", "cwd", "-Fn", "-p", joinedPIDs],
                environment: environment,
                timeout: 5,
                acceptsNonZeroExit: true,
                label: "agent session cwd scan")
            {
                return LSOFCWDOutputParser.parse(result.stdout)
            }
        }

        return Dictionary(uniqueKeysWithValues: pids.compactMap { pid in
            let path = "/proc/\(pid)/cwd"
            guard let destination = try? FileManager.default.destinationOfSymbolicLink(atPath: path) else { return nil }
            return (pid, destination)
        })
        #endif
    }

    private func codexRollouts(
        now: Date,
        codexHomeDirectory: URL,
        matchingCWDs: [String]?,
        directoryBudget: inout DirectoryMetadataScanBudget) -> [Rollout]
    {
        let root = codexHomeDirectory.appendingPathComponent("sessions", isDirectory: true)
        let calendar = Calendar(identifier: .gregorian)
        let days = [now, calendar.date(byAdding: .day, value: -1, to: now)].compactMap(\.self)
        let formatter = DateFormatter()
        formatter.locale = Locale(identifier: "en_US_POSIX")
        formatter.dateFormat = "yyyy/MM/dd"
        let fileManager = FileManager.default

        let candidates = days.flatMap { day -> [(url: URL, modifiedAt: Date)] in
            let directory = root.appendingPathComponent(formatter.string(from: day), isDirectory: true)
            return directoryBudget.files(in: directory, fileManager: fileManager).compactMap { file in
                guard file.lastPathComponent.hasPrefix("rollout-"), file.pathExtension == "jsonl",
                      let modifiedAt = try? file.resourceValues(
                          forKeys: [.contentModificationDateKey]).contentModificationDate
                else { return nil }
                return (file, self.futureModificationDateClamp.clamp(
                    url: file,
                    modifiedAt: modifiedAt,
                    now: now))
            }
        }.sorted { $0.modifiedAt > $1.modifiedAt }

        var remainingCWDs = matchingCWDs ?? []
        var rollouts: [Rollout] = []
        for candidate in candidates.prefix(max(0, self.config.maxCodexRolloutCount)) {
            guard directoryBudget.hasTimeRemaining() else { break }
            guard let metadata = CodexRolloutFirstLineParser.read(from: candidate.url) else { continue }
            rollouts.append(Rollout(url: candidate.url, modifiedAt: candidate.modifiedAt, metadata: metadata))
            if let index = remainingCWDs.firstIndex(where: {
                AgentSessionCorrelation.codexWorkingDirectoriesMatch(metadata.cwd, $0)
            }) {
                remainingCWDs.remove(at: index)
                if matchingCWDs != nil, remainingCWDs.isEmpty {
                    break
                }
            }
        }
        return rollouts
    }

    private func findExecutable(_ name: String, environment: [String: String]) -> String? {
        let path = environment["PATH"] ?? "/usr/local/bin:/usr/bin:/bin:/opt/homebrew/bin"
        return path.split(separator: ":")
            .map { String($0) + "/" + name }
            .first { FileManager.default.isExecutableFile(atPath: $0) }
    }

    private static func standardized(_ path: String?) -> String? {
        path.map { URL(fileURLWithPath: $0).standardizedFileURL.path }
    }

    private static func projectName(_ cwd: String?) -> String? {
        guard let cwd, !cwd.isEmpty else { return nil }
        return URL(fileURLWithPath: cwd).lastPathComponent
    }
}
