import SwiftUI
import Observation

/// Holds the live ("working") panel arrangement plus the user's saved, named
/// layouts and which one is the default. Global (app-wide), persisted to
/// `UserDefaults` as JSON — see the design note in `PaneledChatView`.
///
/// All mutation goes through here so the single working layout stays the source of
/// truth for the container view; saved layouts are immutable snapshots applied by
/// copying into `working`.
@MainActor
@Observable
final class PanelLayoutStore {
    /// The arrangement currently shown around the chat.
    private(set) var working: PanelLayout
    /// User-saved named layouts, in creation order.
    private(set) var saved: [PanelLayout]
    /// The layout applied to a fresh launch, if any.
    private(set) var defaultLayoutID: UUID?

    private let defaults: UserDefaults

    private enum Key {
        static let working = "nucleic.panels.working"
        static let saved = "nucleic.panels.saved"
        static let defaultID = "nucleic.panels.defaultID"
        static let vmMonitorHome = "nucleic.panels.vmMonitorHome"
    }

    init(defaults: UserDefaults = .standard) {
        self.defaults = defaults
        let saved = Self.decode([PanelLayout].self, from: defaults.data(forKey: Key.saved)) ?? []
        let defaultID = defaults.string(forKey: Key.defaultID).flatMap { UUID(uuidString: $0) }
        self.saved = saved
        self.defaultLayoutID = defaultID
        self.vmMonitorHome = Self.decode(
            PanelHome.self, from: defaults.data(forKey: Key.vmMonitorHome))

        // Prefer the last live arrangement; otherwise fall back to the default layout,
        // otherwise an empty (chat-only) arrangement.
        if let stored = Self.decode(PanelLayout.self, from: defaults.data(forKey: Key.working)) {
            self.working = stored
        } else if let id = defaultID, let layout = saved.first(where: { $0.id == id }) {
            self.working = layout.asWorkingCopy()
        } else {
            self.working = .empty
        }
    }

    // MARK: - Queries

    var defaultLayout: PanelLayout? {
        guard let id = defaultLayoutID else { return nil }
        return saved.first { $0.id == id }
    }

    /// Which slot a placed panel currently lives in, if any.
    func slot(of instanceID: UUID) -> PanelSlot? {
        PanelSlot.allCases.first { working.instances(in: $0).contains { $0.id == instanceID } }
    }

    /// Whether a VM Monitor panel is placed in any slot right now.
    var hasVMMonitor: Bool {
        PanelSlot.allCases.contains {
            working.instances(in: $0).contains { $0.kind == .vmMonitor }
        }
    }

    /// True when the working arrangement matches a saved layout's panel placement
    /// (sizes ignored) — used to show a checkmark in the layouts menu.
    func matchesWorking(_ layout: PanelLayout) -> Bool {
        func kinds(_ xs: [PanelInstance]) -> [PanelKind] { xs.map(\.kind) }
        return kinds(layout.left) == kinds(working.left)
            && kinds(layout.right) == kinds(working.right)
            && kinds(layout.bottom) == kinds(working.bottom)
    }

    // MARK: - Working arrangement mutations

    func add(_ kind: PanelKind, to slot: PanelSlot) {
        let weight = newPaneWeight(for: slot)
        modify(slot) { $0.append(PanelInstance(kind: kind, weight: weight)) }
    }

    /// The weight a pane arriving in `slot` should take: the average of what's already there,
    /// so it lands on an equal footing with its new siblings however they've been resized —
    /// rather than a bare `1`, which in a column of hand-sized panes is an arbitrary share.
    private func newPaneWeight(for slot: PanelSlot) -> CGFloat {
        let weights = working.instances(in: slot).map { PanelInstance.sanitize($0.weight) }
        guard !weights.isEmpty else { return 1 }
        return weights.reduce(0, +) / CGFloat(weights.count)
    }

    /// Surface an Editor panel in a side column in response to opening a file from the
    /// file explorer at `explorerID`. An editor already in a side column is left where
    /// it is (it already shows the file via `FileSelectionStore`); an editor docked in
    /// the bottom row is moved up to the right column; otherwise a fresh editor is
    /// added in the column opposite the file explorer.
    func revealEditor(forExplorer explorerID: UUID?) {
        if working.left.contains(where: { $0.kind == .editor })
            || working.right.contains(where: { $0.kind == .editor }) { return }

        if let bottomEditor = working.bottom.first(where: { $0.kind == .editor }) {
            drop(PanelDrag(instanceID: bottomEditor.id, kind: .editor), into: .right)
            return
        }

        let target: PanelSlot
        switch explorerID.flatMap(slot(of:)) {
        case .left: target = .right
        case .right: target = .left
        case .bottom, .none: target = .right
        }
        add(.editor, to: target)
    }

