import Foundation
//
// SPDX-License-Identifier: AGPL-3.0-or-later
// Copyright (C) 2026 Rune Olsen
//
// This file is part of oAI.
//
// oAI is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as
// published by the Free Software Foundation, either version 3 of the
// License, or (at your option) any later version.
//
// oAI is distributed in the hope that it will be useful, but WITHOUT
// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
// or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General
// Public License for more details.
//
// You should have received a copy of the GNU Affero General Public
// License along with oAI. If not, see <https://www.gnu.org/licenses/>.

import os

@Observable
class GitSyncService {
    static let shared = GitSyncService()

    private let settings = SettingsService.shared
    private let db = DatabaseService.shared
    private let log = Logger(subsystem: "com.oai.oAI", category: "sync")

    private(set) var syncStatus = SyncStatus()
    private(set) var isSyncing = false
    private(set) var lastSyncError: String?

    // Debounce tracking
    private var pendingSyncTask: Task<Void, Never>?

    private init() {
        // Check if repository is cloned at initialization (synchronous check)
        let localPath = expandPath(settings.syncLocalPath)
        syncStatus.isCloned = FileManager.default.fileExists(atPath: localPath + "/.git")
    }

    // MARK: - Repository Operations

    /// Test connection to remote repository
    func testConnection() async throws -> String {
        let url = try buildAuthenticatedURL()
        _ = try await runGit(["ls-remote", url])
        return "Connected to \(extractProvider())"
    }

    /// Clone repository to local path
    func cloneRepository() async throws {
        guard settings.syncConfigured else {
            throw SyncError.notConfigured
        }

        let url = try buildAuthenticatedURL()
        let localPath = expandPath(settings.syncLocalPath)

        // Check if already cloned
        if FileManager.default.fileExists(atPath: localPath + "/.git") {
            log.info("Repository already cloned at \(localPath)")
            syncStatus.isCloned = true
            return
        }

        log.info("Cloning repository from \(self.settings.syncRepoURL)")
        _ = try await runGit(["clone", url, localPath])
        syncStatus.isCloned = true

        await updateStatus()
    }

    /// Pull latest changes from remote
    func pull() async throws {
        try ensureCloned()

        let localPath = expandPath(settings.syncLocalPath)
        log.info("Pulling changes from remote")

        _ = try await runGit(["pull", "--ff-only"], cwd: localPath)
        syncStatus.lastSyncTime = Date()

        await updateStatus()
    }

    /// Push local changes to remote
    func push(message: String = "Sync from oAI") async throws {
        try ensureCloned()

        let localPath = expandPath(settings.syncLocalPath)

        // 1. Scan for secrets before committing
        try scanForSecrets(in: localPath)

        // 2. Add all changes
        log.info("Adding changes to git")
        _ = try await runGit(["add", "."], cwd: localPath)

        // 3. Check if there are changes to commit
        let status = try await runGit(["status", "--porcelain"], cwd: localPath)
        guard !status.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else {
            log.info("No changes to commit")
            return
        }

        // 4. Commit
        log.info("Committing changes")
        _ = try await runGit(["commit", "-m", message], cwd: localPath)

        // 5. Push
        log.info("Pushing to remote")
        // Check if upstream is set, if not set it (for first push to empty repo)
        do {
            _ = try await runGit(["push"], cwd: localPath)
        } catch {
            // First push might fail if no upstream, try with -u origin HEAD
            log.info("First push - setting upstream")
            _ = try await runGit(["push", "-u", "origin", "HEAD"], cwd: localPath)
        }

        syncStatus.lastSyncTime = Date()
        await updateStatus()
    }

    // MARK: - Conversation Export/Import

