// SPDX-FileCopyrightText: 2025 Ben Buhse <me@benbuhse.email>
//
// SPDX-License-Identifier: GPL-3.0-only

const Window = @This();

context: *Context,

river_window_v1: *river.WindowV1,
river_node_v1: *river.NodeV1,

app_id: ?[]const u8 = null,
title: ?[]const u8 = null,
parent: ?*river.WindowV1 = null,

rect: utils.Rect = .{},

fullscreen: bool = false,
maximized: bool = false,

tags: u32 = 0x0001,
output: ?*Output,

floating: bool = false,
floating_rect: utils.Rect = .{},
dimensions_hint: DimensionsHint = .{},

initialized: bool = false,

/// State consumed in manage() phase, reset at end of manage().
pending_manage: PendingManage = .{},

/// State consumed in render() phase, reset at end of render().
pending_render: PendingRender = .{},

/// Used to put Windows into a list when calculating the layout
active_list_node: DoublyLinkedList.Node = .{},

link: wl.list.Link,

pub const PendingManage = struct {
    dimensions: ?struct { width: u31, height: u31 } = null,

    fullscreen: ?bool = null,
    maximized: ?bool = null,

    tags: ?u32 = null,
    pending_output: ?PendingOutput = null,

    floating: ?bool = null,

    pub const PendingOutput = union(enum) {
        output: *Output,
        clear_output,
    };
};

const PendingRender = struct {
    position: ?struct { x: i32, y: i32 } = null,

    focused: ?bool = null,

    show: ?bool = null,

    // This could probably be a tagged union and just take *where* to place it (for above/below)
    place_top: bool = false,
};

const DimensionsHint = struct {
    min_width: u31 = 0,
    min_height: u31 = 0,
    max_width: u31 = 0,
    max_height: u31 = 0,

    fn clampWidth(hint: DimensionsHint, width: u31) u31 {
        return math.clamp(
            width,
            if (hint.min_width != 0) hint.min_width else 1,
            if (hint.max_width != 0) hint.max_width else math.maxInt(u31),
        );
    }

    fn clampHeight(hint: DimensionsHint, height: u31) u31 {
        return math.clamp(
            height,
            if (hint.min_height != 0) hint.min_height else 1,
            if (hint.max_height != 0) hint.max_height else math.maxInt(u31),
        );
    }
};

pub fn create(context: *Context, river_window_v1: *river.WindowV1, output: ?*Output) !*Window {
    var window = try utils.gpa.create(Window);
    errdefer utils.gpa.destroy(window);

    window.* = .{
        .context = context,
        .river_window_v1 = river_window_v1,
        .river_node_v1 = river_window_v1.getNode() catch @panic("Failed to get node"),
        .output = output,
        .tags = if (output) |o| o.tags else 0x0001,
        .link = undefined, // Handled by the wl.list
        // Ensure borders are applied on the first render cycle, even for windows that
        // are never explicitly told they are unfocused (e.g. on WM restart).
        .pending_render = .{ .focused = false },
    };

    window.river_window_v1.setListener(*Window, windowListener, window);

    return window;
}

pub fn destroy(window: *Window) void {
    if (window.app_id) |app_id| utils.gpa.free(app_id);
    if (window.title) |title| utils.gpa.free(title);

    window.river_node_v1.destroy();
    window.river_window_v1.destroy();

    utils.gpa.destroy(window);
}