    /// Surface a Terminal panel. A terminal already placed anywhere is left where it is;
    /// otherwise a fresh one is docked in the bottom row.
    func revealTerminal() {
        let hasTerminal = PanelSlot.allCases.contains {
            working.instances(in: $0).contains { $0.kind == .terminal }
        }
        guard !hasTerminal else { return }
        add(.terminal, to: .bottom)
    }

    /// Surface a Subagents panel — used by the orchestration card's "view all" action, when a
    /// delegation plan spawned more workers than the card lists inline. One already placed anywhere is
    /// left where it is (it lists the open chat's workers on its own); otherwise a fresh one is docked
    /// in the right column, where a list of sessions reads like the sidebar it mirrors.
    func revealSubagents() {
        let hasSubagents = PanelSlot.allCases.contains {
            working.instances(in: $0).contains { $0.kind == .subagents }
        }
        guard !hasSubagents else { return }
        add(.subagents, to: .right)
    }

    /// Surface a VM Monitor panel — used by the Control panel's "Observe" action so the user can watch
    /// the just-opened session's macOS guest screen. A monitor already placed anywhere is left where it
    /// is (it re-roots onto the newly open chat's VM on its own); otherwise a fresh one is docked where
    /// the last monitor stood (see ``vmMonitorHome``), defaulting to the right column, where a display
    /// reads best.
    func revealVMMonitor() {
        vmMonitorDismissed = false
        guard !hasVMMonitor else { return }
        dockVMMonitor()
    }

    /// Where a VM Monitor should reappear when it's docked again: the slot, index, and column
    /// share it held the last time one was removed. Without it the monitor is appended to the
    /// bottom of the right column every time it comes back — so a monitor dragged to the top of
    /// a column, or given most of that column's height, silently loses both each time the user
    /// clicks between chats and the auto-open driver retracts and re-docks it. Persisted, so the
    /// position outlives a relaunch as well.
    private var vmMonitorHome: PanelHome?

    /// A pane's remembered place in the arrangement — see ``vmMonitorHome``.
    private struct PanelHome: Codable, Equatable, Sendable {
        var slot: PanelSlot
        var index: Int
        var weight: CGFloat
    }

    /// Place a VM Monitor back where the last one stood (right column, appended, on a first
    /// run), and hand back the instance so the auto-open driver can remember it as its own.
    @discardableResult
    private func dockVMMonitor() -> PanelInstance {
        let home = vmMonitorHome
        let slot = home?.slot ?? .right
        let instance = PanelInstance(
            kind: .vmMonitor, weight: home?.weight ?? newPaneWeight(for: slot))
        modify(slot) { items in
            items.insert(instance, at: min(max(home?.index ?? items.count, 0), items.count))
        }
        return instance
    }

    /// Remember where a monitor stood, just before it leaves the arrangement. Called for every
    /// removal — the driver's retraction and a hand-close alike — since either way the next
    /// monitor should come back to the place the user last put this one.
    private func rememberVMMonitorHome(_ instanceID: UUID) {
        guard let slot = slot(of: instanceID),
              let index = working.instances(in: slot).firstIndex(where: { $0.id == instanceID })
        else { return }
        vmMonitorHome = PanelHome(
            slot: slot, index: index, weight: working.instances(in: slot)[index].weight)
        persistVMMonitorHome()
    }

    /// Instance id of the VM Monitor this store opened for the "Automatically open VM monitors" setting,
    /// so ``autoCloseVMMonitor()`` can retract exactly that panel without disturbing a monitor the user
    /// docked by hand. `nil` when no auto-opened monitor is live.
    private var autoVMMonitorID: UUID?

    /// The user closed a VM Monitor by hand while the auto-open setting was on. Without this the
    /// event-driven monitor driver (`RootView.applyAutoVMMonitor`) would re-dock the panel on its very
    /// next trigger, making the close button look broken. Cleared once the driver retracts on its own
    /// (the VM stopped, or the setting was turned off), when the open chat changes, or on an explicit
    /// reveal.
    private var vmMonitorDismissed = false

