//
//  GitSyncServiceTests.swift
//  oAITests
//
// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0
// Copyright (C) 2026 Rune Olsen

import Testing
import Foundation
@testable import Confab

@Suite("GitSyncService pure helpers")
struct GitSyncServiceTests {

    private var service: GitSyncService { GitSyncService.shared }

    // MARK: - convertToSSH

    @Test("Already-SSH URLs pass through unchanged")
    func convertToSSHAlreadySSH() {
        #expect(service.convertToSSH("git@github.com:user/repo.git") == "git@github.com:user/repo.git")
    }

    @Test("HTTPS URL converts to SSH form")
    func convertToSSHFromHTTPS() {
        #expect(service.convertToSSH("https://gitlab.pm/rune/oAI-Sync.git") == "git@gitlab.pm:rune/oAI-Sync.git")
    }

    @Test("HTTP URL converts to SSH form")
    func convertToSSHFromHTTP() {
        #expect(service.convertToSSH("http://example.com/user/repo.git") == "git@example.com:user/repo.git")
    }

    @Test("Malformed URL with no path segment is returned unchanged")
    func convertToSSHMalformedNoSlash() {
        #expect(service.convertToSSH("https://onlyhost") == "https://onlyhost")
    }

    @Test("Unknown scheme is returned unchanged")
    func convertToSSHUnknownScheme() {
        #expect(service.convertToSSH("ftp://example.com/repo") == "ftp://example.com/repo")
    }

    // MARK: - injectCredentials

    @Test("Injects username and password into an HTTPS URL")
    func injectCredentialsHTTPS() {
        let result = service.injectCredentials("https://gitlab.pm/rune/repo.git", username: "rune", password: "secret")
        #expect(result == "https://rune:secret@gitlab.pm/rune/repo.git")
    }

    @Test("Non-HTTPS URLs are returned unchanged (credentials not injected)")
    func injectCredentialsNonHTTPS() {
        let result = service.injectCredentials("git@gitlab.pm:rune/repo.git", username: "rune", password: "secret")
        #expect(result == "git@gitlab.pm:rune/repo.git")
    }

    // MARK: - sanitizeFilename

    @Test("Strips filesystem-invalid characters, replacing each with a dash")
    func sanitizeFilenameStripsInvalidChars() {
        #expect(service.sanitizeFilename("a/b\\c:d*e?f\"g<h>i|j") == "a-b-c-d-e-f-g-h-i-j")
    }

    @Test("Leaves an already-valid filename untouched")
    func sanitizeFilenameValidInput() {
        #expect(service.sanitizeFilename("My Chat 2026-01-15") == "My Chat 2026-01-15")
    }

    // MARK: - detectSecretsInText

    @Test("Detects an OpenAI-style API key")
    func detectSecretsOpenAIKey() {
        let text = "here's my key: sk-abcdefghijklmnopqrstuvwxyz123456"
        #expect(service.detectSecretsInText(text).contains("OpenAI Key"))
    }

    @Test("Detects a GitHub personal access token")
    func detectSecretsGitHubToken() {
        let text = "token=ghp_abcdefghijklmnopqrstuvwxyz1234567890"
        #expect(service.detectSecretsInText(text).contains("Access Token"))
    }

    @Test("Plain conversational text has no detected secrets")
    func detectSecretsCleanText() {
        #expect(service.detectSecretsInText("Just a normal conversation about the weather.").isEmpty)
    }

    @Test("Does not flag an OpenAI-shaped string one character short of the length threshold")
    func detectSecretsJustBelowThreshold() {
        // "sk-" + 31 chars = 34 total, but the pattern requires 32+ chars after "sk-"
        let shortKey = "sk-" + String(repeating: "a", count: 31)
        #expect(!service.detectSecretsInText(shortKey).contains("OpenAI Key"))
    }

    // MARK: - extractProvider

    @Test("Recognizes github.com, gitlab.com, and gitea URLs by name")
    func extractProviderRecognizesKnownHosts() {
        #expect(GitSyncService.extractProvider(from: "https://github.com/user/repo.git") == "GitHub")
        #expect(GitSyncService.extractProvider(from: "https://gitlab.com/user/repo.git") == "GitLab")
        #expect(GitSyncService.extractProvider(from: "https://my-gitea-instance.example.com/user/repo.git") == "Gitea")
    }

