import Foundation

/// A file or piece of media the user attached to a composer message, carried from the UI
/// down to the point where it's written into the session's working tree. Just bytes plus a
/// display name — the UI has already read the file (or captured the pasted/dragged image
/// data) before handing it over, so this stays `Sendable` and free of any view types.
public struct PendingAttachment: Sendable, Equatable {
    /// The original (or synthesized) file name, e.g. `screenshot.png`. Sanitized before it
    /// hits disk — never trusted as a path.
    public let filename: String
    public let data: Data

    public init(filename: String, data: Data) {
        self.filename = filename
        self.data = data
    }
}

// `QueuedMessage` / `QueuedAttachment` moved to NucleicProtocol (so they ride along on the wire
// `SessionSummary` to the phone). Still reachable here unqualified via `ProtocolExports`.

/// Materializes composer attachments into a session's working tree and builds the message
/// reference block that points the agent at them.
///
/// Why files-on-disk rather than inline image blocks: it's backend-agnostic (Claude, Codex,
/// and Grok all just read the working tree), it survives the container sandbox (the worktree
/// is bind-mounted at its identical host path, so a path written here resolves inside the
/// guest), and it needs no change to the `AgentInput` wire format. The agent reaches the
/// files with its ordinary `Read` tool, which renders images visually.
public enum AttachmentMaterializer {
    /// Where attachments land, relative to the working-tree root. `.nucleic/` is already in
    /// the worktree's git exclude (see `WorktreeManager.excludeNucleicDir`), so dropped files
    /// never show up as changes to integrate.
    public static let directory = ".nucleic/attachments"

    /// Write each attachment under `<worktree>/.nucleic/attachments/<token>-<name>` and return
    /// the paths *relative to the worktree root* (the agent's working directory), in order.
    /// A per-file random token prevents same-named files from colliding within a turn.
    @discardableResult
    public static func materialize(
        _ attachments: [PendingAttachment], intoWorktree worktree: String
    ) throws -> [String] {
        guard !attachments.isEmpty else { return [] }
        let baseURL = URL(fileURLWithPath: worktree, isDirectory: true)
            .appendingPathComponent(directory, isDirectory: true)
        try FileManager.default.createDirectory(at: baseURL, withIntermediateDirectories: true)

        var relativePaths: [String] = []
        for attachment in attachments {
            let safeName = sanitizeFilename(attachment.filename)
            let token = String(UUID().uuidString.prefix(8)).lowercased()
            let storedName = "\(token)-\(safeName)"
            let dest = baseURL.appendingPathComponent(storedName)
            try attachment.data.write(to: dest, options: .atomic)
            relativePaths.append("\(directory)/\(storedName)")
        }
        return relativePaths
    }

    /// Fold the attachment references into the user's message so the agent sees what was
    /// attached and where to read it. Appended after the user's own text (or standing alone
    /// when the user attached files without typing anything).
    public static func augment(message: String, withReferencePaths paths: [String]) -> String {
        guard !paths.isEmpty else { return message }
        let list = paths.map { "- \($0)" }.joined(separator: "\n")
        let block = "\(referenceHeader(count: paths.count, hasMessage: !message.isEmpty))\n\(list)"
        return message.isEmpty ? block : message + "\n\n" + block
    }

    /// The line that introduces the reference list: a plain "Attached …:" label alongside the
    /// user's own text, or a "Please take a look …" instruction when they attached files without
    /// typing anything. Singular/plural agrees with `count`. The single source of truth for the
    /// wording, so `split` recognizes exactly the lines `augment` emits.
    private static func referenceHeader(count: Int, hasMessage: Bool) -> String {
        let noun = count == 1 ? "file" : "files"
        return hasMessage
            ? "Attached \(noun) (in the working tree):"
            : "Please take a look at the attached \(noun) (in the working tree):"
    }

    /// The inverse of `augment`: recover the user's own text and the attachment reference paths
    /// from a possibly-augmented message. A message with no reference block we recognize comes
    /// back unchanged with an empty path list, so this is safe to call on every user message.
    /// Lets the transcript render attachments as inline previews/cards instead of showing the
    /// raw path list the agent reads.
    public static func split(
        augmentedMessage message: String
    ) -> (text: String, attachmentPaths: [String]) {
        let dirPrefix = directory + "/"
        let lines = message.components(separatedBy: "\n")
        // Walk up the trailing "- <path>" lines that point into the attachments directory.
        var paths: [String] = []
        var index = lines.count - 1
        while index >= 0, lines[index].hasPrefix("- ") {
            let path = String(lines[index].dropFirst(2))
            guard path.hasPrefix(dirPrefix) else { break }
            paths.insert(path, at: 0)
            index -= 1
        }
        // The line just above must be one of the headers `augment` emits, or this is ordinary
        // prose that merely happens to end in a bullet list — leave the message untouched.
        let recognized: Set<String> = [
            referenceHeader(count: 1, hasMessage: true), referenceHeader(count: 2, hasMessage: true),
            referenceHeader(count: 1, hasMessage: false), referenceHeader(count: 2, hasMessage: false),
        ]
        guard !paths.isEmpty, index >= 0, recognized.contains(lines[index]) else {
            return (message, [])
        }
        // Everything above the header — minus the blank line `augment` inserts between the
        // user's text and the block — is the user's own message.
        var textLines = lines[0..<index]
        while let last = textLines.last, last.trimmingCharacters(in: .whitespaces).isEmpty {
            textLines.removeLast()
        }
        return (textLines.joined(separator: "\n"), paths)
    }

    /// Recover an attachment's original file name for display by stripping the random collision
    /// token `materialize` prepends (`<token>-<name>`). Returns the last path component unchanged
    /// when it carries no such token, so it's safe to call on any path.
    public static func displayName(forStoredPath path: String) -> String {
        let name = (path as NSString).lastPathComponent
        // The token is the 8 lowercased hex characters of a UUID, followed by a hyphen.
        guard name.count > 9,
              name[name.index(name.startIndex, offsetBy: 8)] == "-",
              name.prefix(8).allSatisfy(\.isHexDigit)
        else { return name }
        return String(name.dropFirst(9))
    }

    /// Reduce an arbitrary file name to a safe single path component: keep it to the last path
    /// component, allow only a conservative character set (others become `_`), and cap the
    /// length so a pathological name can't blow out the path. Never empty.
    static func sanitizeFilename(_ raw: String) -> String {
        let lastComponent = (raw as NSString).lastPathComponent
        let allowed = CharacterSet.alphanumerics.union(CharacterSet(charactersIn: "._-()[]"))
        var cleaned = String(lastComponent.unicodeScalars.map {
            allowed.contains($0) ? Character($0) : "_"
        })
        // A name that's all dots ("." / "..") or empty would be unusable as a file.
        if cleaned.allSatisfy({ $0 == "." }) || cleaned.isEmpty { cleaned = "attachment" }
        if cleaned.count > 120 { cleaned = String(cleaned.suffix(120)) }
        return cleaned
    }
}