    /// Ensure a VM Monitor is visible in the right column for the auto-open-monitors setting. If any
    /// monitor is already placed — auto-opened earlier or added by the user — nothing changes (it
    /// re-roots onto the open chat's VM on its own). Otherwise a fresh one is docked where the last
    /// monitor stood — same slot, same index, same share of the column (see ``vmMonitorHome``) — and
    /// remembered as auto-opened. A hand-closed monitor stays closed (see ``vmMonitorDismissed``).
    func autoRevealVMMonitor() {
        guard !vmMonitorDismissed, !hasVMMonitor else { return }
        autoVMMonitorID = dockVMMonitor().id
    }

    /// Retract the monitor this store auto-opened (see ``autoRevealVMMonitor()``). A monitor the user
    /// placed or moved is left untouched — only the tracked auto-opened instance is removed. Reaching
    /// here means the auto-open condition is false, so any manual dismissal has served its purpose.
    func autoCloseVMMonitor() {
        vmMonitorDismissed = false
        guard let id = autoVMMonitorID else { return }
        remove(id)
    }

    /// Forget a manual VM Monitor close so the next chat gets the auto-opened monitor it's due.
    func resetVMMonitorDismissal() { vmMonitorDismissed = false }

    func remove(_ instanceID: UUID) {
        // Closing a monitor by hand suppresses the auto-open driver until the condition that opened it
        // goes away — otherwise the panel reappears on the driver's next trigger. Its place in the
        // column is noted first, so whenever it comes back it comes back *here*.
        if PanelSlot.allCases.contains(where: {
            working.instances(in: $0).contains { $0.id == instanceID && $0.kind == .vmMonitor }
        }) {
            rememberVMMonitorHome(instanceID)
            vmMonitorDismissed = true
        }
        if autoVMMonitorID == instanceID { autoVMMonitorID = nil }
        for slot in PanelSlot.allCases {
            modify(slot) { $0.removeAll { $0.id == instanceID } }
        }
    }

    /// Move (or insert) `drag` into `slot` at `index` (appending when `index` is nil).
    /// A palette drag (`instanceID == nil`) inserts a new panel; an existing-panel drag
    /// is pulled from wherever it was first, then inserted, so dragging within the same
    /// slot reorders it.
    func drop(_ drag: PanelDrag, into slot: PanelSlot, at index: Int? = nil) {
        var moving = PanelInstance(kind: drag.kind, weight: newPaneWeight(for: slot))
        var target = index
        if let id = drag.instanceID {
            for s in PanelSlot.allCases {
                if let existing = working.instances(in: s).first(where: { $0.id == id }) {
                    moving = existing
                    // A reorder keeps the height the user gave this pane; a move to a *different*
                    // region does not — a weight only means anything against its own siblings, so
                    // carrying one across would let a pane that filled a tall column swallow the
                    // one it lands in.
                    if s != slot { moving.weight = newPaneWeight(for: slot) }
                }
            }
            // `index` was measured against the destination slot as it looks *now*, with
            // the dragged pane still in it. Pulling that pane out shifts everything after
            // it up one, so an in-slot move to a later position has to come up with them —
            // otherwise dragging a pane one place down lands it two places down, and
            // dropping it back where it started nudges it forward.
            if let t = target,
                let from = working.instances(in: slot).firstIndex(where: { $0.id == id }),
                from < t {
                target = t - 1
            }
            // Remove from all slots before re-inserting.
            for s in PanelSlot.allCases { modify(s) { $0.removeAll { $0.id == id } } }
        }
        modify(slot) { items in
            let i = min(max(target ?? items.count, 0), items.count)
            items.insert(moving, at: i)
        }
    }

    /// The placed pane with this id, in whichever slot holds it.
    func instance(_ instanceID: UUID) -> PanelInstance? {
        for s in PanelSlot.allCases {
            if let hit = working.instances(in: s).first(where: { $0.id == instanceID }) { return hit }
        }
        return nil
    }

    /// Send a placed pane to the end of `slot`. The menu equivalent of dragging it
    /// across, so the pane header's "Move to" commands — and anyone driving the app by
    /// keyboard or VoiceOver — can reach every position a drag can.
    func move(_ instanceID: UUID, to slot: PanelSlot) {
        guard let kind = instance(instanceID)?.kind else { return }
        drop(PanelDrag(instanceID: instanceID, kind: kind), into: slot)
    }

    /// Nudge a pane one place earlier or later within the slot it already occupies.
    func shift(_ instanceID: UUID, by delta: Int) {
        guard let slot = slot(of: instanceID) else { return }
        modify(slot) { items in
            guard let from = items.firstIndex(where: { $0.id == instanceID }) else { return }
            let to = min(max(from + delta, 0), items.count - 1)
            guard to != from else { return }
            items.insert(items.remove(at: from), at: to)
        }
    }