    @Test("Falls back to a generic label for an unrecognized host")
    func extractProviderFallsBackForUnknownHost() {
        #expect(GitSyncService.extractProvider(from: "https://gitlab.pm/rune/oai-swift.git") == "Git repository")
    }

    // MARK: - orphanedExportFilenames

    private func makeExportMarkdown(id: String, name: String = "Test") -> String {
        ConversationExport(
            id: id,
            name: name,
            createdAt: Date(),
            updatedAt: Date(),
            primaryModel: nil,
            messages: [.init(role: "user", content: "hi", timestamp: Date(), tokens: nil, cost: nil, modelId: nil)]
        ).toMarkdown()
    }

    @Test("A file whose ID still exists locally is not orphaned")
    func keepsFilesForExistingConversations() {
        let id = UUID().uuidString
        let files = [(filename: "chat.md", markdown: makeExportMarkdown(id: id))]
        let orphans = GitSyncService.orphanedExportFilenames(currentIds: [id], files: files)
        #expect(orphans.isEmpty)
    }

    @Test("A file whose ID no longer exists locally is orphaned")
    func flagsFilesForDeletedConversations() {
        let deletedId = UUID().uuidString
        let keptId = UUID().uuidString
        let files = [
            (filename: "deleted.md", markdown: makeExportMarkdown(id: deletedId)),
            (filename: "kept.md", markdown: makeExportMarkdown(id: keptId)),
        ]
        let orphans = GitSyncService.orphanedExportFilenames(currentIds: [keptId], files: files)
        #expect(orphans == ["deleted.md"])
    }

    @Test("Files that fail to parse are left alone rather than deleted")
    func unparsableFilesAreNotFlaggedAsOrphans() {
        let files = [(filename: "not-a-conversation.md", markdown: "not a valid export file")]
        let orphans = GitSyncService.orphanedExportFilenames(currentIds: [], files: files)
        #expect(orphans.isEmpty)
    }

    @Test("Empty file list produces no orphans")
    func emptyFileListProducesNoOrphans() {
        #expect(GitSyncService.orphanedExportFilenames(currentIds: [UUID().uuidString], files: []).isEmpty)
    }

    @Test("Empty local conversation list never flags files as orphaned, even when files exist")
    func emptyCurrentIdsNeverOrphansExistingFiles() {
        // Regression test: this is the exact shape of the production bug. A fresh clone (or any
        // moment where the local DB hasn't caught up yet) has zero local conversations, while the
        // sync repo still has every previously-synced file on disk. Without the guard, all of them
        // used to be flagged orphaned and deleted+pushed, wiping the whole sync repo.
        let files = [
            (filename: "a.md", markdown: makeExportMarkdown(id: UUID().uuidString)),
            (filename: "b.md", markdown: makeExportMarkdown(id: UUID().uuidString)),
        ]
        let orphans = GitSyncService.orphanedExportFilenames(currentIds: [], files: files)
        #expect(orphans.isEmpty)
    }

    // MARK: - orphanedLocalFolderIds

    @Test("A local folder still present in the manifest is not orphaned")
    func keepsLocalFoldersStillInManifest() {
        let id = UUID().uuidString
        let orphans = GitSyncService.orphanedLocalFolderIds(manifestFolderIds: [id], localFolderIds: [id])
        #expect(orphans.isEmpty)
    }

    @Test("A local folder missing from the manifest is orphaned")
    func flagsLocalFoldersMissingFromManifest() {
        let keptId = UUID().uuidString
        let deletedId = UUID().uuidString
        let orphans = GitSyncService.orphanedLocalFolderIds(
            manifestFolderIds: [keptId], localFolderIds: [keptId, deletedId]
        )
        #expect(orphans == [deletedId])
    }

    @Test("An empty manifest never orphans existing local folders")
    func emptyManifestNeverOrphansLocalFolders() {
        // Same safety guard as orphanedExportFilenames: an empty manifest is indistinguishable
        // from "folders.json hasn't been imported yet" (older sync repo, or a fresh clone before
        // the first export), so treating it as "delete every local folder" would repeat the exact
        // class of mass-deletion bug that hit conversation sync.
        let orphans = GitSyncService.orphanedLocalFolderIds(
            manifestFolderIds: [], localFolderIds: [UUID().uuidString, UUID().uuidString]
        )
        #expect(orphans.isEmpty)
    }

