import Foundation

/// Session transfer Mac→Mac (mesh P5). Moves one idle session — its git branch (as a bundle),
/// its transcript, its row projection, and best-effort agent memory — from a source Mac to a
/// destination Mac over the E2EE sync channel, as a 2-phase commit that never lets the session
/// run in two places.
///
/// Wire shape: the **source acts as a client of the destination**. Source→dest verbs are
/// `ClientMsg` cases (which throw on an unknown tag, so every send is gated on the
/// destination advertising `WireCapabilities.canReceiveSessionTransfer`); dest→source replies
/// are `HostMsg` cases (forward-compatible `.unknown` fallback) that also surface as
/// `SyncClient.Event`s so the source — receiving them as a client — can observe them.
///
/// The transfer protocol has its own format version, independent of `SyncProtocol.version`
/// (which stays 1): the destination rejects an offer whose `formatVersion` it can't handle.
public enum SessionTransferFormat {
    public static let version = 1
}

/// Which piece of a transfer a chunk stream carries. Each item is streamed as `chunkCount`
/// ordered `transferChunk`s and verified whole against its `sha256` at the destination.
public enum TransferItemKind: String, Sendable, Codable, CaseIterable {
    /// The git bundle for the session's branch (`git bundle create … <branch> ^<haveSHAs>`).
    case bundle
    /// Our append-only JSONL transcript (the UI's source of truth).
    case transcript
    /// The native agent transcript (`claude --resume` history) — best-effort memory carry.
    case nativeTranscript
}

/// Why a session can't be transferred, shared by the source-side classifier (which shows the
/// reason and never offers) and the destination-side wire reject. Raw-string-backed so an
/// older peer decodes a newer reason as its raw value instead of throwing.
public struct TransferRejectReason: RawRepresentable, Sendable, Codable, Equatable, Hashable {
    public let rawValue: String
    public init(rawValue: String) { self.rawValue = rawValue }

    // Source-side (the session/repo can't be packaged):
    /// Status has a live backend process (running / provisioning / mid-approval) — quiesce first.
    public static let notQuiescent = TransferRejectReason(rawValue: "notQuiescent")
    /// An nvrsion (shared-trunk) session — no per-session worktree/branch to bundle.
    public static let nvrsion = TransferRejectReason(rawValue: "nvrsion")
    /// A nested session, or a parent of still-live children — its lineage can't move alone.
    public static let nestedOrParent = TransferRejectReason(rawValue: "nestedOrParent")
    /// The repo is a shallow clone — its history can't be bundled prerequisite-free.
    public static let shallowRepo = TransferRejectReason(rawValue: "shallowRepo")
    /// The repo is a partial (filtered/promisor) clone.
    public static let partialClone = TransferRejectReason(rawValue: "partialClone")
    /// The repo uses Git LFS — pointer files would move without their objects.
    public static let lfsRepo = TransferRejectReason(rawValue: "lfsRepo")
    /// The repo has submodules — their checkouts don't ride the bundle.
    public static let submoduleRepo = TransferRejectReason(rawValue: "submoduleRepo")
    /// The session has no worktree/branch to move.
    public static let noWorktree = TransferRejectReason(rawValue: "noWorktree")

    // Destination-side (the offer can't be accepted):
    /// No matching project on the destination — the user must add it there first.
    public static let projectNotFound = TransferRejectReason(rawValue: "projectNotFound")
    /// The destination already has a branch by that name.
    public static let branchCollision = TransferRejectReason(rawValue: "branchCollision")
    /// The transcript header version exceeds what the destination supports.
    public static let versionMismatch = TransferRejectReason(rawValue: "versionMismatch")
    /// The destination doesn't support this transfer format version.
    public static let unsupportedFormat = TransferRejectReason(rawValue: "unsupportedFormat")
    /// A transfer for this session is already in progress on the destination.
    public static let alreadyTransferring = TransferRejectReason(rawValue: "alreadyTransferring")
    /// A hash check failed or a git operation errored during staging.
    public static let verificationFailed = TransferRejectReason(rawValue: "verificationFailed")
    /// The destination hit an unexpected internal error.
    public static let internalError = TransferRejectReason(rawValue: "internalError")