fn windowListener(river_window_v1: *river.WindowV1, event: river.WindowV1.Event, window: *Window) void {
    assert(window.river_window_v1 == river_window_v1);
    switch (event) {
        .closed => {
            // Clear any pointer operations referencing this window
            var seat_it = window.context.wm.seats.iterator(.forward);
            while (seat_it.next()) |seat| {
                switch (seat.pointer_op) {
                    .move => |op| if (op.window == window) {
                        seat.river_seat_v1.opEnd();
                        seat.pointer_op = .none;
                    },
                    .resize => |op| if (op.window == window) {
                        seat.river_seat_v1.opEnd();
                        seat.pointer_op = .none;
                    },
                    .none => {},
                }
                if (seat.pending_manage.pointer_move_request == window)
                    seat.pending_manage.pointer_move_request = null;
                if (seat.pending_manage.pointer_resize_request) |req| {
                    if (req.window == window)
                        seat.pending_manage.pointer_resize_request = null;
                }
            }
            if (window.output) |output| {
                // Get a new window for the wm to focus
                var it = window.context.wm.seats.iterator(.forward);
                while (it.next()) |seat| {
                    if (seat.focused_window == window) {
                        // Find another window to focus and warp pointer there
                        if (output.nextWindow(window)) |next_focus| {
                            if (next_focus != window) {
                                seat.pending_manage.window = .{ .window = next_focus };
                                seat.pending_manage.should_warp_pointer = true;
                            } else {
                                // Only window in list - clear focus
                                seat.pending_manage.window = .clear_focus;
                            }
                        } else {
                            seat.pending_manage.window = .clear_focus;
                        }
                    }
                }
            }
            window.link.remove();
            window.destroy();
        },
        .dimensions => |ev| {
            // Protocol guarantees that width and height are strictly greater than zero
            assert(ev.width > 0 and ev.height > 0);
            window.pending_manage.dimensions = .{ .width = @intCast(ev.width), .height = @intCast(ev.height) };
        },
        .dimensions_hint => |ev| {
            window.dimensions_hint = .{
                .min_width = @intCast(ev.min_width),
                .min_height = @intCast(ev.min_height),
                .max_width = @intCast(ev.max_width),
                .max_height = @intCast(ev.max_height),
            };
        },
        .app_id => |ev| {
            if (window.app_id) |app_id| utils.gpa.free(app_id);
            window.app_id = if (ev.app_id) |aid|
                utils.gpa.dupe(u8, std.mem.span(aid)) catch @panic("Out of memory")
            else
                null;
        },
        .title => |ev| {
            if (window.title) |title| utils.gpa.free(title);
            window.title = if (ev.title) |t|
                utils.gpa.dupe(u8, std.mem.span(t)) catch @panic("Out of memory")
            else
                null;

            // Need to update the bar if this window is focused
            if (window.context.wm.seats.first()) |seat| {
                if (seat.focused_window) |focused_window| {
                    if (focused_window == window) {
                        if (window.output) |output| {
                            if (output.bar) |*bar| {
                                bar.pending_render.draw = true;
                            }
                        }
                    }
                }
            }
        },
        .parent => |ev| {
            // Nothing to do if ev.parent is null
            const parent = ev.parent orelse return;
            window.parent = parent;

            // Make window float on top of its parent
            window.pending_manage.floating = true;
            const parent_window: *Window = @ptrCast(@alignCast(parent.getUserData() orelse return));
            window.pending_render.position = .{
                .x = parent_window.rect.x + @divTrunc(parent_window.rect.width, 2),
                .y = parent_window.rect.y + @divTrunc(parent_window.rect.height, 2),
            };
        },
        .fullscreen_requested => |ev| {
            window.pending_manage.fullscreen = true;

            // This event allows the window to provide an output preference,
            // so we should move the window if it wants
            if (ev.output) |river_output_v1| {
                if (river_output_v1.getUserData()) |user_data| {
                    const output: *Output = @ptrCast(@alignCast(user_data));
                    // We have to remove window from current output's windows list first
                    window.link.remove();
                    output.windows.append(window);

                    window.pending_manage.pending_output = .{ .output = output };
                }
            }
        },
        .exit_fullscreen_requested => window.pending_manage.fullscreen = false,
        else => |ev| {
            log.debug("unhandled event: {s}", .{@tagName(ev)});
        },
    }
}

/// Apply one-time initialization for newly created windows.
/// Called before calculatePrimaryStackLayout() so that tag and float
/// rules are reflected in the first frame's layout.
pub fn initialize(window: *Window) void {
    if (window.initialized) {
        @branchHint(.unlikely);
        return;
    }
    window.initialized = true;

    const river_window_v1 = window.river_window_v1;

    // TODO: We might want to think about paying attention to the decoration_hint event
    //       If we do, this would need to move, I think?
    river_window_v1.useSsd();

    river_window_v1.setCapabilities(.{ .fullscreen = true, .maximize = true });
    river_window_v1.setTiled(.{ .top = true, .bottom = true, .left = true, .right = true });

    const res = window.applyRules();
    if (res.tags) |tags| window.tags = tags;
    if (res.float) |should_float|
        window.pending_manage.floating = should_float;
}