    @Test("Empty local folder list produces no orphans")
    func emptyLocalFolderListProducesNoOrphans() {
        let orphans = GitSyncService.orphanedLocalFolderIds(manifestFolderIds: [UUID().uuidString], localFolderIds: [])
        #expect(orphans.isEmpty)
    }

    // MARK: - FolderSyncManifest round-trip

    @Test("FolderSyncManifest round-trips through JSON encode/decode")
    func folderSyncManifestRoundTrips() throws {
        let folderId = UUID().uuidString
        let conversationId = UUID().uuidString
        let manifest = FolderSyncManifest(
            folders: [
                FolderSyncManifest.FolderEntry(
                    id: folderId, name: "Work", parentId: nil,
                    createdAt: Date(timeIntervalSince1970: 1_000), updatedAt: Date(timeIntervalSince1970: 2_000)
                )
            ],
            assignments: [conversationId: folderId]
        )

        let encoder = JSONEncoder()
        encoder.dateEncodingStrategy = .iso8601
        let decoder = JSONDecoder()
        decoder.dateDecodingStrategy = .iso8601

        let data = try encoder.encode(manifest)
        let decoded = try decoder.decode(FolderSyncManifest.self, from: data)

        #expect(decoded.folders.count == 1)
        #expect(decoded.folders.first?.id == folderId)
        #expect(decoded.folders.first?.name == "Work")
        #expect(decoded.assignments[conversationId] == folderId)
    }

    // MARK: - orphanedNoteFilenames

    @Test("A note file still referenced by a current conversation is not orphaned")
    func keepsNoteFilesForExistingConversations() {
        let orphans = GitSyncService.orphanedNoteFilenames(currentFilenames: ["chat-a3f2.md"], existingFiles: ["chat-a3f2.md"])
        #expect(orphans.isEmpty)
    }

    @Test("A note file no longer referenced by any current conversation is orphaned")
    func flagsNoteFilesForDeletedConversations() {
        let orphans = GitSyncService.orphanedNoteFilenames(
            currentFilenames: ["kept-b1c2.md"],
            existingFiles: ["kept-b1c2.md", "deleted-d3e4.md"]
        )
        #expect(orphans == ["deleted-d3e4.md"])
    }

    @Test("Non-markdown files in the notes directory are never flagged as orphans")
    func nonMarkdownFilesAreNotFlaggedAsOrphans() {
        let orphans = GitSyncService.orphanedNoteFilenames(currentFilenames: ["kept.md"], existingFiles: [".DS_Store"])
        #expect(orphans.isEmpty)
    }

    @Test("Empty existing file list produces no orphans")
    func emptyExistingNoteFilesProducesNoOrphans() {
        #expect(GitSyncService.orphanedNoteFilenames(currentFilenames: ["a.md"], existingFiles: []).isEmpty)
    }

    @Test("Empty current filenames set never orphans existing note files, even when files exist")
    func emptyCurrentNoteFilenamesNeverOrphansExistingFiles() {
        // Same class of regression guard as emptyCurrentIdsNeverOrphansExistingFiles /
        // emptyManifestNeverOrphansLocalFolders: an empty local-state snapshot must never be
        // read as "delete everything in the sync repo."
        let orphans = GitSyncService.orphanedNoteFilenames(currentFilenames: [], existingFiles: ["a.md", "b.md"])
        #expect(orphans.isEmpty)
    }

    // MARK: - NotesSyncManifest round-trip

    @Test("NotesSyncManifest round-trips through JSON encode/decode")
    func notesSyncManifestRoundTrips() throws {
        let conversationId = UUID().uuidString
        let manifest = NotesSyncManifest(
            notes: [conversationId: NotesSyncManifest.Entry(filename: "Chat-a3f2.md", enabled: true)]
        )

        let data = try JSONEncoder().encode(manifest)
        let decoded = try JSONDecoder().decode(NotesSyncManifest.self, from: data)

        #expect(decoded.notes[conversationId]?.filename == "Chat-a3f2.md")
        #expect(decoded.notes[conversationId]?.enabled == true)
    }