    /// Live region-size updates during a divider drag. These deliberately do *not*
    /// persist — a drag fires many per second, and synchronously re-encoding JSON on
    /// each tick caused visible jitter (especially with a `TextEditor` in a panel).
    /// Call `commitSizes()` once on drag end to persist the final size.
    func setWidth(_ width: CGFloat, for slot: PanelSlot) {
        switch slot {
        case .left: working.leftWidth = width
        case .right: working.rightWidth = width
        case .bottom: break
        }
    }

    func setBottomHeight(_ height: CGFloat) {
        working.bottomHeight = height
    }

    /// Live pane-share updates while a divider *inside* a region is dragged — the same
    /// no-persist-per-tick rule as ``setWidth(_:for:)``, committed by ``commitSizes()`` on
    /// release. The two neighbours either side of the divider are handed in together, since a
    /// drag there trades length between them and leaves every other pane in the column alone.
    func setPaneWeights(_ weights: [UUID: CGFloat]) {
        for slot in PanelSlot.allCases {
            updateInstances(slot) { items in
                for i in items.indices {
                    if let weight = weights[items[i].id] {
                        items[i].weight = PanelInstance.sanitize(weight)
                    }
                }
            }
        }
    }

    /// Persist the working layout after a resize gesture finishes.
    func commitSizes() { persistWorking() }

    func clearAll() {
        working = .empty
        persistWorking()
    }

    private func modify(_ slot: PanelSlot, _ change: (inout [PanelInstance]) -> Void) {
        updateInstances(slot, change)
        persistWorking()
    }

    /// Mutate a slot *without* writing to `UserDefaults` — for the per-tick edits a live drag
    /// makes, which persist once at the end (see ``setPaneWeights(_:)``).
    private func updateInstances(_ slot: PanelSlot, _ change: (inout [PanelInstance]) -> Void) {
        switch slot {
        case .left: change(&working.left)
        case .right: change(&working.right)
        case .bottom: change(&working.bottom)
        }
    }

    // MARK: - Saved layouts

    /// Snapshot the working arrangement under `name`. If a saved layout already has
    /// that name, it's overwritten in place (keeping its id, so it stays the default
    /// if it was).
    func saveWorking(as name: String) {
        let trimmed = name.trimmingCharacters(in: .whitespacesAndNewlines)
        guard !trimmed.isEmpty else { return }
        if let index = saved.firstIndex(where: { $0.name.caseInsensitiveCompare(trimmed) == .orderedSame }) {
            var updated = working
            updated.id = saved[index].id
            updated.name = trimmed
            saved[index] = updated
        } else {
            var snapshot = working
            snapshot.id = UUID()
            snapshot.name = trimmed
            saved.append(snapshot)
        }
        persistSaved()
    }

    func apply(_ layout: PanelLayout) {
        working = layout.asWorkingCopy()
        persistWorking()
    }

    func deleteSaved(_ id: UUID) {
        saved.removeAll { $0.id == id }
        if defaultLayoutID == id { defaultLayoutID = nil; persistDefault() }
        persistSaved()
    }

    func renameSaved(_ id: UUID, to name: String) {
        let trimmed = name.trimmingCharacters(in: .whitespacesAndNewlines)
        guard !trimmed.isEmpty, let index = saved.firstIndex(where: { $0.id == id }) else { return }
        saved[index].name = trimmed
        persistSaved()
    }

    func setDefault(_ id: UUID?) {
        defaultLayoutID = id
        persistDefault()
    }

    // MARK: - Persistence

    private func persistWorking() { defaults.set(Self.encode(working), forKey: Key.working) }
    private func persistSaved() { defaults.set(Self.encode(saved), forKey: Key.saved) }
    private func persistVMMonitorHome() {
        // Encoded only when there *is* a home: `JSONEncoder` refuses a top-level `nil`.
        guard let home = vmMonitorHome else {
            defaults.removeObject(forKey: Key.vmMonitorHome)
            return
        }
        defaults.set(Self.encode(home), forKey: Key.vmMonitorHome)
    }
    private func persistDefault() {
        if let id = defaultLayoutID { defaults.set(id.uuidString, forKey: Key.defaultID) }
        else { defaults.removeObject(forKey: Key.defaultID) }
    }

    private static func encode<T: Encodable>(_ value: T) -> Data? { try? JSONEncoder().encode(value) }
    private static func decode<T: Decodable>(_ type: T.Type, from data: Data?) -> T? {
        guard let data else { return nil }
        return try? JSONDecoder().decode(T.self, from: data)
    }
}