    /// Export all conversations to markdown files
    func exportAllConversations() async throws {
        try ensureCloned()

        let conversations = try db.listConversations()
        let localPath = expandPath(settings.syncLocalPath)
        let conversationsDir = localPath + "/conversations"

        // Create conversations directory
        try FileManager.default.createDirectory(atPath: conversationsDir, withIntermediateDirectories: true)

        // Create README if it doesn't exist
        try createReadmeIfNeeded()

        log.info("Exporting \(conversations.count) conversations")

        for conversation in conversations {
            // Load full conversation with messages
            guard let (_, messages) = try db.loadConversation(id: conversation.id) else {
                log.warning("Could not load conversation \(conversation.id.uuidString)")
                continue
            }

            let export = ConversationExport(
                id: conversation.id.uuidString,
                name: conversation.name,
                createdAt: conversation.createdAt,
                updatedAt: conversation.updatedAt,
                primaryModel: conversation.primaryModel,
                messages: messages.map { msg in
                    ConversationExport.MessageExport(
                        role: msg.role.rawValue,
                        content: msg.content,
                        timestamp: msg.timestamp,
                        tokens: msg.tokens,
                        cost: msg.cost,
                        modelId: msg.modelId
                    )
                }
            )

            let markdown = export.toMarkdown()
            let filename = sanitizeFilename(conversation.name) + ".md"
            let filepath = conversationsDir + "/" + filename

            try markdown.write(toFile: filepath, atomically: true, encoding: String.Encoding.utf8)
            log.debug("Exported: \(filename)")
        }

        await updateStatus()
    }

    /// Import conversations from markdown files
    func importAllConversations() async throws -> (imported: Int, skipped: Int, errors: Int) {
        try ensureCloned()

        let localPath = expandPath(settings.syncLocalPath)
        let conversationsDir = localPath + "/conversations"

        guard FileManager.default.fileExists(atPath: conversationsDir) else {
            log.warning("No conversations directory found")
            return (0, 0, 0)
        }

        let files = try FileManager.default.contentsOfDirectory(atPath: conversationsDir)
        let mdFiles = files.filter { $0.hasSuffix(".md") }

        log.info("Importing \(mdFiles.count) conversation files")

        var imported = 0
        var skipped = 0
        var errors = 0

        for filename in mdFiles {
            let filepath = conversationsDir + "/" + filename

            do {
                // Read markdown file
                let markdown = try String(contentsOfFile: filepath, encoding: .utf8)

                // Parse markdown to ConversationExport
                let export = try ConversationExport.fromMarkdown(markdown)

                // Check if conversation already exists (by ID)
                if let existingId = UUID(uuidString: export.id) {
                    if (try? db.loadConversation(id: existingId)) != nil {
                        // Already exists - skip
                        log.debug("Skipping existing conversation: \(export.name)")
                        skipped += 1
                        continue
                    }
                }

                // Convert MessageExport to Message
                let messages = export.messages.map { msgExport -> Message in
                    let role: MessageRole
                    switch msgExport.role.lowercased() {
                    case "user": role = .user
                    case "assistant": role = .assistant
                    case "system": role = .system
                    default: role = .user
                    }

                    return Message(
                        role: role,
                        content: msgExport.content,
                        tokens: msgExport.tokens,
                        cost: msgExport.cost,
                        timestamp: msgExport.timestamp,
                        modelId: msgExport.modelId
                    )
                }

                // Import to database with primaryModel
                let conversationId = UUID(uuidString: export.id) ?? UUID()
                _ = try db.saveConversation(
                    id: conversationId,
                    name: export.name,
                    messages: messages,
                    primaryModel: export.primaryModel
                )
                log.info("Imported: \(export.name)")
                imported += 1

            } catch {
                log.error("Failed to import \(filename): \(error.localizedDescription)")
                errors += 1
            }
        }

        log.info("Import complete: \(imported) imported, \(skipped) skipped, \(errors) errors")
        return (imported, skipped, errors)
    }

