import Foundation
import SQLite3

public actor TokenUsageScanner {
    public let homeDirectory: URL
    public let cacheURL: URL
    private let chunkSize = 256 * 1024
    private var lastSignature: FileSignature?
    private var lastSummary: UsageSummary?

    public init(
        homeDirectory: URL = FileManager.default.homeDirectoryForCurrentUser,
        cacheURL: URL? = nil
    ) {
        self.homeDirectory = homeDirectory
        self.cacheURL = cacheURL ?? homeDirectory
            .appending(path: "Library/Application Support/TokenLens")
            .appending(path: "usage-cache.sqlite")
    }

    public func scan(now: Date = Date()) async -> UsageSummary {
        defer { MemoryPressure.relieve() }

        if let summary = scanWithCache(now: now) {
            return summary
        }

        var accumulator = UsageSummaryAccumulator(now: now)
        scanClaude(into: &accumulator)
        scanCodex(into: &accumulator)
        scanGemini(into: &accumulator)
        scanHermes(into: &accumulator)
        scanOpenCode(into: &accumulator)
        return accumulator.makeSummary()
    }

    private func scanWithCache(now: Date) -> UsageSummary? {
        do {
            let snapshots = try logFileSnapshots()
            let signature = FileSignature(snapshots: snapshots)
            if signature == lastSignature,
               let lastSummary,
               isSameDisplayWindow(lastSummary.generatedAt, now) {
                let updated = lastSummary.updatingGeneratedAt(now)
                self.lastSummary = updated
                return updated
            }

            let cache = try SQLiteUsageCache(url: cacheURL)
            let currentPaths = try refreshCache(cache, snapshots: snapshots)
            try cache.pruneMissingFiles(currentPaths: currentPaths)
            let summary = try cache.makeSummary(now: now)
            lastSignature = signature
            lastSummary = summary
            return summary
        } catch {
            return nil
        }
    }

    private func refreshCache(_ cache: SQLiteUsageCache, snapshots: [LogFileSnapshot]) throws -> Set<String> {
        var currentPaths = Set<String>()
        for snapshot in snapshots {
            currentPaths.insert(snapshot.path)
            let state = try cache.fileState(path: snapshot.path)
            let isCurrent = try cache.isFileCurrent(
                path: snapshot.path,
                size: snapshot.size,
                modifiedAt: snapshot.modifiedAt
            )
            if isCurrent && (snapshot.scanner.supportsZeroRecordCache || (state?.recordCount ?? 0) > 0) {
                continue
            }

            if let state,
               snapshot.size > state.size,
               state.size == state.scannedOffset,
               state.scannedOffset > 0,
               snapshot.scanner.supportsIncrementalScan {
                try cache.appendFileRecords(
                    path: snapshot.path,
                    size: snapshot.size,
                    modifiedAt: snapshot.modifiedAt,
                    startingRecordCount: state.recordCount
                ) { insert in
                    try scanFile(
                        snapshot.url,
                        startingAt: UInt64(state.scannedOffset),
                        scanner: snapshot.scanner,
                        consume: insert
                    ).scannedOffset
                }
            } else {
                try cache.replaceFileRecords(
                    path: snapshot.path,
                    size: snapshot.size,
                    modifiedAt: snapshot.modifiedAt
                ) { insert in
                    try scanFile(snapshot.url, scanner: snapshot.scanner, consume: insert).scannedOffset
                }
            }
        }
        return currentPaths
    }

    private func scanClaude(into accumulator: inout UsageSummaryAccumulator) {
        scanJSONLFiles(
            root: homeDirectory.appending(path: ".claude/projects"),
            include: { $0.pathExtension == "jsonl" },
            parse: TokenUsageParser.parseClaudeLine,
            into: &accumulator
        )
    }

    private func scanCodex(into accumulator: inout UsageSummaryAccumulator) {
        scanJSONLFiles(
            root: homeDirectory.appending(path: ".codex/sessions"),
            include: { $0.pathExtension == "jsonl" },
            parse: TokenUsageParser.parseCodexLine,
            into: &accumulator
        )
    }

    private func scanGemini(into accumulator: inout UsageSummaryAccumulator) {
        scanJSONLFiles(
            root: homeDirectory.appending(path: ".gemini"),
            include: { url in
                url.pathExtension == "jsonl" && url.pathComponents.contains("chats")
            },
            parse: TokenUsageParser.parseGeminiLine,
            into: &accumulator
        )
    }

    private func scanHermes(into accumulator: inout UsageSummaryAccumulator) {
        for file in files(root: homeDirectory.appending(path: ".hermes"), include: { $0.lastPathComponent == "state.db" }) {
            _ = try? scanHermesStateDB(file) { record in
                accumulator.add(record)
            }
        }
    }

    private func scanOpenCode(into accumulator: inout UsageSummaryAccumulator) {
        for file in files(root: homeDirectory.appending(path: ".local/share/opencode"), include: { $0.lastPathComponent == "opencode.db" }) {
            _ = try? scanOpenCodeDB(file) { record in
                accumulator.add(record)
            }
        }
    }

    private func scanJSONLFiles(
        root: URL,
        include: (URL) -> Bool,
        parse: (String, String) -> UsageRecord?,
        into accumulator: inout UsageSummaryAccumulator
    ) {
        for file in files(root: root, include: include) {
            _ = try? scanJSONLFile(file, parse: parse) { record in
                accumulator.add(record)
            }
        }
    }

    private func scanFile(
        _ file: URL,
        startingAt offset: UInt64 = 0,
        scanner: SourceScanner,
        consume: (UsageRecord) throws -> Void
    ) throws -> ScanProgress {
        switch scanner {
        case let .jsonl(parse):
            return try scanJSONLFile(file, startingAt: offset, parse: parse, consume: consume)
        case .hermesStateDB:
            return try scanHermesStateDB(file, consume: consume)
        case .openCodeDB:
            return try scanOpenCodeDB(file, consume: consume)
        }
    }

    private func scanJSONLFile(
        _ file: URL,
        startingAt offset: UInt64 = 0,
        parse: (String, String) -> UsageRecord?,
        consume: (UsageRecord) throws -> Void
    ) throws -> ScanProgress {
        guard let handle = try? FileHandle(forReadingFrom: file) else {
            return ScanProgress(scannedOffset: Int64(offset), recordCount: 0)
        }
        defer { try? handle.close() }
        try handle.seek(toOffset: offset)

        var pendingLine = Data()
        var scannedOffset = Int64(offset)
        var recordCount = 0

        while true {
            let chunk = (try? handle.read(upToCount: chunkSize)) ?? Data()
            if chunk.isEmpty { break }

            var lineStart = chunk.startIndex
            for index in chunk.indices where chunk[index] == 0x0A {
                let lineData = chunk[lineStart..<index]
                if pendingLine.isEmpty {
                    scannedOffset += Int64(lineData.count + 1)
                    try autoreleasepool {
                        if try appendRecord(from: lineData, sourcePath: file.path, parse: parse, consume: consume) {
                            recordCount += 1
                        }
                    }
                } else {
                    pendingLine.append(contentsOf: lineData)
                    scannedOffset += Int64(pendingLine.count + 1)
                    try autoreleasepool {
                        if try appendRecord(from: pendingLine, sourcePath: file.path, parse: parse, consume: consume) {
                            recordCount += 1
                        }
                    }
                    pendingLine.removeAll(keepingCapacity: true)
                }
                lineStart = chunk.index(after: index)
            }

            if lineStart < chunk.endIndex {
                pendingLine.append(contentsOf: chunk[lineStart..<chunk.endIndex])
            }
        }

        if !pendingLine.isEmpty {
            try autoreleasepool {
                if try appendRecord(from: pendingLine, sourcePath: file.path, parse: parse, consume: consume) {
                    recordCount += 1
                    scannedOffset += Int64(pendingLine.count)
                } else if isCompleteJSONLine(pendingLine) {
                    scannedOffset += Int64(pendingLine.count)
                }
            }
        }

        return ScanProgress(scannedOffset: scannedOffset, recordCount: recordCount)
    }

    private func appendRecord<LineData: Collection>(
        from lineData: LineData,
        sourcePath: String,
        parse: (String, String) -> UsageRecord?,
        consume: (UsageRecord) throws -> Void
    ) throws -> Bool where LineData.Element == UInt8 {
        guard !lineData.isEmpty,
              let line = String(bytes: lineData, encoding: .utf8),
              let record = parse(line, sourcePath)
        else {
            return false
        }
        try consume(record)
        return true
    }

    private func isCompleteJSONLine(_ lineData: Data) -> Bool {
        guard !lineData.isEmpty else { return true }
        return (try? JSONSerialization.jsonObject(with: lineData)) != nil
    }

    private func scanHermesStateDB(
        _ file: URL,
        consume: (UsageRecord) throws -> Void
    ) throws -> ScanProgress {
        let sql = """
            SELECT id, started_at, COALESCE(model, ''),
                   input_tokens, output_tokens, cache_read_tokens,
                   cache_write_tokens, reasoning_tokens
            FROM sessions
            WHERE input_tokens + output_tokens + cache_read_tokens + cache_write_tokens + reasoning_tokens > 0
            """
        return try scanSQLiteRows(file, sql: sql) { statement in
            UsageRecord(
                provider: .hermes,
                source: "state.db/sessions",
                sourcePath: file.path,
                timestamp: Date(timeIntervalSince1970: sqlite3_column_double(statement, 1)),
                sessionID: textColumn(statement, 0),
                model: textColumn(statement, 2).isEmpty ? "Hermes" : textColumn(statement, 2),
                inputTokens: intColumn(statement, 3),
                outputTokens: intColumn(statement, 4),
                cachedTokens: intColumn(statement, 5) + intColumn(statement, 6),
                reasoningTokens: intColumn(statement, 7),
                toolTokens: 0
            )
        } consume: {
            try consume($0)
        }
    }

    private func scanOpenCodeDB(
        _ file: URL,
        consume: (UsageRecord) throws -> Void
    ) throws -> ScanProgress {
        let sql = """
            SELECT id, COALESCE(NULLIF(time_updated, 0), time_created), COALESCE(model, ''),
                   tokens_input, tokens_output, tokens_reasoning,
                   tokens_cache_read, tokens_cache_write
            FROM session
            WHERE tokens_input + tokens_output + tokens_reasoning + tokens_cache_read + tokens_cache_write > 0
            """
        return try scanSQLiteRows(file, sql: sql) { statement in
            UsageRecord(
                provider: .opencode,
                source: "opencode.db/session",
                sourcePath: file.path,
                timestamp: Date(timeIntervalSince1970: sqlite3_column_double(statement, 1) / 1000),
                sessionID: textColumn(statement, 0),
                model: openCodeModelName(textColumn(statement, 2)),
                inputTokens: intColumn(statement, 3),
                outputTokens: intColumn(statement, 4),
                cachedTokens: intColumn(statement, 6) + intColumn(statement, 7),
                reasoningTokens: intColumn(statement, 5),
                toolTokens: 0
            )
        } consume: {
            try consume($0)
        }
    }

    private func scanSQLiteRows(
        _ file: URL,
        sql: String,
        makeRecord: (OpaquePointer?) -> UsageRecord,
        consume: (UsageRecord) throws -> Void
    ) throws -> ScanProgress {
        var database: OpaquePointer?
        let flags = SQLITE_OPEN_READWRITE | SQLITE_OPEN_FULLMUTEX
        guard sqlite3_open_v2(file.path, &database, flags, nil) == SQLITE_OK else {
            sqlite3_close(database)
            return ScanProgress(scannedOffset: fileSize(file), recordCount: 0)
        }
        defer { sqlite3_close(database) }
        sqlite3_busy_timeout(database, 1000)

        var statement: OpaquePointer?
        guard sqlite3_prepare_v2(database, sql, -1, &statement, nil) == SQLITE_OK else {
            sqlite3_finalize(statement)
            return ScanProgress(scannedOffset: fileSize(file), recordCount: 0)
        }
        defer { sqlite3_finalize(statement) }

        var recordCount = 0
        while sqlite3_step(statement) == SQLITE_ROW {
            try consume(makeRecord(statement))
            recordCount += 1
        }
        return ScanProgress(scannedOffset: fileSize(file), recordCount: recordCount)
    }

    private func fileSize(_ file: URL) -> Int64 {
        ((try? file.resourceValues(forKeys: [.fileSizeKey]).fileSize).flatMap { Int64($0) }) ?? 0
    }

    private func textColumn(_ statement: OpaquePointer?, _ index: Int32) -> String {
        guard let text = sqlite3_column_text(statement, index) else { return "" }
        return String(cString: UnsafeRawPointer(text).assumingMemoryBound(to: CChar.self))
    }

    private func intColumn(_ statement: OpaquePointer?, _ index: Int32) -> Int {
        Int(sqlite3_column_int64(statement, index))
    }

    private func openCodeModelName(_ rawModel: String) -> String {
        guard !rawModel.isEmpty,
              let data = rawModel.data(using: .utf8),
              let object = (try? JSONSerialization.jsonObject(with: data)) as? [String: Any]
        else {
            return rawModel.isEmpty ? "OpenCode" : rawModel
        }
        let provider = object["providerID"] as? String
        let model = object["id"] as? String
        var parts: [String] = []
        if let provider, !provider.isEmpty {
            parts.append(provider)
        }
        if let model, !model.isEmpty {
            parts.append(model)
        }
        return parts.isEmpty ? "OpenCode" : parts.joined(separator: "/")
    }

    private func files(root: URL, include: (URL) -> Bool) -> [URL] {
        guard let enumerator = FileManager.default.enumerator(
            at: root,
            includingPropertiesForKeys: [.isRegularFileKey],
            options: [.skipsHiddenFiles]
        ) else {
            return []
        }

        var result: [URL] = []
        for case let file as URL in enumerator where include(file) {
            result.append(file)
        }
        return result
    }

    private var logSources: [LogSource] {
        [
            LogSource(
                root: homeDirectory.appending(path: ".claude/projects"),
                include: { $0.pathExtension == "jsonl" },
                scanner: .jsonl(TokenUsageParser.parseClaudeLine)
            ),
            LogSource(
                root: homeDirectory.appending(path: ".codex/sessions"),
                include: { $0.pathExtension == "jsonl" },
                scanner: .jsonl(TokenUsageParser.parseCodexLine)
            ),
            LogSource(
                root: homeDirectory.appending(path: ".gemini"),
                include: { url in
                    url.pathExtension == "jsonl" && url.pathComponents.contains("chats")
                },
                scanner: .jsonl(TokenUsageParser.parseGeminiLine)
            ),
            LogSource(
                root: homeDirectory.appending(path: ".hermes"),
                include: {
                    $0.lastPathComponent == "state.db" &&
                        $0.deletingLastPathComponent().lastPathComponent == ".hermes"
                },
                scanner: .hermesStateDB
            ),
            LogSource(
                root: homeDirectory.appending(path: ".local/share/opencode"),
                include: {
                    $0.lastPathComponent == "opencode.db" &&
                        $0.deletingLastPathComponent().lastPathComponent == "opencode"
                },
                scanner: .openCodeDB
            )
        ]
    }

    private func logFileSnapshots() throws -> [LogFileSnapshot] {
        var snapshots: [LogFileSnapshot] = []
        for source in logSources {
            for file in files(root: source.root, include: source.include) {
                let metadata = try fileMetadata(file)
                snapshots.append(LogFileSnapshot(
                    url: file,
                    path: file.path,
                    size: metadata.size,
                    modifiedAt: metadata.modifiedAt,
                    scanner: source.scanner
                ))
            }
        }
        return snapshots.sorted { $0.path < $1.path }
    }

    private func fileMetadata(_ file: URL) throws -> FileMetadata {
        let values = try file.resourceValues(forKeys: [.fileSizeKey, .contentModificationDateKey])
        return FileMetadata(
            size: Int64(values.fileSize ?? 0),
            modifiedAt: values.contentModificationDate?.timeIntervalSince1970 ?? 0
        )
    }

    private func isSameDisplayWindow(_ lhs: Date, _ rhs: Date) -> Bool {
        var calendar = Calendar.current
        calendar.firstWeekday = 2
        return calendar.isDate(lhs, equalTo: rhs, toGranularity: .hour)
    }
}

private struct LogSource {
    let root: URL
    let include: (URL) -> Bool
    let scanner: SourceScanner
}

private enum SourceScanner {
    case jsonl((String, String) -> UsageRecord?)
    case hermesStateDB
    case openCodeDB

    var supportsIncrementalScan: Bool {
        if case .jsonl = self { return true }
        return false
    }

    var supportsZeroRecordCache: Bool {
        if case .jsonl = self { return true }
        return false
    }
}

private struct ScanProgress {
    let scannedOffset: Int64
    let recordCount: Int
}

private struct FileMetadata {
    let size: Int64
    let modifiedAt: TimeInterval
}

private struct LogFileSnapshot {
    let url: URL
    let path: String
    let size: Int64
    let modifiedAt: TimeInterval
    let scanner: SourceScanner
}

private struct FileSignature: Equatable {
    let entries: [Entry]

    init(snapshots: [LogFileSnapshot]) {
        entries = snapshots.map {
            Entry(path: $0.path, size: $0.size, modifiedAt: $0.modifiedAt)
        }
    }

    struct Entry: Equatable {
        let path: String
        let size: Int64
        let modifiedAt: TimeInterval
    }
}