    // MARK: - parseUntrackedFileConflict

    @Test("Parses a single colliding file out of git's untracked-files error")
    func parseUntrackedFileConflictSingleFile() {
        let message = "error: The following untracked working tree files would be overwritten by merge:\n\tfolders.json\nPlease move or remove them before you merge.\nAborting"
        #expect(GitSyncService.parseUntrackedFileConflict(from: message) == ["folders.json"])
    }

    @Test("Parses multiple colliding files out of git's untracked-files error")
    func parseUntrackedFileConflictMultipleFiles() {
        let message = "error: The following untracked working tree files would be overwritten by merge:\n\tfolders.json\n\tnotes.json\nPlease move or remove them before you merge.\nAborting"
        #expect(GitSyncService.parseUntrackedFileConflict(from: message) == ["folders.json", "notes.json"])
    }

    @Test("Wrapped SyncError.gitFailed description still parses correctly")
    func parseUntrackedFileConflictWrappedMessage() {
        let message = "Git command failed: error: The following untracked working tree files would be overwritten by merge:\n\tnotes/Chat-a3f2.md\nPlease move or remove them before you merge.\nAborting"
        #expect(GitSyncService.parseUntrackedFileConflict(from: message) == ["notes/Chat-a3f2.md"])
    }

    @Test("Unrelated git errors return nil, not an empty or bogus file list")
    func parseUntrackedFileConflictUnrelatedErrors() {
        #expect(GitSyncService.parseUntrackedFileConflict(from: "fatal: Authentication failed for 'https://gitlab.pm/rune/oai-swift.git/'") == nil)
        #expect(GitSyncService.parseUntrackedFileConflict(from: "fatal: unable to access: Could not resolve host") == nil)
        #expect(GitSyncService.parseUntrackedFileConflict(from: "error: Your local changes to the following files would be overwritten by merge:\n\tconversations/x.md") == nil)
    }

    // MARK: - isFileSafeToAutoDelete

    @Test("Known sync-manifest files are safe to auto-delete")
    func isFileSafeToAutoDeleteAllowsKnownFiles() {
        #expect(GitSyncService.isFileSafeToAutoDelete("folders.json"))
        #expect(GitSyncService.isFileSafeToAutoDelete("notes.json"))
        #expect(GitSyncService.isFileSafeToAutoDelete("conversations/my-chat.md"))
        #expect(GitSyncService.isFileSafeToAutoDelete("notes/Chat-a3f2.md"))
    }

    @Test("Path traversal and absolute paths are never safe to auto-delete")
    func isFileSafeToAutoDeleteRejectsTraversal() {
        #expect(!GitSyncService.isFileSafeToAutoDelete("../etc/passwd"))
        #expect(!GitSyncService.isFileSafeToAutoDelete("/etc/passwd"))
        #expect(!GitSyncService.isFileSafeToAutoDelete("conversations/../../../etc/passwd"))
    }

    @Test("Files outside the known shape are never safe to auto-delete")
    func isFileSafeToAutoDeleteRejectsUnknownFiles() {
        #expect(!GitSyncService.isFileSafeToAutoDelete("README.md"))
        #expect(!GitSyncService.isFileSafeToAutoDelete("conversations/sub/x.md"))
        #expect(!GitSyncService.isFileSafeToAutoDelete("notes/sub/x.md"))
        #expect(!GitSyncService.isFileSafeToAutoDelete(""))
        #expect(!GitSyncService.isFileSafeToAutoDelete("conversations/"))
    }

    // MARK: - PendingGitConflict.canAutoFix

    @Test("canAutoFix is true when every file is safe to auto-delete")
    func pendingGitConflictCanAutoFixAllSafe() {
        let conflict = GitSyncService.PendingGitConflict(files: ["folders.json", "notes.json"], rawError: "")
        #expect(conflict.canAutoFix)
    }

    @Test("canAutoFix is false when any file isn't safe to auto-delete")
    func pendingGitConflictCanAutoFixOneUnsafe() {
        let conflict = GitSyncService.PendingGitConflict(files: ["folders.json", "README.md"], rawError: "")
        #expect(!conflict.canAutoFix)
    }
}