pub fn manage(window: *Window) void {
    // Updating state since the last manage event
    defer window.pending_manage = .{};
    const pending_manage = window.pending_manage;
    const river_window_v1 = window.river_window_v1;
    // Floating status
    var became_floating = false;
    if (pending_manage.floating) |floating| blk: {
        // This needs to be before proposing the new dimensions since we want to save the current ones!
        // Skip the rest of the block if floating matches what is already set
        if (floating == window.floating) break :blk;

        window.floating = floating;
        if (floating) {
            became_floating = true;
            // Let the window know it isn't tiled
            river_window_v1.setTiled(.{});

            if (window.floating_rect.width == 0) {
                // This window has never floated before, let's give it floating dimensions.
                // Use 75% of the output's usable size, clamped to the window's dimension hints.
                if (window.output) |output| {
                    window.floating_rect.width = window.dimensions_hint.clampWidth(@divFloor(output.usable_geometry.width * 3, 4));
                    window.floating_rect.height = window.dimensions_hint.clampHeight(@divFloor(output.usable_geometry.height * 3, 4));
                    window.floating_rect.x = output.usable_geometry.x + @divFloor(output.usable_geometry.width, 2) - @divFloor(window.floating_rect.width, 2);
                    window.floating_rect.y = output.usable_geometry.y + @divFloor(output.usable_geometry.height, 2) - @divFloor(window.floating_rect.height, 2);
                } else {
                    window.floating_rect.width = window.rect.width;
                    window.floating_rect.height = window.rect.height;
                }
            }
            river_window_v1.proposeDimensions(window.floating_rect.width, window.floating_rect.height);
            window.pending_render.position = .{
                .x = window.floating_rect.x,
                .y = window.floating_rect.y,
            };
        } else {
            river_window_v1.setTiled(.{ .top = true, .bottom = true, .left = true, .right = true });
            // Save floating dimensions in case the window gets floated again
            window.floating_rect.width = window.rect.width;
            window.floating_rect.height = window.rect.height;
            window.floating_rect.x = window.rect.x;
            window.floating_rect.y = window.rect.y;
        }
    }
    // Layout (pre-computed by WindowManager
    if (pending_manage.dimensions) |dimensions| {
        window.rect.width = dimensions.width;
        window.rect.height = dimensions.height;
        if (!became_floating) {
            // We want to skip this if the floating block above already proposed dimensions
            window.river_window_v1.proposeDimensions(dimensions.width, dimensions.height);
        }
    }
    // Fullscreen and maximize operations
    if (pending_manage.fullscreen) |fullscreen| blk: {
        window.fullscreen = fullscreen;
        if (fullscreen) {
            // If the window isn't on an output, just skip fullscreening
            const output = window.output orelse break :blk;
            window.river_window_v1.fullscreen(output.river_output_v1);
            window.river_window_v1.informFullscreen();
            // We need to do this so the window appears on top of the bar
            window.pending_render.place_top = true;
        } else {
            window.river_window_v1.exitFullscreen();
            window.river_window_v1.informNotFullscreen();
        }
    }
    if (pending_manage.maximized) |maximized| {
        window.maximized = maximized;
        if (maximized) {
            window.river_window_v1.informMaximized();
        } else {
            window.river_window_v1.informUnmaximized();
        }
    }
    // New tags
    if (pending_manage.tags) |tags| {
        window.tags = tags;
    }
    // New output
    if (pending_manage.pending_output) |pending_output| {
        switch (pending_output) {
            .output => |output| {
                window.output = output;
            },
            .clear_output => window.output = null,
        }
    }
}

pub fn render(window: *Window) void {
    defer window.pending_render = .{};

    if (window.pending_render.position) |position| {
        window.rect.x = position.x;
        window.rect.y = position.y;
        window.river_node_v1.setPosition(window.rect.x, window.rect.y);
    }

    // Set borders
    if (!window.fullscreen) {
        if (window.pending_render.focused) |focused| {
            if (focused) {
                window.applyBorders(window.context.config.border_color_focused);
            } else {
                window.applyBorders(window.context.config.border_color_unfocused);
            }
        }
    }

    // Show or hide the Window
    if (window.pending_render.show) |show| {
        if (show) {
            window.river_window_v1.show();
        } else {
            window.river_window_v1.hide();
        }
    }

    if (window.pending_render.place_top) {
        window.river_node_v1.placeTop();
    }
}

fn applyBorders(window: *Window, color: utils.RiverColor) void {
    const border_width = window.context.config.border_width;
    const all_sides = river.WindowV1.Edges{ .top = true, .bottom = true, .left = true, .right = true };
    window.river_window_v1.setBorders(all_sides, border_width, color.red, color.green, color.blue, color.alpha);
}

// Iterate over all window rules and apply any that match.
// Later rules in the list overwrite earlier ones.
fn applyRules(window: *Window) struct {
    float: ?bool = null,
    tags: ?u32 = null,
} {
    var float: ?bool = null;
    var tags: ?u32 = null;
    for (window.context.config.window_rules.items) |rule| {
        const app_id_matches = if (rule.app_id_glob) |glob|
            if (window.app_id) |app_id| globber.match(app_id, glob) else false
        else
            true;
        const title_matches = if (rule.title_glob) |glob|
            if (window.title) |title| globber.match(title, glob) else false
        else
            true;
        if (app_id_matches and title_matches) {
            switch (rule.action) {
                .float => |should_float| float = should_float,
                .tags => |tagmask| tags = tagmask,
            }
        }
    }

    return .{
        .float = float,
        .tags = tags,
    };
}

const std = @import("std");
const assert = std.debug.assert;
const math = std.math;
const DoublyLinkedList = std.DoublyLinkedList;

const wayland = @import("wayland");
const wl = wayland.client.wl;
const river = wayland.client.river;

const globber = @import("globber.zig");
const utils = @import("utils.zig");
const Context = @import("Context.zig");
const Output = @import("Output.zig");
const Seat = @import("Seat.zig");
const WindowRule = @import("Config.zig").WindowRule;

const log = std.log.scoped(.Window);
