import AppKit
import SwiftUI

// MARK: - The accessory controller

/// **The print panel's own pane of Lanework options** — `NSPrintPanelAccessorizing`, hosting SwiftUI, with
/// the system's live preview redrawing as the controls change.
///
/// ### Why the accessory rather than a pre-flight sheet of our own
///
/// The scope ruling prefers it and the reason holds up: ⌘P is one of the most over-learned gestures on the
/// platform, and a sheet of ours *before* the print panel would put two dialogs between the user and a
/// sheet of paper — the second of which asks about paper, orientation and the printer, which is where a
/// user expects the *first* one to. The accessory route also gets the thing a pre-flight sheet could never
/// have: **the system's own preview**, showing the actual paginated document, updating as a toggle flips.
/// Rebuilding that inside an app sheet would mean re-implementing the preview, the paper controls, and the
/// PDF/queue destinations.
///
/// The one thing an accessory is cramped for is **profile management**, which is why the popup here does
/// the three management gestures through named prompts (`PrintProfilePrompt`) rather than an inline
/// editable list. That is a genuine compromise and it is the right one: choosing a profile is a
/// once-per-print gesture that belongs in the flow, while naming and deleting them is rare and is fine
/// behind a prompt.
///
/// ### The preview refresh is one KVO key, deliberately
///
/// `keyPathsForValuesAffectingPreview()` is a KVO contract: the panel observes the key paths it returns and
/// redraws when one changes. A `@Observable` session cannot be observed that way, and mirroring thirteen
/// options as thirteen `@objc dynamic` properties would be thirteen chances to forget one — a toggle that
/// silently stopped updating the preview. So there is exactly **one** observed key, a revision counter the
/// form bumps whenever the options value changes at all. The options are `Equatable`, so "changed" is a
/// real comparison rather than a notification storm.
@MainActor
final class PrintOptionsAccessoryController: NSViewController, NSPrintPanelAccessorizing {

    private let session: PrintSession

    /// **The one key the panel observes.** See the type's note — bumped by the form whenever
    /// `session.options` changes, which is what makes the preview live.
    @objc dynamic private(set) var optionsRevision = 0

    init(session: PrintSession) {
        self.session = session
        super.init(nibName: nil, bundle: nil)
    }

    @available(*, unavailable)
    required init?(coder: NSCoder) { fatalError("PrintOptionsAccessoryController is created in code") }

    override func loadView() {
        let hosting = NSHostingView(rootView: PrintOptionsForm(session: session) { [weak self] in
            // Willingly on the main actor: the form is a SwiftUI view in this controller's own view tree.
            self?.optionsRevision += 1
        })
        // The panel sizes its accessory to the view it is given, and a hosting view with no frame reports
        // zero. The width is the panel's own comfortable measure; the height is what the form needs.
        hosting.frame = CGRect(origin: .zero, size: CGSize(width: 480, height: 430))
        view = hosting
    }

    // MARK: NSPrintPanelAccessorizing

    /// The **collapsed** summary the panel shows when the accessory is not the visible pane — the answer to
    /// "what will this print do" without opening anything.
    ///
    /// Four rows, each one a decision the user could otherwise only recover by switching back: the profile
    /// they are on (with its modified state, which is the one thing a popup title cannot show once the
    /// pane is hidden), what is included, where pages break, and the face.
    nonisolated func localizedSummaryItems() -> [[NSPrintPanel.AccessorySummaryKey: String]] {
        MainActor.assumeIsolated { summaryItems() }
    }

    private func summaryItems() -> [[NSPrintPanel.AccessorySummaryKey: String]] {
        let options = session.options.normalized
        return [
            [
                .itemName: "Profile",
                .itemDescription: session.isModified
                    ? "\(session.selectedProfileName) (modified)"
                    : session.selectedProfileName
            ],
            [.itemName: "Includes", .itemDescription: PrintOptionsSummary.includes(options)],
            [.itemName: "Page Breaks", .itemDescription: PrintOptionsSummary.pageBreaks(options)],
            [.itemName: "Type", .itemDescription: PrintOptionsSummary.type(options)]
        ]
    }

