import Foundation
//
// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0
// Copyright (C) 2026 Rune Olsen
//
// This file is part of Confab.
//
// Confab is licensed under the PolyForm Noncommercial License 1.0.0.
// You may use, study, modify, and share it for any noncommercial
// purpose. Commercial use — including selling Confab or any part of
// it, standalone or bundled into another product or service —
// requires a separate commercial license from the copyright holder.
//
// See the LICENSE file or
// <https://polyformproject.org/licenses/noncommercial/1.0.0> for
// the full license text. For commercial licensing, contact Rune
// Olsen via <https://confab.no>.

import os

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

    private let settings = SettingsService.shared
    private let db = DatabaseService.shared
    private let log = Logger(subsystem: Log.subsystem, 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>?

    /// A pull failed because a new sync-repo file (folders.json, notes.json, ...) collided with an
    /// untracked local copy — see parseUntrackedFileConflict(from:). Surfaced to the user via
    /// GitSyncConflictSheet (wired in ChatView.swift), offering an automatic or manual fix.
    struct PendingGitConflict: Identifiable {
        let id = UUID()
        let files: [String]
        let rawError: String
        var canAutoFix: Bool { files.allSatisfy(GitSyncService.isFileSafeToAutoDelete) }
    }
    private(set) var pendingGitConflict: PendingGitConflict? = nil

    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 \(Self.extractProvider(from: settings.syncRepoURL))"
    }

    /// 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], timeout: 120)
        syncStatus.isCloned = true

        // Import immediately so this machine's DB is never left empty after a clone —
        // an empty DB is what makes the next export think every existing conversation
        // was deleted (see exportAllConversations's orphan-cleanup guard).
        _ = try? await importAllConversations()

        await updateStatus()
    }

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

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

        do {
            _ = try await runGit(["pull", "--ff-only"], cwd: localPath)
        } catch {
            // Surface an "untracked working tree files" collision as a recoverable conflict the
            // user can act on, without changing this function's throw contract — existing callers
            // (syncOnStartup's non-fatal log, syncNow's error display) are unaffected. Guarded on
            // pendingGitConflict already being nil so a second pull failure while the sheet is
            // still showing doesn't replace its content out from under the user.
            if pendingGitConflict == nil,
               let files = Self.parseUntrackedFileConflict(from: error.localizedDescription) {
                pendingGitConflict = PendingGitConflict(files: files, rawError: error.localizedDescription)
            }
            throw error
        }
        syncStatus.lastSyncTime = Date()

        await updateStatus()
    }

    /// Re-verifies each file is still genuinely untracked (not just trusting the parsed error text)
    /// immediately before deleting, deletes them, retries pull(), and on success imports so the
    /// previously-blocked content actually lands. Returns nil on success, an error description on
    /// failure. Deliberately does not touch pendingGitConflict itself — dismissPendingGitConflict()
    /// is the sheet's explicit "I'm done looking at this" signal. SwiftUI's .sheet(item:) dismisses
    /// the instant pendingGitConflict goes nil, so clearing it here would yank the sheet away before
    /// the user ever sees whether the fix actually worked.
    func autoResolveUntrackedConflict(_ conflict: PendingGitConflict) async -> String? {
        guard conflict.canAutoFix else {
            return "Some of these files can't be safely removed automatically."
        }

        let localPath = expandPath(settings.syncLocalPath)

        for file in conflict.files {
            guard let status = try? await runGit(["status", "--porcelain", "--", file], cwd: localPath),
                  status.trimmingCharacters(in: .whitespacesAndNewlines).hasPrefix("??")
            else {
                return "\(file) is no longer untracked — leaving it in place rather than risk deleting something else. Try syncing again."
            }
            try? FileManager.default.removeItem(at: URL(fileURLWithPath: localPath).appendingPathComponent(file))
        }

        do {
            try await pull()
            _ = try await importAllConversations()
            return nil
        } catch {
            return error.localizedDescription
        }
    }

    /// Explicit dismiss for GitSyncConflictSheet — see autoResolveUntrackedConflict's note on why
    /// the recovery method itself never clears this.
    func dismissPendingGitConflict() {
        pendingGitConflict = nil
    }

    /// Shown by GitSyncManualFixSheet when the user picks "Fix It Myself" on GitSyncConflictSheet.
    /// Deliberately in-app text rather than a deep link into the Help Book: NSWorkspace.shared.open()
    /// silently drops the #fragment for file:// URLs before handing off to the default browser (the
    /// anchor never survives — confirmed by inspecting location.hash in the opened page, it comes
    /// back empty), so an anchored Help Book link always lands on the index instead of the relevant
    /// section. Carrying the conflict's own file list and the real sync path into this sheet is also
    /// just more useful than generic help-page prose pointing at "the file(s) named in the error".
    private(set) var pendingManualFixInstructions: PendingGitConflict? = nil

    /// Swaps GitSyncConflictSheet for GitSyncManualFixSheet — clearing pendingGitConflict here (rather
    /// than relying on the sheet's own onDismiss) dismisses the first sheet via its .sheet(item:)
    /// binding while pendingManualFixInstructions immediately presents the second.
    func showManualFixInstructions(for conflict: PendingGitConflict) {
        pendingGitConflict = nil
        pendingManualFixInstructions = conflict
    }

    func dismissManualFixInstructions() {
        pendingManualFixInstructions = nil
    }

    /// Push local changes to remote
    func push(message: String = "Sync from Confab") 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)")
        }

        // Remove files for conversations that no longer exist locally (e.g. deleted since
        // the last export). Without this, a deletion is never reflected in the sync repo,
        // so importAllConversations() silently resurrects it on every future pull.
        let currentIds = Set(conversations.map { $0.id.uuidString })
        let existingFiles = (try? FileManager.default.contentsOfDirectory(atPath: conversationsDir)) ?? []
        let mdFilesWithContent: [(filename: String, markdown: String)] = existingFiles
            .filter { $0.hasSuffix(".md") }
            .compactMap { filename in
                guard let markdown = try? String(contentsOfFile: conversationsDir + "/" + filename, encoding: .utf8)
                else { return nil }
                return (filename, markdown)
            }
        for filename in Self.orphanedExportFilenames(currentIds: currentIds, files: mdFilesWithContent) {
            try? FileManager.default.removeItem(atPath: conversationsDir + "/" + filename)
            log.info("Removed orphaned export for deleted conversation: \(filename)")
        }

        // Export the folder tree + conversation→folder assignments alongside the conversations
        // themselves, so a fresh machine's import can restore folder structure too. See
        // upsertSyncedFolder/orphanedLocalFolderIds for how the import side consumes this.
        let allFolders = try db.listFolders()
        let manifest = FolderSyncManifest(
            folders: allFolders.map {
                FolderSyncManifest.FolderEntry(
                    id: $0.id.uuidString, name: $0.name, parentId: $0.parentId?.uuidString,
                    createdAt: $0.createdAt, updatedAt: $0.updatedAt
                )
            },
            assignments: Dictionary(uniqueKeysWithValues: conversations.compactMap { conv in
                conv.folderId.map { (conv.id.uuidString, $0.uuidString) }
            })
        )
        let encoder = JSONEncoder()
        encoder.dateEncodingStrategy = .iso8601
        encoder.outputFormatting = [.prettyPrinted, .sortedKeys]
        let manifestData = try encoder.encode(manifest)
        try manifestData.write(to: URL(fileURLWithPath: localPath + "/folders.json"))
        log.debug("Exported folders.json (\(allFolders.count) folders)")

        // Export per-conversation notes (see ConversationNotesService), same manifest + directory
        // shape as folders.json: notes/<filename> holds the raw file content byte-for-byte (its
        // embedded **ID** header included), notes.json maps conversationId -> {filename, enabled}
        // so import can match without parsing file content.
        let notesDir = localPath + "/notes"
        try FileManager.default.createDirectory(atPath: notesDir, withIntermediateDirectories: true)

        var notesEntries: [String: NotesSyncManifest.Entry] = [:]
        for conversation in conversations {
            guard let filename = conversation.notesFilename,
                  let content = ConversationNotesService.shared.readRaw(filename: filename)
            else { continue }
            try content.write(toFile: notesDir + "/" + filename, atomically: true, encoding: .utf8)
            notesEntries[conversation.id.uuidString] = NotesSyncManifest.Entry(filename: filename, enabled: conversation.notesEnabled)
        }
        let notesManifest = NotesSyncManifest(notes: notesEntries)
        let notesManifestData = try encoder.encode(notesManifest)
        try notesManifestData.write(to: URL(fileURLWithPath: localPath + "/notes.json"))
        log.debug("Exported notes.json (\(notesEntries.count) notes)")

        // Remove sync-repo note files for conversations that no longer exist locally — same
        // orphan cleanup and empty-state safety guard as orphanedExportFilenames above.
        let existingNoteFiles = (try? FileManager.default.contentsOfDirectory(atPath: notesDir)) ?? []
        let currentNoteFilenames = Set(notesEntries.values.map { $0.filename })
        for filename in Self.orphanedNoteFilenames(currentFilenames: currentNoteFilenames, existingFiles: existingNoteFiles) {
            try? FileManager.default.removeItem(atPath: notesDir + "/" + filename)
            log.info("Removed orphaned note file: \(filename)")
        }

        await updateStatus()
    }

    /// Given the note filenames currently referenced by local conversations and the filenames
    /// found on disk in the sync repo's notes directory, returns the filenames safe to delete
    /// because no local conversation references them anymore (conversation deleted, or its notes
    /// file was replaced). Same empty-state guard as orphanedExportFilenames/orphanedLocalFolderIds
    /// — an empty currentFilenames set is indistinguishable from "haven't loaded local
    /// conversations yet" (e.g. right after a fresh clone), so treating it as "every note file was
    /// orphaned" would repeat the exact class of mass-deletion bug that hit conversation sync.
    nonisolated static func orphanedNoteFilenames(currentFilenames: Set<String>, existingFiles: [String]) -> [String] {
        guard !currentFilenames.isEmpty else { return [] }
        return existingFiles.filter { $0.hasSuffix(".md") && !currentFilenames.contains($0) }
    }

    /// Given the current conversation IDs and the (filename, markdown content) pairs found in
    /// the sync repo's conversations directory, returns the filenames whose export ID doesn't
    /// match any current conversation — i.e. files safe to delete because their conversation
    /// was removed from the database since the last export.
    nonisolated static func orphanedExportFilenames(
        currentIds: Set<String>,
        files: [(filename: String, markdown: String)]
    ) -> [String] {
        // A locally-empty conversation list is indistinguishable here from "nothing has been
        // imported into this machine's DB yet" (e.g. right after a fresh clone). Treating it as
        // "every existing file was deleted" wiped a user's entire sync repo in production: clone
        // completed, an auto-sync fired before the post-clone import finished, every synced
        // conversation looked orphaned, and the deletion got committed and pushed. Skipping
        // cleanup here means a genuine last-conversation deletion won't propagate until another
        // conversation exists locally — a far smaller cost than mass data loss.
        guard !currentIds.isEmpty else { return [] }
        return files.compactMap { file in
            guard let export = try? ConversationExport.fromMarkdown(file.markdown) else { return nil }
            return currentIds.contains(export.id) ? nil : file.filename
        }
    }

    /// Given the folder ids present in a just-pulled `folders.json` manifest and the folder ids
    /// that exist locally, returns the local ids that should be deleted (folder was removed
    /// upstream since the last sync). Same empty-manifest safety guard as
    /// `orphanedExportFilenames` — an empty manifest is indistinguishable from "haven't imported
    /// folders.json yet" (e.g. an older sync repo with no manifest at all, or a fresh clone before
    /// the first export), so treating it as "delete every local folder" would be exactly the same
    /// class of mass-deletion bug that hit conversation sync.
    nonisolated static func orphanedLocalFolderIds(
        manifestFolderIds: Set<String>,
        localFolderIds: Set<String>
    ) -> [String] {
        guard !manifestFolderIds.isEmpty else { return [] }
        return localFolderIds.filter { !manifestFolderIds.contains($0) }
    }

    // MARK: - Untracked File Conflict Recovery

    /// Parses git's "untracked working tree files would be overwritten by merge" pull failure into
    /// the list of colliding relative paths. Returns nil for any other error (auth, network, a real
    /// merge conflict) — those aren't what this recovery flow is for. Exact git format:
    /// "error: The following untracked working tree files would be overwritten by merge:\n\t<file>\n...\nPlease move or remove them before you merge.\nAborting"
    nonisolated static func parseUntrackedFileConflict(from message: String) -> [String]? {
        let marker = "untracked working tree files would be overwritten by merge:"
        guard let markerRange = message.range(of: marker) else { return nil }

        let lines = message[markerRange.upperBound...].components(separatedBy: "\n")
        var files: [String] = []
        for line in lines {
            let trimmed = line.trimmingCharacters(in: .whitespaces)
            if trimmed.isEmpty { continue }
            // The file list ends at the first line that isn't an indented filename (git's own
            // trailing "Please move or remove them..."/"Aborting" lines aren't tab-indented).
            guard line.hasPrefix("\t") || line.hasPrefix("    ") else { break }
            files.append(trimmed)
        }
        return files.isEmpty ? nil : files
    }

    /// Defense in depth for the "Fix It For Me" auto-recovery path: only files this app itself is
    /// known to write into the sync repo are ever eligible for automatic deletion. Rejects path
    /// traversal, absolute paths, and anything outside the known shape — an unrecognized file falls
    /// back to manual recovery only (see PendingGitConflict.canAutoFix).
    nonisolated static func isFileSafeToAutoDelete(_ relativePath: String) -> Bool {
        if relativePath == "folders.json" || relativePath == "notes.json" {
            return true
        }
        for prefix in ["conversations/", "notes/"] {
            guard relativePath.hasPrefix(prefix) else { continue }
            let rest = relativePath.dropFirst(prefix.count)
            // Exactly one path segment (no further "/"), and a .md file.
            return !rest.isEmpty && !rest.contains("/") && rest.hasSuffix(".md")
        }
        return false
    }

    /// 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)
        }

        // Import the folder tree + assignments before any conversation, so a brand-new
        // conversation created below can immediately reference a folder that already exists
        // locally. Missing/unparsable folders.json (older sync repos, or a fresh clone before the
        // first export) is treated as "no folders to import," not an error.
        var folderAssignments: [String: String] = [:]
        let manifestPath = localPath + "/folders.json"
        if let manifestData = try? Data(contentsOf: URL(fileURLWithPath: manifestPath)) {
            let decoder = JSONDecoder()
            decoder.dateDecodingStrategy = .iso8601
            if let manifest = try? decoder.decode(FolderSyncManifest.self, from: manifestData) {
                folderAssignments = manifest.assignments

                // Insert parents before children so parentId's foreign key reference (folders.
                // parentId references folders(id)) is always satisfied.
                let manifestFolders = manifest.folders.compactMap { entry -> Folder? in
                    guard let id = UUID(uuidString: entry.id) else { return nil }
                    return Folder(
                        id: id, name: entry.name, createdAt: entry.createdAt,
                        parentId: entry.parentId.flatMap { UUID(uuidString: $0) },
                        updatedAt: entry.updatedAt
                    )
                }
                for (folder, _) in Folder.orderedTree(from: manifestFolders) {
                    try? db.upsertSyncedFolder(
                        id: folder.id, name: folder.name, parentId: folder.parentId,
                        createdAt: folder.createdAt, updatedAt: folder.updatedAt
                    )
                }

                // Delete local folders no longer present upstream — reparents their contents up
                // one level via the existing deleteFolder semantics.
                let manifestFolderIds = Set(manifest.folders.map { $0.id })
                let localFolderIds = Set((try? db.listFolders())?.map { $0.id.uuidString } ?? [])
                for idString in Self.orphanedLocalFolderIds(manifestFolderIds: manifestFolderIds, localFolderIds: localFolderIds) {
                    if let id = UUID(uuidString: idString) {
                        try? db.deleteFolder(id: id)
                        log.info("Removed local folder no longer present in sync repo: \(idString)")
                    }
                }
                log.debug("Imported folders.json (\(manifest.folders.count) folders)")
            }
        }

        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 let (existingConversation, _) = try? db.loadConversation(id: existingId) {
                        // Already exists - skip re-importing its content, but still backfill a
                        // folder assignment if the manifest has one and this conversation isn't
                        // filed anywhere locally yet. Without this, a conversation that was synced
                        // to this machine before folder sync existed (or before it was ever put in
                        // a folder on any machine) would never get filed here — every conversation
                        // in a multi-machine setup already exists locally by the time folders.json
                        // starts carrying assignments, so this isn't an edge case, it's the normal
                        // case. Never overwrites an existing local folderId, so a conversation
                        // already filed (by this machine or a prior import) isn't silently moved.
                        if existingConversation.folderId == nil,
                           let assignedFolderId = folderAssignments[export.id].flatMap(UUID.init) {
                            try? db.moveConversation(id: existingId, toFolder: assignedFolderId)
                        }
                        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, plus its folder assignment (if any) from
                // folders.json — only applies here at first-import; an existing local conversation
                // that's later moved to a different folder on another machine doesn't get updated,
                // matching how its content/name aren't updated either once already imported.
                let conversationId = UUID(uuidString: export.id) ?? UUID()
                let folderId = folderAssignments[export.id].flatMap { UUID(uuidString: $0) }
                _ = try db.saveConversation(
                    id: conversationId,
                    name: export.name,
                    messages: messages,
                    primaryModel: export.primaryModel,
                    folderId: folderId
                )
                log.info("Imported: \(export.name)")
                imported += 1

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

        // Import per-conversation notes (see ConversationNotesService) — runs after the
        // conversations loop above so a conversation created in this same import pass already
        // exists locally by the time we try to match notes.json against it. Missing/unparsable
        // notes.json (older sync repos, or a fresh clone before the first export) is treated as
        // "no notes to import," not an error.
        let notesManifestPath = localPath + "/notes.json"
        if let notesManifestData = try? Data(contentsOf: URL(fileURLWithPath: notesManifestPath)),
           let notesManifest = try? JSONDecoder().decode(NotesSyncManifest.self, from: notesManifestData) {
            var notesImported = 0
            for (conversationIdString, entry) in notesManifest.notes {
                guard let conversationId = UUID(uuidString: conversationIdString),
                      let (existingConversation, _) = try? db.loadConversation(id: conversationId)
                else { continue }

                // Never overwrite notes the user has already touched locally — same
                // never-clobber-existing-content philosophy as skipping already-imported
                // conversation content, and the folder-assignment backfill-only-if-unset above.
                guard existingConversation.notesFilename == nil, !existingConversation.notesEnabled else {
                    continue
                }

                guard let content = try? String(contentsOfFile: localPath + "/notes/" + entry.filename, encoding: .utf8) else {
                    continue
                }
                ConversationNotesService.shared.writeRaw(content: content, filename: entry.filename)
                try? db.setNotesFilename(id: conversationId, filename: entry.filename)
                try? db.setNotesEnabled(id: conversationId, enabled: entry.enabled)
                notesImported += 1
            }
            if notesImported > 0 {
                log.info("Imported notes for \(notesImported) conversations")
            }
        }

        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 = """
        # Confab Conversation Sync

        This repository contains your Confab conversations in markdown format.

        ## ⚠️ WARNING - DO NOT MANUALLY EDIT

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

        - ❌ **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 Confab handle it)

        **Why?** Confab 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)
        - Confab saves conversations to its local database
        - Auto-sync exports conversations to `conversations/*.md`
        - Your folder structure (if you organize conversations into folders) is exported to `folders.json`
        - Per-conversation notes (if you've turned on `/notes on` for a conversation) are exported to `notes/*.md`, tracked in `notes.json`
        - Files are committed and pushed to this git repository

        ### Import (On New Machine)
        - Clone this repository on a new machine
        - Confab imports markdown files, folders.json, and notes.json into its database
        - Your conversation history, folder structure, and conversation notes are 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
        - Folder renames/moves also sync between machines; a conversation's folder is only set
          the first time it's imported onto a new machine
        - Conversation notes sync the same way; a conversation's notes are only adopted the first
          time it's imported onto a new machine — if that machine already has its own notes for the
          same conversation, they're left alone rather than overwritten

        ## File Structure

        ```
        /
        ├── README.md              # This file
        ├── folders.json           # Your folder structure (auto-managed, don't edit)
        ├── notes.json             # Per-conversation notes index (auto-managed, don't edit)
        ├── notes/                 # Per-conversation notes files
        │   └── ...
        └── 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 Confab
        - Verify git credentials are correct
        - Check network connection

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

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

        ## Support

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

        ---

        **Generated by Confab 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
        }

        // Guard against racing autoSync()/syncNow() on the same working tree — this method and
        // autoSync() are both fired from independent, uncoordinated Tasks (this one at app launch,
        // autoSync() debounced off chat activity), so without this a pull here could run while
        // autoSync() is mid-export, leaving a freshly-written untracked file (folders.json,
        // notes.json) that the pull then refuses to merge over: "untracked working tree files
        // would be overwritten by merge." Skipping outright (not waiting) is fine here since
        // startup sync is a one-time best-effort fetch, not something the user is blocked on.
        guard !isSyncing else {
            log.debug("Skipping startup sync (another sync already in progress)")
            return
        }
        isSyncing = true
        defer { isSyncing = false }

        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)")
        }
    }

    /// Fire-and-forget sync trigger for conversation-deletion call sites. No-ops when sync
    /// isn't configured. Deletions otherwise only reach the sync repo on the next incidental
    /// auto-sync (or never, if the app is closed first) — this makes the removal propagate
    /// promptly instead of the deleted conversation silently reappearing on next pull+import.
    func syncAfterDeletion() {
        guard settings.syncConfigured else { return }
        Task { await autoSync() }
    }

    /// 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 {
            // Tracks whether *this* task is the one holding isSyncing, so the catch block below
            // only ever releases a lock it actually acquired — without this, a cancellation while
            // still waiting in the loop below (i.e. before this task owns the lock at all) would
            // incorrectly clear isSyncing out from under whichever other sync is still running.
            var acquiredLock = false

            do {
                // Wait for debounce period
                try await Task.sleep(for: .seconds(5))

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

                // Wait for any other sync (startup pull, manual Sync Now) already in flight to
                // finish rather than racing it on the same working tree — see syncOnStartup()'s
                // guard for what goes wrong otherwise. Waiting (not skipping) here, since
                // auto-sync is how local changes actually reach the remote; silently dropping this
                // round could leave a push pending indefinitely if nothing else triggers autoSync
                // again soon.
                while await MainActor.run(body: { isSyncing }) {
                    guard !Task.isCancelled else { return }
                    try await Task.sleep(for: .milliseconds(500))
                }

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

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

                // Export conversations
                try await exportAllConversations()

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

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

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

            } catch {
                // Error — only release the lock if this task actually acquired it
                if acquiredLock {
                    await MainActor.run {
                        isSyncing = false
                        lastSyncError = error.localizedDescription
                    }
                }

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

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

    /// Manual full sync (the Settings → Sync "Sync Now" button): pull → import → export → push,
    /// in that order so the working tree is fully merged before Confab writes its own files back
    /// out (see exportAllConversations's ordering note). Throws `.syncInProgress` rather than
    /// racing autoSync()/syncOnStartup() if either is already running on the same working tree —
    /// same class of bug as the "untracked working tree files" failure those two guard against.
    func syncNow() async throws -> (imported: Int, skipped: Int) {
        guard !isSyncing else {
            throw SyncError.syncInProgress
        }
        isSyncing = true
        defer { isSyncing = false }

        try await pull()
        let result = try await importAllConversations()
        try await exportAllConversations()
        try await push()

        await updateStatus()
        return (result.imported, result.skipped)
    }

    // 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)
        }
    }

    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
        }
    }

    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
    }

    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
    }

    /// Runs off the main thread with a hard timeout, rather than blocking synchronously on
    /// `waitUntilExit()` on the (MainActor-isolated, per this project's default actor isolation)
    /// calling thread. A plain `git fetch`/`pull`/`push` over a connection that died silently
    /// during system sleep can otherwise hang indefinitely with no OS-level timeout of its own —
    /// since that used to block the main thread, it froze the entire app UI with nothing to show
    /// for it in the logs (no error is ever produced by a command that never finishes). Every
    /// MainActor-touching value (paths, the logger) is captured *before* dispatching to the
    /// background queue — the queue's closure must not touch `self` or other MainActor state.
    private func runGit(_ args: [String], cwd: String? = nil, timeout: TimeInterval = 30) async throws -> String {
        let workingDirectoryURL = cwd.map { URL(fileURLWithPath: expandPath($0)) }
        let log = self.log

        return try await withCheckedThrowingContinuation { continuation in
            DispatchQueue.global(qos: .userInitiated).async {
                let process = Process()
                process.executableURL = URL(fileURLWithPath: "/usr/bin/git")
                process.arguments = args
                if let workingDirectoryURL {
                    process.currentDirectoryURL = workingDirectoryURL
                }

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

                var timedOut = false
                let timeoutItem = DispatchWorkItem {
                    if process.isRunning {
                        timedOut = true
                        process.terminate()
                    }
                }
                DispatchQueue.global().asyncAfter(deadline: .now() + timeout, execute: timeoutItem)

                do {
                    try process.run()
                    process.waitUntilExit()
                } catch {
                    timeoutItem.cancel()
                    continuation.resume(throwing: error)
                    return
                }
                timeoutItem.cancel()

                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 {
                    let message = timedOut
                        ? "Timed out after \(Int(timeout))s — this can happen if your Mac just woke from sleep and the network hasn't reconnected yet. Try again in a moment."
                        : (error.isEmpty ? "Unknown error" : error)
                    log.error("Git command failed: \(args.joined(separator: " "))")
                    log.error("Error: \(message)")
                    continuation.resume(throwing: SyncError.gitFailed(message))
                    return
                }

                continuation.resume(returning: 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
        }
    }

    func sanitizeFilename(_ name: String) -> String {
        name.sanitizedForFilename()
    }

    static func extractProvider(from url: String) -> String {
        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"
        }
    }
}