    /// Create README.md in sync repository
    private func createReadmeIfNeeded() throws {
        let localPath = expandPath(settings.syncLocalPath)
        let readmePath = localPath + "/README.md"

        // Only create if doesn't exist
        guard !FileManager.default.fileExists(atPath: readmePath) else {
            return
        }

        let readme = """
        # oAI Conversation Sync

        This repository contains your oAI conversations in markdown format.

        ## ⚠️ WARNING - DO NOT MANUALLY EDIT

        **This repository is automatically managed by oAI.**

        - ❌ **DO NOT manually edit** these files
        - ❌ **DO NOT add** files to this repository
        - ❌ **DO NOT delete** files from this repository
        - ❌ **DO NOT merge conflicts** manually (let oAI handle it)

        **Why?** oAI rebuilds its internal database from these files. Manual edits will be:
        - Overwritten on next sync
        - May cause data corruption
        - May prevent proper import/restore

        ## How It Works

        ### Export (Automatic)
        - oAI saves conversations to its local database
        - Auto-sync exports conversations to `conversations/*.md`
        - Files are committed and pushed to this git repository

        ### Import (On New Machine)
        - Clone this repository on a new machine
        - oAI imports markdown files into its database
        - Your conversation history is restored

        ### Sync Across Machines
        - Machine A: Chat → Auto-save → Export → Push to git
        - Machine B: Pull from git → Auto-import → Database updated
        - Conversations stay in sync across all machines

        ## File Structure

        ```
        /
        ├── README.md              # This file
        └── conversations/         # Your conversations
            ├── conversation-1.md
            ├── conversation-2.md
            └── ...
        ```

        ## Conversation File Format

        Each `.md` file contains:
        - Conversation metadata (ID, name, dates)
        - All messages (user and assistant)
        - Token counts and costs
        - Timestamps

        Example:
        ```markdown
        # Python async patterns guide

        **ID**: `abc-123-def`
        **Created**: 2026-02-14T10:30:00Z
        **Updated**: 2026-02-14T11:45:00Z

        ---

        ## User

        How do I use async/await in Python?

        ---

        ## Assistant

        [Response here...]
        ```

        ## Security Notes

        - This repository contains **plain text** conversations
        - API keys and secrets are **automatically scanned and blocked**
        - Keep this repository **private** if conversations contain sensitive info
        - Use **.gitignore** if you want to exclude specific conversations

        ## Troubleshooting

        **Problem:** Files not syncing?
        - Check Settings → Sync in oAI
        - Verify git credentials are correct
        - Check network connection

        **Problem:** Conflicts after editing?
        - Restore from git: `git reset --hard origin/main`
        - Re-export from oAI: Manual Sync → Export All → Push

        **Problem:** Lost conversations?
        - Conversations are in your local oAI database
        - Export manually: Settings → Sync → Export All
        - Check git history for deleted files

        ## Support

        For help with oAI, see:
        - Settings → Help in oAI app
        - GitHub issues (if open source)

        ---

        **Generated by oAI v1.0**
        **Last updated:** \(ISO8601DateFormatter().string(from: Date()))
        """

        try readme.write(toFile: readmePath, atomically: true, encoding: .utf8)
        log.info("Created README.md in sync repository")
    }

    // MARK: - Auto-Sync

    /// Sync on app startup (pull + import only, no push)
    /// Runs silently in background to fetch changes from other devices
    func syncOnStartup() async {
        // First, update status to check if repo is actually cloned
        await updateStatus()

        // Only run if configured and cloned
        guard settings.syncConfigured else {
            log.debug("Skipping startup sync (sync not configured)")
            return
        }

        guard syncStatus.isCloned else {
            log.debug("Skipping startup sync (repository not cloned)")
            return
        }

        log.info("Running startup sync (pull + import)...")

        do {
            // Pull latest changes
            try await pull()

            // Import any new/updated conversations
            let result = try await importAllConversations()

            if result.imported > 0 {
                log.info("Startup sync: imported \(result.imported) conversations")
            } else {
                log.debug("Startup sync: no new conversations to import")
            }

        } catch {
            // Don't block app startup on sync errors
            log.warning("Startup sync failed (non-fatal): \(error.localizedDescription)")
        }
    }

    /// Perform auto-sync with debouncing (export + push)
    /// Debounces multiple rapid sync requests to avoid spamming git
    func autoSync() async {
        // Cancel any pending sync
        pendingSyncTask?.cancel()

        // Schedule new sync with 5 second delay
        pendingSyncTask = Task {
            do {
                // Wait for debounce period
                try await Task.sleep(for: .seconds(5))

                // Check if cancelled during sleep
                guard !Task.isCancelled else { return }

                // Set syncing state
                await MainActor.run {
                    isSyncing = true
                    lastSyncError = nil
                }

                log.info("Auto-sync starting (export + push)...")

                // Export conversations
                try await exportAllConversations()

                // Push to git
                try await push(message: "Auto-sync from oAI")

                // Success
                await MainActor.run {
                    isSyncing = false
                    syncStatus.lastSyncTime = Date()
                }

                log.info("Auto-sync completed successfully")

            } catch {
                // Error
                await MainActor.run {
                    isSyncing = false
                    lastSyncError = error.localizedDescription
                }

                log.error("Auto-sync failed: \(error.localizedDescription)")
            }
        }

        // Wait for the task to complete
        await pendingSyncTask?.value
    }