    /// The panel redraws its preview when this changes — see the type's note on why there is one of them.
    nonisolated func keyPathsForValuesAffectingPreview() -> Set<String> {
        // `#keyPath` rather than a string literal, so a rename of the property is a compile error rather
        // than a preview that quietly stopped updating. It needs the actor to form, and the panel asks this
        // on the main thread like everything else it does.
        MainActor.assumeIsolated { [#keyPath(optionsRevision)] }
    }
}

// MARK: - The summary's wording

/// The sentences the collapsed summary shows, as pure functions of the options — separated from the
/// controller for the reason every rule in this feature is: a wording nobody can test drifts from the
/// controls it describes.
enum PrintOptionsSummary {

    /// "Title, labels, body" — the components, in the order they print, or "Nothing" for the state a user
    /// can reach by turning all four off (`PrintOptions.describesAnyContent`).
    static func includes(_ options: PrintOptions) -> String {
        var parts: [String] = []
        if options.includesTitle { parts.append("title") }
        if options.includesLabels { parts.append("labels") }
        if options.includesBody { parts.append("body") }
        if options.includesComments {
            parts.append(options.commentSort == .newestFirst ? "comments (newest first)" : "comments (oldest first)")
        }
        guard !parts.isEmpty else { return "Nothing" }
        return parts.joined(separator: ", ").capitalizedFirstLetter
    }

    static func pageBreaks(_ options: PrintOptions) -> String {
        switch options.pageBreaks {
        case .flow: "Continuous"
        case .betweenLanes: "Between lanes"
        case .betweenCards: "Between cards"
        }
    }

    /// "Palatino 11 pt" / "System 11 pt". The size is written as an integer when it is one, because
    /// "11 pt" is what a user typed and "11.0 pt" is what a `Double` remembers.
    static func type(_ options: PrintOptions) -> String {
        let face = options.fontFamily ?? "System"
        let size = options.fontSize
        let text = size == size.rounded() ? String(Int(size)) : String(format: "%.1f", size)
        return "\(face) \(text) pt"
    }
}

private extension String {
    var capitalizedFirstLetter: String {
        guard let first else { return self }
        return first.uppercased() + dropFirst()
    }
}

// MARK: - The form

/// The accessory's controls — the card's five bullets, in the order it lists them, plus the profile row
/// that makes them reusable.
///
/// SwiftUI inside an `NSHostingView` inside a print panel: the panel is AppKit and modal, but the controls
/// are the app's, and every other configuration surface in Lanework is SwiftUI (the style editor, the board
/// popover, the settings pane). A second UI vocabulary for one pane would be a second set of layout and
/// accessibility habits to keep honest.
private struct PrintOptionsForm: View {

    let session: PrintSession

    /// Called whenever the options value changes — bumps the controller's KVO counter, which is what makes
    /// the panel's preview live (`PrintOptionsAccessoryController`).
    let onOptionsChange: () -> Void

    /// The families, read once: `NSFontManager`'s list is a few hundred entries and does not change while a
    /// print panel is up.
    @State private var families: [String] = PrintTypography.families()

    /// The sentinel the picker uses for "the system font", since `nil` is not a `Picker` tag value.
    private static let systemFace = ""

    var body: some View {
        ScrollView {
            VStack(alignment: .leading, spacing: 14) {
                profiles
                Divider()
                components
                Divider()
                breaks
                Divider()
                type
                Divider()
                runningLines
            }
            .padding(16)
            .frame(maxWidth: .infinity, alignment: .leading)
        }
        .onChange(of: session.options) { _, _ in onOptionsChange() }
    }

    // MARK: Profiles

    @ViewBuilder
    private var profiles: some View {
        VStack(alignment: .leading, spacing: 6) {
            HStack(spacing: 8) {
                Picker("Profile", selection: profileSelection) {
                    ForEach(session.profiles.menuNames, id: \.self) { name in
                        Text(name).tag(name)
                    }
                }
                .frame(maxWidth: 240)

                Spacer(minLength: 0)

                // Save is always live: on the reserved row it is how a named profile is *created* from the
                // options in front of you, which is the gesture the whole feature exists for.
                Button("Save…") { save() }
                Button("Rename…") { rename() }
                    .disabled(!session.canManageSelection)
                Button("Delete") { session.deleteSelectedProfile() }
                    .disabled(!session.canManageSelection)
            }

            if session.isModified {
                Text("Modified — Save… keeps these settings under a name.")
                    .font(.caption)
                    .foregroundStyle(.secondary)
            }
        }
    }

    /// The popup's binding. Reading is the session's label; writing goes through `selectProfile(named:)`,
    /// which is what copies the profile's options in rather than only moving a selection.
    private var profileSelection: Binding<String> {
        Binding(
            get: { session.selectedProfileName },
            set: { session.selectProfile(named: $0) }
        )
    }

    private func save() {
        let suggested = session.canManageSelection ? session.selectedProfileName : ""
        guard let name = PrintProfilePrompt.ask(
            title: "Save Print Profile",
            message: "Name these print settings so you can reuse them.",
            defaultValue: suggested,
            prompt: "Save"
        ) else { return }
        session.saveProfile(named: name)
    }

    private func rename() {
        guard let name = PrintProfilePrompt.ask(
            title: "Rename Print Profile",
            message: "Give '\(session.selectedProfileName)' a new name.",
            defaultValue: session.selectedProfileName,
            prompt: "Rename"
        ) else { return }
        session.renameSelectedProfile(to: name)
    }

    // MARK: Components

    @ViewBuilder
    private var components: some View {
        VStack(alignment: .leading, spacing: 6) {
            Text("Include").font(.headline)
            Toggle("Title", isOn: binding(\.includesTitle))
            Toggle("Icon & Labels", isOn: binding(\.includesLabels))
            Toggle("Body", isOn: binding(\.includesBody))
            Toggle("Comments", isOn: binding(\.includesComments))

            Picker("Comment order", selection: binding(\.commentSort)) {
                Text("Oldest first").tag(PrintCommentSort.oldestFirst)
                Text("Newest first").tag(PrintCommentSort.newestFirst)
            }
            .pickerStyle(.radioGroup)
            .padding(.leading, 18)
            // Disabled rather than hidden: a control that vanishes takes the *existence* of the choice with
            // it, and the sort is remembered across the toggle (`PrintOptions.commentSort`).
            .disabled(!session.options.includesComments)
        }
    }

    // MARK: Page breaks

    @ViewBuilder
    private var breaks: some View {
        VStack(alignment: .leading, spacing: 6) {
            Text("Page Breaks").font(.headline)
            Picker("", selection: binding(\.pageBreaks)) {
                Text("Continuous").tag(PrintPageBreaks.flow)
                Text("Start each lane on a new page").tag(PrintPageBreaks.betweenLanes)
                Text("Start each card on a new page").tag(PrintPageBreaks.betweenCards)
            }
            .pickerStyle(.radioGroup)
            .labelsHidden()
        }
    }

    // MARK: Type

    @ViewBuilder
    private var type: some View {
        VStack(alignment: .leading, spacing: 6) {
            Text("Type").font(.headline)
            HStack(spacing: 8) {
                Picker("Face", selection: faceSelection) {
                    Text("System").tag(Self.systemFace)
                    Divider()
                    ForEach(families, id: \.self) { family in
                        Text(family).tag(family)
                    }
                }
                .frame(maxWidth: 260)

                Stepper(value: sizeSelection, in: PrintOptions.fontSizeRange, step: 0.5) {
                    Text("Size \(sizeLabel) pt")
                }
            }
            Text("Headings, bylines and the running head are all derived from this size.")
                .font(.caption)
                .foregroundStyle(.secondary)
        }
    }

    private var faceSelection: Binding<String> {
        Binding(
            get: { session.options.fontFamily ?? Self.systemFace },
            set: { session.options.fontFamily = $0 == Self.systemFace ? nil : $0 }
        )
    }

    private var sizeSelection: Binding<Double> {
        Binding(
            get: { session.options.fontSize },
            set: { session.options.fontSize = PrintOptions.clamped(fontSize: $0) }
        )
    }

    private var sizeLabel: String {
        let size = session.options.fontSize
        return size == size.rounded() ? String(Int(size)) : String(format: "%.1f", size)
    }

    // MARK: Header and footer

    @ViewBuilder
    private var runningLines: some View {
        VStack(alignment: .leading, spacing: 6) {
            Text("Header & Footer").font(.headline)
            Toggle("Board title", isOn: binding(\.headerShowsBoardTitle))
            Toggle("Print date", isOn: binding(\.headerShowsPrintDate))
            Toggle("Page numbers", isOn: binding(\.footerShowsPageNumbers))
            Toggle("Custom line", isOn: binding(\.footerShowsCustomLine))
            TextField("", text: binding(\.footerCustomLine), prompt: Text("Footer text"))
                .textFieldStyle(.roundedBorder)
                .padding(.leading, 18)
                .disabled(!session.options.footerShowsCustomLine)
        }
    }

    // MARK: One binding shape for every option

    /// A writable binding into `session.options` through a key path — thirteen controls, one mechanism, so a
    /// new option is a row rather than a row plus a binding plus a chance to bind the wrong field.
    private func binding<Value>(_ keyPath: WritableKeyPath<PrintOptions, Value>) -> Binding<Value> {
        Binding(
            get: { session.options[keyPath: keyPath] },
            set: { session.options[keyPath: keyPath] = $0 }
        )
    }
}

// MARK: - The naming prompt

/// The one-field prompt behind Save… and Rename….
///
/// **An `NSAlert`, run modally over the print panel**, and not a SwiftUI sheet: the accessory has no window
/// of its own to present from — it is a view inside AppKit's panel — and a nested modal session is exactly
/// what the platform provides for a dialog raised from a modal dialog. It is also the shape Finder uses for
/// the same gesture.
///
/// The refusal path is quiet: `nil` for Cancel, and `nil` for a name the catalog will not take, which is
/// the same answer because both mean "nothing was named" (`PrintProfileCatalog.isAcceptable` states which
/// names those are — blank, and the reserved one).
@MainActor
enum PrintProfilePrompt {

    static func ask(title: String, message: String, defaultValue: String, prompt: String) -> String? {
        let alert = NSAlert()
        alert.messageText = title
        alert.informativeText = message
        alert.addButton(withTitle: prompt)
        alert.addButton(withTitle: "Cancel")

        let field = NSTextField(frame: CGRect(x: 0, y: 0, width: 260, height: 24))
        field.stringValue = defaultValue
        field.placeholderString = "Profile name"
        alert.accessoryView = field
        // Without this the field is not first responder and the user has to click into it before typing.
        alert.window.initialFirstResponder = field

        guard alert.runModal() == .alertFirstButtonReturn else { return nil }
        let name = PrintProfileCatalog.normalized(field.stringValue)
        guard PrintProfileCatalog.isAcceptable(name) else { return nil }
        return name
    }
}