    /// User-facing text.
    public var message: String {
        switch self {
        case .notQuiescent: "The agent is still working — wait for the turn to finish or interrupt it."
        case .nvrsion: "Shared-trunk (nvrsion) sessions can't be moved yet."
        case .nestedOrParent: "This session is part of a stack of dependent sessions and can't move on its own."
        case .shallowRepo: "This project is a shallow clone, which can't be moved."
        case .partialClone: "This project is a partial clone, which can't be moved."
        case .lfsRepo: "This project uses Git LFS, which isn't supported for transfer yet."
        case .submoduleRepo: "This project has submodules, which aren't supported for transfer yet."
        case .noWorktree: "This session has no worktree to move."
        case .projectNotFound: "The other Mac doesn't have this project. Add it there, then try again."
        case .branchCollision: "The other Mac already has a branch with this session's name."
        case .versionMismatch: "The other Mac is running an older version that can't read this session."
        case .unsupportedFormat: "The other Mac doesn't support session transfer at this version."
        case .alreadyTransferring: "This session is already being transferred."
        case .verificationFailed: "The transfer data failed verification."
        case .internalError: "The other Mac hit an error importing the session."
        default: "This session can't be transferred (\(rawValue))."
        }
    }
}

/// A portable projection of the source's session row. Paths (worktree / transcript / native)
/// are deliberately **absent** — the destination recomputes them under its own layout. Status
/// is normalized to a quiescent state at the destination, so it isn't carried.
public struct SessionTransferRecord: Sendable, Codable, Equatable {
    public let sessionID: SessionID
    public let backend: BackendID
    /// The native agent session id, for `--resume` memory carry. Nil ⇒ fresh context.
    public let backendSessionID: String?
    public let title: String
    public let branch: String
    /// The fork-point commit — the natural bundle base and the destination's `haveSHA` candidate.
    public let baseSHA: String
    public let model: String?
    public let effort: String?
    public let lastSeq: UInt64
    public let createdAt: Date

    public init(
        sessionID: SessionID, backend: BackendID, backendSessionID: String?,
        title: String, branch: String, baseSHA: String,
        model: String?, effort: String?, lastSeq: UInt64, createdAt: Date
    ) {
        self.sessionID = sessionID
        self.backend = backend
        self.backendSessionID = backendSessionID
        self.title = title
        self.branch = branch
        self.baseSHA = baseSHA
        self.model = model
        self.effort = effort
        self.lastSeq = lastSeq
        self.createdAt = createdAt
    }
}

/// How the destination matches an incoming session to one of its local projects: project UUID
/// first, then repo identity (root-commit SHA / normalized remote), so a project the user added
/// separately on each Mac — hence under different UUIDs — still matches.
public struct ProjectDescriptor: Sendable, Codable, Equatable {
    public let projectID: ProjectID
    public let name: String
    /// SHA of the repo's first (parentless) commit — a stable repo fingerprint.
    public let rootCommitSHA: String?
    /// `origin` remote, normalized (scheme/creds/`.git`/trailing-slash stripped) for comparison.
    public let normalizedRemote: String?
    public let defaultBranch: String

    public init(
        projectID: ProjectID, name: String, rootCommitSHA: String?,
        normalizedRemote: String?, defaultBranch: String
    ) {
        self.projectID = projectID
        self.name = name
        self.rootCommitSHA = rootCommitSHA
        self.normalizedRemote = normalizedRemote
        self.defaultBranch = defaultBranch
    }

    /// Whether `other` (a local project descriptor) is the same repo as this one — same UUID,
    /// or same root commit, or same normalized remote.
    public func matches(_ other: ProjectDescriptor) -> Bool {
        if projectID == other.projectID { return true }
        if let a = rootCommitSHA, let b = other.rootCommitSHA, a == b { return true }
        if let a = normalizedRemote, let b = other.normalizedRemote, !a.isEmpty, a == b { return true }
        return false
    }
}

/// One item's whole-content descriptor. For `transcript`/`nativeTranscript` this is known at
/// offer time; the `bundle`'s descriptor is finalized after the destination reports its
/// `haveSHAs`, so it arrives with the chunk stream (each chunk repeats it) rather than in the offer.
public struct TransferItemDescriptor: Sendable, Codable, Equatable {
    public let kind: TransferItemKind
    public let byteCount: Int
    public let sha256: String
    public let chunkCount: Int

    public init(kind: TransferItemKind, byteCount: Int, sha256: String, chunkCount: Int) {
        self.kind = kind
        self.byteCount = byteCount
        self.sha256 = sha256
        self.chunkCount = chunkCount
    }
}