    // MARK: - Secret Scanning

    /// Scan for API keys and secrets in conversations
    func scanForSecrets(in directory: String) throws {
        let conversationsDir = directory + "/conversations"

        guard FileManager.default.fileExists(atPath: conversationsDir) else {
            return
        }

        let files = try FileManager.default.contentsOfDirectory(atPath: conversationsDir)
        let mdFiles = files.filter { $0.hasSuffix(".md") }

        var detectedSecrets: [String] = []

        for filename in mdFiles {
            let filepath = conversationsDir + "/" + filename
            let content = try String(contentsOfFile: filepath, encoding: .utf8)

            let secrets = detectSecretsInText(content)
            if !secrets.isEmpty {
                detectedSecrets.append("\(filename): \(secrets.joined(separator: ", "))")
            }
        }

        if !detectedSecrets.isEmpty {
            log.error("Secrets detected in conversations!")
            throw SyncError.secretsDetected(detectedSecrets)
        }
    }

    private func detectSecretsInText(_ text: String) -> [String] {
        let patterns: [(name: String, pattern: String)] = [
            ("OpenAI Key", "sk-[a-zA-Z0-9]{32,}"),
            ("Anthropic Key", "sk-ant-[a-zA-Z0-9_-]+"),
            ("Bearer Token", "Bearer [a-zA-Z0-9_-]{20,}"),
            ("API Key", "api[_-]?key[\"']?\\s*[:=]\\s*[\"']?[a-zA-Z0-9]{20,}"),
            ("Access Token", "ghp_[a-zA-Z0-9]{36}"), // GitHub personal access token
        ]

        var found: [String] = []

        for (name, pattern) in patterns {
            if let regex = try? NSRegularExpression(pattern: pattern, options: .caseInsensitive) {
                let range = NSRange(text.startIndex..., in: text)
                let matches = regex.matches(in: text, range: range)

                if !matches.isEmpty {
                    found.append(name)
                }
            }
        }

        return Array(Set(found)) // Remove duplicates
    }

    // MARK: - Status Management

    func updateStatus() async {
        let localPath = expandPath(settings.syncLocalPath)

        // Check if cloned
        syncStatus.isCloned = FileManager.default.fileExists(atPath: localPath + "/.git")

        guard syncStatus.isCloned else { return }

        do {
            // Get current branch
            let branch = try await runGit(["branch", "--show-current"], cwd: localPath)
            syncStatus.currentBranch = branch.trimmingCharacters(in: .whitespacesAndNewlines)

            // Get uncommitted changes count
            let status = try await runGit(["status", "--porcelain"], cwd: localPath)
            let lines = status.components(separatedBy: .newlines).filter { !$0.isEmpty }
            syncStatus.uncommittedChanges = lines.count

            // Get remote status
            _ = try await runGit(["fetch"], cwd: localPath)
            let remoteDiff = try await runGit(["rev-list", "--left-right", "--count", "HEAD...@{u}"], cwd: localPath)
            let parts = remoteDiff.trimmingCharacters(in: .whitespacesAndNewlines).components(separatedBy: "\t")

            if parts.count == 2 {
                let ahead = Int(parts[0]) ?? 0
                let behind = Int(parts[1]) ?? 0

                if ahead == 0 && behind == 0 {
                    syncStatus.remoteStatus = "up-to-date"
                } else if ahead > 0 && behind == 0 {
                    syncStatus.remoteStatus = "ahead \(ahead)"
                } else if ahead == 0 && behind > 0 {
                    syncStatus.remoteStatus = "behind \(behind)"
                } else {
                    syncStatus.remoteStatus = "diverged"
                }
            }

        } catch {
            log.error("Failed to update status: \(error.localizedDescription)")
        }
    }

    // MARK: - Helper Methods

