import UIKit
import AVFoundation
import UniformTypeIdentifiers

// Share Extension — puts "Biz Connect" in the iOS share sheet AND does the whole send right here, the way
// Teams/WhatsApp do: pick one or more chats in the sheet, it uploads and sends, no app-open needed.
//
// How it can send without the app: the app writes a bearer token + API base into the App Group each launch
// (via /api/share/token → ShareInbox.setAuth). This extension reads that token and calls the same server
// API the native client uses — GET /api/messages/conversations, POST /api/messages/upload, POST
// /api/messages.
//
// Manifest lifecycle (why the app doesn't also pop a "Send to" sheet): the App Group manifest represents an
// UNSENT share. We only write it when this extension can't send (no token) or a send fails — so a
// successful in-sheet send leaves nothing behind and the app never re-offers it. Cancel/success clear any
// staged files too, so the App Group doesn't accumulate orphans.

struct ShareChat {
    let kind: String   // "dm" | "group"
    let id: String
    let name: String
    let avatar: String?
    let subtitle: String
    var key: String { kind + ":" + id }
}

struct ShareItem {
    let name: String
    let url: URL
    let mime: String
    let isText: Bool
    let text: String
    var isImage: Bool { mime.hasPrefix("image/") }
    var isVideo: Bool { mime.hasPrefix("video/") }
}

class ShareViewController: UIViewController, UITableViewDataSource, UITableViewDelegate {

    private let appGroup = "group.com.bizgaze.connect"
    private let maxItems = 20
    private let brandNavy = UIColor(red: 0x1F/255.0, green: 0x3B/255.0, blue: 0x73/255.0, alpha: 1)

    private var token = ""
    private var apiBase = "https://remote.bizgaze.com"
    private var items: [ShareItem] = []
    private var chats: [ShareChat] = []
    private var filtered: [ShareChat] = []
    private var selected = Set<String>()
    private var avatarCache: [String: UIImage] = [:]
    private var sending = false

    private let table = UITableView(frame: .zero, style: .grouped)
    private let search = UISearchBar()
    private let statusLabel = UILabel()
    private let spinner = UIActivityIndicatorView(style: .medium)
    private let previewStack = UIStackView()
    private let previewScroll = UIScrollView()
    private var sendButton: UIBarButtonItem!

    // MARK: - Lifecycle

    override func viewDidLoad() {
        super.viewDidLoad()
        readAuth()
        buildUI()
        ingest()
    }

    private func readAuth() {
        if let d = UserDefaults(suiteName: appGroup) {
            token = d.string(forKey: "bzc_token") ?? ""
            let b = d.string(forKey: "bzc_base") ?? ""
            if !b.isEmpty { apiBase = b }
        }
    }

    // MARK: - UI