/// Source→dest: the opening manifest. Carries everything the destination needs to accept or
/// reject *before* any bytes move — the session projection, project identity, transcript
/// version, the base SHAs the source could bundle against, and the descriptors for the items
/// known up front (the git bundle's descriptor rides its chunks instead).
public struct TransferOffer: Sendable, Codable, Equatable {
    public let transferID: String
    public let formatVersion: Int
    public let record: SessionTransferRecord
    public let project: ProjectDescriptor
    /// `SessionHeader.version` of the transcript being sent, checked against the dest's max.
    public let transcriptHeaderVersion: Int
    /// SHAs the source can bundle against (baseSHA, and the branch tip) — the dest picks which
    /// it already has to keep the bundle prerequisite-based; none in common ⇒ full-history bundle.
    public let availableBaseSHAs: [String]
    /// Descriptors known at offer time (transcript, optional native transcript). The bundle's is
    /// finalized post-accept and travels with its chunks.
    public let knownItems: [TransferItemDescriptor]

    public init(
        transferID: String, formatVersion: Int = SessionTransferFormat.version,
        record: SessionTransferRecord, project: ProjectDescriptor,
        transcriptHeaderVersion: Int, availableBaseSHAs: [String],
        knownItems: [TransferItemDescriptor]
    ) {
        self.transferID = transferID
        self.formatVersion = formatVersion
        self.record = record
        self.project = project
        self.transcriptHeaderVersion = transcriptHeaderVersion
        self.availableBaseSHAs = availableBaseSHAs
        self.knownItems = knownItems
    }
}

/// Dest→source: the offer is accepted. Names the destination's local project id (the source
/// remaps `record.projectID` to it), the SHAs the destination already has (so the source
/// bundles `^have`), and — for resume — the chunk index to resume each partially-staged item from.
public struct TransferAccept: Sendable, Codable, Equatable {
    public let transferID: String
    public let resolvedProjectID: ProjectID
    public let haveSHAs: [String]
    /// Per-item resume cursor: the next chunk index the destination still needs. Absent/empty ⇒
    /// start every item from chunk 0.
    public let resumeFrom: [TransferItemKind: Int]

    public init(
        transferID: String, resolvedProjectID: ProjectID,
        haveSHAs: [String], resumeFrom: [TransferItemKind: Int] = [:]
    ) {
        self.transferID = transferID
        self.resolvedProjectID = resolvedProjectID
        self.haveSHAs = haveSHAs
        self.resumeFrom = resumeFrom
    }
}

/// Dest→source: the offer is rejected with a typed reason (at offer time, or later if staging
/// fails). Terminal for this transfer.
public struct TransferReject: Sendable, Codable, Equatable {
    public let transferID: String
    public let reason: TransferRejectReason
    public let message: String

    public init(transferID: String, reason: TransferRejectReason, message: String? = nil) {
        self.transferID = transferID
        self.reason = reason
        self.message = message ?? reason.message
    }
}

/// Source→dest: one ordered slice of an item. Each chunk repeats the item's whole `sha256` and
/// `chunkCount` so the destination can verify the reassembled item and tolerate resume without a
/// separate per-item manifest. `data` is raw bytes, ≤ `TransferChunk.maxDataBytes`.
public struct TransferChunk: Sendable, Codable, Equatable {
    /// Max raw payload per chunk (2 MiB) — below the 16 MiB frame cap with headroom.
    public static let maxDataBytes = 2 * 1024 * 1024

    public let transferID: String
    public let kind: TransferItemKind
    public let chunkIndex: Int
    public let chunkCount: Int
    public let sha256: String
    public let data: Data

    public init(
        transferID: String, kind: TransferItemKind, chunkIndex: Int,
        chunkCount: Int, sha256: String, data: Data
    ) {
        self.transferID = transferID
        self.kind = kind
        self.chunkIndex = chunkIndex
        self.chunkCount = chunkCount
        self.sha256 = sha256
        self.data = data
    }
}

/// Dest→source: a chunk landed and was persisted — the stop-and-wait flow-control ack the
/// source's send window slides on.
public struct TransferChunkAck: Sendable, Codable, Equatable {
    public let transferID: String
    public let kind: TransferItemKind
    public let chunkIndex: Int

    public init(transferID: String, kind: TransferItemKind, chunkIndex: Int) {
        self.transferID = transferID
        self.kind = kind
        self.chunkIndex = chunkIndex
    }
}