    private func buildAuthenticatedURL() throws -> String {
        guard settings.syncConfigured else {
            throw SyncError.notConfigured
        }

        let baseURL = settings.syncRepoURL

        switch settings.syncAuthMethod {
        case "ssh":
            // Convert HTTPS URL to SSH format if needed
            return convertToSSH(baseURL)

        case "password":
            guard let username = settings.syncUsername,
                  let password = settings.syncPassword else {
                throw SyncError.missingCredentials
            }
            return injectCredentials(baseURL, username: username, password: password)

        case "token":
            guard let token = settings.syncAccessToken else {
                throw SyncError.missingCredentials
            }
            // Use oauth2 as username for tokens
            return injectCredentials(baseURL, username: "oauth2", password: token)

        default:
            return baseURL
        }
    }

    private func convertToSSH(_ url: String) -> String {
        // If already SSH format, return as-is
        if url.hasPrefix("git@") {
            return url
        }

        // Convert HTTPS to SSH format
        // https://gitlab.pm/rune/oAI-Sync.git -> git@gitlab.pm:rune/oAI-Sync.git
        if url.hasPrefix("https://") {
            let withoutScheme = url.replacingOccurrences(of: "https://", with: "")
            // Replace first "/" with ":"
            if let firstSlash = withoutScheme.firstIndex(of: "/") {
                var sshURL = withoutScheme
                sshURL.replaceSubrange(firstSlash...firstSlash, with: ":")
                return "git@" + sshURL
            }
        }

        // If http:// (rare but possible)
        if url.hasPrefix("http://") {
            let withoutScheme = url.replacingOccurrences(of: "http://", with: "")
            if let firstSlash = withoutScheme.firstIndex(of: "/") {
                var sshURL = withoutScheme
                sshURL.replaceSubrange(firstSlash...firstSlash, with: ":")
                return "git@" + sshURL
            }
        }

        // Unknown format, return as-is
        return url
    }

    private func injectCredentials(_ url: String, username: String, password: String) -> String {
        // Convert https://github.com/user/repo.git
        // To: https://username:password@github.com/user/repo.git

        if url.hasPrefix("https://") {
            let withoutScheme = url.replacingOccurrences(of: "https://", with: "")
            return "https://\(username):\(password)@\(withoutScheme)"
        }

        return url // SSH or other protocol
    }

    private func runGit(_ args: [String], cwd: String? = nil) async throws -> String {
        let process = Process()
        process.executableURL = URL(fileURLWithPath: "/usr/bin/git")
        process.arguments = args

        if let cwd = cwd {
            process.currentDirectoryURL = URL(fileURLWithPath: expandPath(cwd))
        }

        let outputPipe = Pipe()
        let errorPipe = Pipe()
        process.standardOutput = outputPipe
        process.standardError = errorPipe

        try process.run()
        process.waitUntilExit()

        let outputData = outputPipe.fileHandleForReading.readDataToEndOfFile()
        let errorData = errorPipe.fileHandleForReading.readDataToEndOfFile()

        let output = String(data: outputData, encoding: .utf8) ?? ""
        let error = String(data: errorData, encoding: .utf8) ?? ""

        guard process.terminationStatus == 0 else {
            log.error("Git command failed: \(args.joined(separator: " "))")
            log.error("Error: \(error)")
            throw SyncError.gitFailed(error.isEmpty ? "Unknown error" : error)
        }

        return output
    }

    private func expandPath(_ path: String) -> String {
        return NSString(string: path).expandingTildeInPath
    }

    private func ensureCloned() throws {
        let localPath = expandPath(settings.syncLocalPath)
        guard FileManager.default.fileExists(atPath: localPath + "/.git") else {
            throw SyncError.repoNotCloned
        }
    }

    private func sanitizeFilename(_ name: String) -> String {
        // Remove invalid filename characters
        let invalid = CharacterSet(charactersIn: "/\\:*?\"<>|")
        return name.components(separatedBy: invalid).joined(separator: "-")
    }

    private func extractProvider() -> String {
        let url = settings.syncRepoURL
        if url.contains("github.com") {
            return "GitHub"
        } else if url.contains("gitlab.com") {
            return "GitLab"
        } else if url.contains("gitea") {
            return "Gitea"
        } else {
            return "Git repository"
        }
    }
}