    private func buildUI() {
        view.backgroundColor = .systemGroupedBackground

        let nav = UINavigationBar()
        nav.translatesAutoresizingMaskIntoConstraints = false
        let appearance = UINavigationBarAppearance()
        appearance.configureWithOpaqueBackground()
        appearance.backgroundColor = brandNavy
        appearance.titleTextAttributes = [.foregroundColor: UIColor.white, .font: UIFont.systemFont(ofSize: 17, weight: .bold)]
        nav.standardAppearance = appearance
        nav.scrollEdgeAppearance = appearance
        nav.tintColor = .white
        let navItem = UINavigationItem(title: "Share to Biz Connect")
        // Clean, light SF Symbols instead of a heavy grey X-circle / bold pill (thin xmark + paper-plane).
        let symCfg = UIImage.SymbolConfiguration(pointSize: 16, weight: .regular)
        navItem.leftBarButtonItem = UIBarButtonItem(image: UIImage(systemName: "xmark", withConfiguration: symCfg), style: .plain, target: self, action: #selector(cancelTapped))
        let sendCfg = UIImage.SymbolConfiguration(pointSize: 18, weight: .semibold)
        sendButton = UIBarButtonItem(image: UIImage(systemName: "paperplane.fill", withConfiguration: sendCfg), style: .plain, target: self, action: #selector(sendTapped))
        sendButton.isEnabled = false
        navItem.rightBarButtonItem = sendButton
        nav.setItems([navItem], animated: false)
        view.addSubview(nav)

        // Preview strip — thumbnails of what's being shared (like Teams' attachment row).
        previewScroll.translatesAutoresizingMaskIntoConstraints = false
        previewScroll.showsHorizontalScrollIndicator = false
        previewStack.axis = .horizontal
        previewStack.spacing = 8
        previewStack.alignment = .center
        previewStack.translatesAutoresizingMaskIntoConstraints = false
        previewScroll.addSubview(previewStack)
        view.addSubview(previewScroll)

        search.placeholder = "Search for people or groups"
        search.delegate = self
        search.searchBarStyle = .minimal
        search.translatesAutoresizingMaskIntoConstraints = false
        view.addSubview(search)

        table.dataSource = self
        table.delegate = self
        table.rowHeight = 60
        table.backgroundColor = .systemGroupedBackground
        table.translatesAutoresizingMaskIntoConstraints = false
        view.addSubview(table)

        statusLabel.font = .systemFont(ofSize: 15)
        statusLabel.textColor = .secondaryLabel
        statusLabel.textAlignment = .center
        statusLabel.numberOfLines = 0
        statusLabel.translatesAutoresizingMaskIntoConstraints = false
        view.addSubview(statusLabel)

        spinner.translatesAutoresizingMaskIntoConstraints = false
        spinner.hidesWhenStopped = true
        view.addSubview(spinner)

        NSLayoutConstraint.activate([
            nav.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor),
            nav.leadingAnchor.constraint(equalTo: view.leadingAnchor),
            nav.trailingAnchor.constraint(equalTo: view.trailingAnchor),

            previewScroll.topAnchor.constraint(equalTo: nav.bottomAnchor, constant: 10),
            previewScroll.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: 16),
            previewScroll.trailingAnchor.constraint(equalTo: view.trailingAnchor, constant: -16),
            previewScroll.heightAnchor.constraint(equalToConstant: 56),
            previewStack.topAnchor.constraint(equalTo: previewScroll.topAnchor),
            previewStack.bottomAnchor.constraint(equalTo: previewScroll.bottomAnchor),
            previewStack.leadingAnchor.constraint(equalTo: previewScroll.leadingAnchor),
            previewStack.trailingAnchor.constraint(equalTo: previewScroll.trailingAnchor),
            previewStack.heightAnchor.constraint(equalTo: previewScroll.heightAnchor),

            search.topAnchor.constraint(equalTo: previewScroll.bottomAnchor, constant: 8),
            search.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: 6),
            search.trailingAnchor.constraint(equalTo: view.trailingAnchor, constant: -6),

            table.topAnchor.constraint(equalTo: search.bottomAnchor, constant: 2),
            table.leadingAnchor.constraint(equalTo: view.leadingAnchor),
            table.trailingAnchor.constraint(equalTo: view.trailingAnchor),
            table.bottomAnchor.constraint(equalTo: view.bottomAnchor),

            statusLabel.centerXAnchor.constraint(equalTo: view.centerXAnchor),
            statusLabel.centerYAnchor.constraint(equalTo: view.centerYAnchor),
            statusLabel.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: 32),
            statusLabel.trailingAnchor.constraint(equalTo: view.trailingAnchor, constant: -32),
            spinner.centerXAnchor.constraint(equalTo: view.centerXAnchor),
            spinner.topAnchor.constraint(equalTo: statusLabel.bottomAnchor, constant: 14)
        ])
    }

    private func setStatus(_ text: String?, busy: Bool = false) {
        DispatchQueue.main.async {
            self.statusLabel.text = text
            self.statusLabel.isHidden = (text == nil)
            if busy { self.spinner.startAnimating() } else { self.spinner.stopAnimating() }
        }
    }

    private func updateSendButton() {
        // Icon-only paper-plane (like Teams); the radio checks show what's selected. Just enable/disable.
        sendButton.isEnabled = !selected.isEmpty && !sending
    }

    // MARK: - Preview thumbnails

    private func buildPreviews() {
        for f in items where !f.isText {
            let iv = UIImageView()
            iv.translatesAutoresizingMaskIntoConstraints = false
            iv.contentMode = .scaleAspectFill
            iv.clipsToBounds = true
            iv.layer.cornerRadius = 8
            iv.backgroundColor = .tertiarySystemFill
            iv.tintColor = .secondaryLabel
            iv.widthAnchor.constraint(equalToConstant: 56).isActive = true
            iv.heightAnchor.constraint(equalToConstant: 56).isActive = true
            iv.image = UIImage(systemName: f.isVideo ? "video.fill" : "doc.fill")
            previewStack.addArrangedSubview(iv)
            thumbnail(for: f) { img in if let img = img { DispatchQueue.main.async { iv.image = img; iv.contentMode = .scaleAspectFill } } }
        }
        if items.contains(where: { $0.isText }) {
            let lbl = UILabel()
            lbl.text = "🔗 link"
            lbl.font = .systemFont(ofSize: 13)
            lbl.textColor = .secondaryLabel
            previewStack.addArrangedSubview(lbl)
        }
    }

    private func thumbnail(for item: ShareItem, completion: @escaping (UIImage?) -> Void) {
        DispatchQueue.global(qos: .userInitiated).async {
            if item.isImage, let img = UIImage(contentsOfFile: item.url.path) {
                return completion(img)
            }
            if item.isVideo {
                let asset = AVURLAsset(url: item.url)
                let gen = AVAssetImageGenerator(asset: asset)
                gen.appliesPreferredTrackTransform = true
                gen.maximumSize = CGSize(width: 168, height: 168)
                if let cg = try? gen.copyCGImage(at: CMTime(seconds: 0.1, preferredTimescale: 600), actualTime: nil) {
                    return completion(UIImage(cgImage: cg))
                }
            }
            completion(nil)
        }
    }

    // MARK: - Ingest

    private func ingest() {
        setStatus("Preparing…", busy: true)
        let providers = (extensionContext?.inputItems as? [NSExtensionItem] ?? [])
            .flatMap { $0.attachments ?? [] }
            .prefix(maxItems)
        guard !providers.isEmpty else { return finishFail("Nothing to share.") }

        var collected: [ShareItem] = []
        let lock = NSLock()
        let group = DispatchGroup()
        for provider in providers {
            group.enter()
            load(provider) { item in
                if let item = item { lock.lock(); collected.append(item); lock.unlock() }
                group.leave()
            }
        }
        group.notify(queue: .main) { [weak self] in
            guard let self = self else { return }
            self.items = collected
            if collected.isEmpty { return self.finishFail("Couldn’t read the shared file.") }
            self.buildPreviews()
            self.loadChats()
        }
    }

    private func load(_ provider: NSItemProvider, completion: @escaping (ShareItem?) -> Void) {
        let fileTypes: [UTType] = [.movie, .image, .audio, .pdf, .item]
        if let type = fileTypes.first(where: { provider.hasItemConformingToTypeIdentifier($0.identifier) }) {
            provider.loadFileRepresentation(forTypeIdentifier: type.identifier) { [weak self] url, _ in
                guard let self = self, let url = url else { return completion(nil) }
                completion(self.stage(url))
            }
            return
        }
        if provider.hasItemConformingToTypeIdentifier(UTType.url.identifier) {
            provider.loadItem(forTypeIdentifier: UTType.url.identifier) { item, _ in
                if let u = item as? URL { completion(ShareItem(name: "", url: URL(fileURLWithPath: "/"), mime: "", isText: true, text: u.absoluteString)) }
                else { completion(nil) }
            }
            return
        }
        if provider.hasItemConformingToTypeIdentifier(UTType.plainText.identifier) {
            provider.loadItem(forTypeIdentifier: UTType.plainText.identifier) { item, _ in
                if let s = item as? String { completion(ShareItem(name: "", url: URL(fileURLWithPath: "/"), mime: "", isText: true, text: s)) }
                else { completion(nil) }
            }
            return
        }
        completion(nil)
    }

    private func stage(_ src: URL) -> ShareItem? {
        guard let dir = sharedDir() else { return nil }
        let name = src.lastPathComponent.isEmpty ? "shared-file" : src.lastPathComponent
        let dest = uniqueURL(in: dir, preferred: name)
        do { try FileManager.default.copyItem(at: src, to: dest) } catch { return nil }
        return ShareItem(name: dest.lastPathComponent, url: dest, mime: Self.mimeType(for: dest), isText: false, text: "")
    }

    // MARK: - Chat list

    private func loadChats() {
        guard !token.isEmpty else {
            writeManifest(items)   // no token → let the app pick these up
            setStatus("Open Biz Connect and sign in first, then share again.\n\nYour file is saved and will be waiting in the app.")
            return
        }
        setStatus("Loading your chats…", busy: true)
        api("GET", "/api/messages/conversations") { [weak self] data, _ in
            guard let self = self else { return }
            guard let data = data,
                  let arr = (try? JSONSerialization.jsonObject(with: data)) as? [[String: Any]] else {
                self.writeManifest(self.items)
                self.setStatus("Couldn’t load your chats.\n\nOpen Biz Connect once, then try sharing again.")
                return
            }
            let parsed: [ShareChat] = arr.compactMap { row in
                guard let kind = row["kind"] as? String, let id = idString(row["id"]), let name = row["name"] as? String else { return nil }
                let sub = kind == "group" ? "Group" + ((row["members"] as? Int).map { " · \($0) members" } ?? "") : "Direct message"
                return ShareChat(kind: kind, id: id, name: name, avatar: row["avatar"] as? String, subtitle: sub)
            }
            DispatchQueue.main.async {
                self.chats = parsed
                self.filtered = parsed
                self.setStatus(parsed.isEmpty ? "No chats yet." : nil)
                self.table.reloadData()
            }
        }
    }

    // MARK: - Table

    func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { filtered.count }

    func tableView(_ tableView: UITableView, titleForHeaderInSection section: Int) -> String? {
        filtered.isEmpty ? nil : "Recent chats"
    }

    func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
        let cell = tableView.dequeueReusableCell(withIdentifier: "chat") ?? UITableViewCell(style: .subtitle, reuseIdentifier: "chat")
        let c = filtered[indexPath.row]
        cell.textLabel?.text = c.name
        cell.textLabel?.font = .systemFont(ofSize: 16)
        cell.detailTextLabel?.text = c.subtitle
        cell.detailTextLabel?.textColor = .secondaryLabel
        // Radio selector on the right (empty circle → filled navy check), always visible like Teams.
        let selImg = selected.contains(c.key)
            ? UIImage(systemName: "checkmark.circle.fill")?.withTintColor(brandNavy, renderingMode: .alwaysOriginal)
            : UIImage(systemName: "circle")?.withTintColor(.systemGray3, renderingMode: .alwaysOriginal)
        let selView = UIImageView(image: selImg)
        selView.frame = CGRect(x: 0, y: 0, width: 26, height: 26)
        cell.accessoryView = selView
        // Round avatar
        cell.imageView?.layer.cornerRadius = 20
        cell.imageView?.layer.masksToBounds = true
        if let img = avatarCache[c.key] {
            cell.imageView?.image = img
        } else {
            cell.imageView?.image = initialsImage(c.name)
            loadAvatar(for: c)
        }
        return cell
    }

    func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
        tableView.deselectRow(at: indexPath, animated: true)
        guard !sending else { return }
        let key = filtered[indexPath.row].key
        if selected.contains(key) { selected.remove(key) } else { selected.insert(key) }
        tableView.reloadRows(at: [indexPath], with: .none)
        updateSendButton()
    }

    // MARK: - Avatars

    private func loadAvatar(for chat: ShareChat) {
        guard let av = chat.avatar, !av.isEmpty else { return }
        if av.hasPrefix("data:") {
            if let comma = av.firstIndex(of: ","),
               let d = Data(base64Encoded: String(av[av.index(after: comma)...])),
               let img = Self.circularImage(from: d) {
                cache(img, for: chat)
            }
            return
        }
        let urlStr = av.hasPrefix("/") ? (apiBase + av) : av
        guard let url = URL(string: urlStr) else { return }
        var req = URLRequest(url: url)
        if !token.isEmpty { req.setValue("Bearer " + token, forHTTPHeaderField: "Authorization") }
        URLSession.shared.dataTask(with: req) { [weak self] d, _, _ in
            guard let self = self, let d = d, let img = Self.circularImage(from: d) else { return }
            self.cache(img, for: chat)
        }.resume()
    }

    private func cache(_ img: UIImage, for chat: ShareChat) {
        DispatchQueue.main.async {
            self.avatarCache[chat.key] = img
            if let idx = self.filtered.firstIndex(where: { $0.key == chat.key }) {
                self.table.reloadRows(at: [IndexPath(row: idx, section: 0)], with: .none)
            }
        }
    }

    private func initialsImage(_ name: String, size: CGFloat = 40) -> UIImage {
        let parts = name.split(separator: " ")
        let initials = parts.prefix(2).compactMap { $0.first }.map { String($0) }.joined().uppercased()
        let bg = Self.color(for: name)
        let renderer = UIGraphicsImageRenderer(size: CGSize(width: size, height: size))
        return renderer.image { _ in
            bg.setFill()
            UIBezierPath(ovalIn: CGRect(x: 0, y: 0, width: size, height: size)).fill()
            let para = NSMutableParagraphStyle(); para.alignment = .center
            let attrs: [NSAttributedString.Key: Any] = [
                .font: UIFont.systemFont(ofSize: size * 0.4, weight: .semibold),
                .foregroundColor: UIColor.white,
                .paragraphStyle: para
            ]
            let s = (initials.isEmpty ? "?" : initials) as NSString
            let ts = s.size(withAttributes: attrs)
            s.draw(in: CGRect(x: 0, y: (size - ts.height) / 2, width: size, height: ts.height), withAttributes: attrs)
        }
    }

    private static func circularImage(from data: Data, size: CGFloat = 40) -> UIImage? {
        guard let img = UIImage(data: data) else { return nil }
        let renderer = UIGraphicsImageRenderer(size: CGSize(width: size, height: size))
        return renderer.image { _ in
            UIBezierPath(ovalIn: CGRect(x: 0, y: 0, width: size, height: size)).addClip()
            let scale = max(size / img.size.width, size / img.size.height)
            let w = img.size.width * scale, h = img.size.height * scale
            img.draw(in: CGRect(x: (size - w) / 2, y: (size - h) / 2, width: w, height: h))
        }
    }

    private static let palette: [UIColor] = [
        UIColor(red: 0.20, green: 0.45, blue: 0.85, alpha: 1),
        UIColor(red: 0.85, green: 0.35, blue: 0.45, alpha: 1),
        UIColor(red: 0.30, green: 0.65, blue: 0.45, alpha: 1),
        UIColor(red: 0.75, green: 0.55, blue: 0.20, alpha: 1),
        UIColor(red: 0.50, green: 0.40, blue: 0.75, alpha: 1),
        UIColor(red: 0.25, green: 0.60, blue: 0.70, alpha: 1)
    ]
    private static func color(for name: String) -> UIColor {
        var h: UInt32 = 0
        for c in name.unicodeScalars { h = h &* 31 &+ c.value }
        return palette[Int(h % UInt32(palette.count))]
    }

    // MARK: - Send

    @objc private func sendTapped() {
        guard !sending else { return }
        let targets = chats.filter { selected.contains($0.key) }
        guard !targets.isEmpty else { return }
        sending = true
        updateSendButton()
        search.isHidden = true
        table.isHidden = true
        previewScroll.isHidden = true
        let label = targets.count == 1 ? targets[0].name : "\(targets.count) chats"
        setStatus("Sending to \(label)…", busy: true)

        DispatchQueue.global(qos: .userInitiated).async {
            let files = self.items.filter { !$0.isText }
            let texts = self.items.filter { $0.isText }.map { $0.text }
            var ok = true
            var attIds: [String] = []
            for (i, f) in files.enumerated() {
                if files.count > 1 { self.setStatus("Uploading \(i + 1) of \(files.count)…", busy: true) }
                guard let data = try? Data(contentsOf: f.url), let id = self.uploadSync(name: f.name, mime: f.mime, data: data) else { ok = false; break }
                attIds.append(id)
            }
            if ok {
                sendLoop: for chat in targets {
                    for id in attIds {
                        if !self.sendMessageSync(self.messageBody(chat: chat, attachmentId: id, text: nil)) { ok = false; break sendLoop }
                    }
                    if !texts.isEmpty {
                        if !self.sendMessageSync(self.messageBody(chat: chat, attachmentId: nil, text: texts.joined(separator: "\n"))) { ok = false; break sendLoop }
                    }
                }
            }
            DispatchQueue.main.async {
                if ok {
                    self.clearStaged()   // sent → leave nothing for the app to re-offer
                    self.setStatus("✓ Sent to \(label)")
                    DispatchQueue.main.asyncAfter(deadline: .now() + 0.8) { self.finish() }
                } else {
                    self.writeManifest(self.items)   // failed → let the app pick these up
                    self.sending = false
                    self.search.isHidden = false
                    self.table.isHidden = false
                    self.previewScroll.isHidden = false
                    self.updateSendButton()
                    self.setStatus("Couldn’t send. Your file is saved — open Biz Connect to send it.")
                    DispatchQueue.main.asyncAfter(deadline: .now() + 2.4) { self.finish() }
                }
            }
        }
    }

    private func messageBody(chat: ShareChat, attachmentId: String?, text: String?) -> [String: Any] {
        var b: [String: Any] = [:]
        if chat.kind == "group" { b["group"] = chat.id } else { b["to"] = chat.id }
        if let a = attachmentId { b["attachmentId"] = a }
        if let t = text { b["body"] = t }
        return b
    }

    // MARK: - Networking

    private func uploadSync(name: String, mime: String, data: Data) -> String? {
        guard let url = URL(string: apiBase + "/api/messages/upload") else { return nil }
        var req = URLRequest(url: url)
        req.httpMethod = "POST"
        req.setValue("Bearer " + token, forHTTPHeaderField: "Authorization")
        req.setValue(mime.isEmpty ? "application/octet-stream" : mime, forHTTPHeaderField: "Content-Type")
        req.setValue(name.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) ?? name, forHTTPHeaderField: "X-Filename")
        req.httpBody = data
        req.timeoutInterval = 300
        var out: String? = nil
        let sem = DispatchSemaphore(value: 0)
        URLSession.shared.dataTask(with: req) { d, resp, _ in
            defer { sem.signal() }
            guard let d = d, let http = resp as? HTTPURLResponse, (200..<300).contains(http.statusCode),
                  let obj = (try? JSONSerialization.jsonObject(with: d)) as? [String: Any] else { return }
            out = idString(obj["id"])
        }.resume()
        sem.wait()
        return out
    }

    private func sendMessageSync(_ body: [String: Any]) -> Bool {
        guard let url = URL(string: apiBase + "/api/messages"),
              let json = try? JSONSerialization.data(withJSONObject: body) else { return false }
        var req = URLRequest(url: url)
        req.httpMethod = "POST"
        req.setValue("Bearer " + token, forHTTPHeaderField: "Authorization")
        req.setValue("application/json", forHTTPHeaderField: "Content-Type")
        req.httpBody = json
        req.timeoutInterval = 60
        var ok = false
        let sem = DispatchSemaphore(value: 0)
        URLSession.shared.dataTask(with: req) { _, resp, _ in
            if let http = resp as? HTTPURLResponse, (200..<300).contains(http.statusCode) { ok = true }
            sem.signal()
        }.resume()
        sem.wait()
        return ok
    }

    private func api(_ method: String, _ path: String, completion: @escaping (Data?, HTTPURLResponse?) -> Void) {
        guard let url = URL(string: apiBase + path) else { return completion(nil, nil) }
        var req = URLRequest(url: url)
        req.httpMethod = method
        req.setValue("Bearer " + token, forHTTPHeaderField: "Authorization")
        req.timeoutInterval = 30
        URLSession.shared.dataTask(with: req) { d, resp, _ in completion(d, resp as? HTTPURLResponse) }.resume()
    }

    // MARK: - App Group storage

    private func sharedDir() -> URL? {
        guard let base = FileManager.default.containerURL(forSecurityApplicationGroupIdentifier: appGroup) else { return nil }
        let dir = base.appendingPathComponent("Shared", isDirectory: true)
        try? FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true)
        return dir
    }

    private func uniqueURL(in dir: URL, preferred: String) -> URL {
        let ext = (preferred as NSString).pathExtension
        let stem = (preferred as NSString).deletingPathExtension
        var candidate = dir.appendingPathComponent(preferred)
        var i = 2
        while FileManager.default.fileExists(atPath: candidate.path) {
            let next = ext.isEmpty ? "\(stem) (\(i))" : "\(stem) (\(i)).\(ext)"
            candidate = dir.appendingPathComponent(next)
            i += 1
        }
        return candidate
    }

    // Written only when this extension can't send — it marks an UNSENT share for the app to pick up.
    private func writeManifest(_ its: [ShareItem]) {
        guard let dir = sharedDir() else { return }
        let records: [[String: Any]] = its.map { it in
            it.isText ? ["kind": "text", "text": it.text]
                      : ["kind": "file", "name": it.name, "path": it.url.path, "mime": it.mime]
        }
        if let data = try? JSONSerialization.data(withJSONObject: records) {
            try? data.write(to: dir.appendingPathComponent("manifest.json"), options: .atomic)
        }
    }

    // Remove the staged files + any manifest, so the App Group doesn't accumulate and the app has nothing
    // to re-offer after a successful send or a cancel.
    private func clearStaged() {
        guard let dir = sharedDir() else { return }
        let fm = FileManager.default
        if let entries = try? fm.contentsOfDirectory(at: dir, includingPropertiesForKeys: nil) {
            for e in entries { try? fm.removeItem(at: e) }
        }
    }

    private static func mimeType(for url: URL) -> String {
        if let t = UTType(filenameExtension: url.pathExtension.lowercased()), let m = t.preferredMIMEType { return m }
        return "application/octet-stream"
    }

    // MARK: - Finish

    @objc private func cancelTapped() {
        clearStaged()   // nothing sent → don't leave orphans or let the app re-offer
        extensionContext?.cancelRequest(withError: NSError(domain: "share", code: 0))
    }

    private func finish() {
        extensionContext?.completeRequest(returningItems: nil, completionHandler: nil)
    }

    private func finishFail(_ msg: String) {
        setStatus(msg)
        DispatchQueue.main.asyncAfter(deadline: .now() + 1.8) { self.finish() }
    }
}

private func idString(_ v: Any?) -> String? {
    if let s = v as? String { return s }
    if let n = v as? NSNumber { return n.stringValue }
    return nil
}

extension ShareViewController: UISearchBarDelegate {
    func searchBar(_ searchBar: UISearchBar, textDidChange searchText: String) {
        let q = searchText.trimmingCharacters(in: .whitespaces).lowercased()
        filtered = q.isEmpty ? chats : chats.filter { $0.name.lowercased().contains(q) }
        table.reloadData()
    }
}
