const std = @import("std");
const Allocator = std.mem.Allocator;
const oni = @import("oniguruma");
const configpkg = @import("../config.zig");
const inputpkg = @import("../input.zig");
const terminal = @import("../terminal/main.zig");
const point = terminal.point;
const Screen = terminal.Screen;
const Terminal = terminal.Terminal;

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

/// The link configuration needed for renderers.
pub const Link = struct {
    /// The regular expression to match the link against.
    regex: oni.Regex,

    /// The situations in which the link should be highlighted.
    highlight: inputpkg.Link.Highlight,

    pub fn deinit(self: *Link) void {
        self.regex.deinit();
    }
};

/// A set of links. This provides a higher level API for renderers
/// to match against a viewport and determine if cells are part of
/// a link.
pub const Set = struct {
    links: []Link,

    /// Returns the slice of links from the configuration.
    pub fn fromConfig(
        alloc: Allocator,
        config: []const inputpkg.Link,
    ) !Set {
        var links = std.ArrayList(Link).init(alloc);
        defer links.deinit();

        for (config) |link| {
            var regex = try link.oniRegex();
            errdefer regex.deinit();
            try links.append(.{
                .regex = regex,
                .highlight = link.highlight,
            });
        }

        return .{ .links = try links.toOwnedSlice() };
    }

    pub fn deinit(self: *Set, alloc: Allocator) void {
        for (self.links) |*link| link.deinit();
        alloc.free(self.links);
    }

    /// Returns the matchset for the viewport state. The matchset is the
    /// full set of matching links for the visible viewport. A link
    /// only matches if it is also in the correct state (i.e. hovered
    /// if necessary).
    ///
    /// This is not a particularly efficient operation. This should be
    /// called sparingly.
    pub fn matchSet(
        self: *const Set,
        alloc: Allocator,
        screen: *Screen,
        mouse_vp_pt: point.Coordinate,
        mouse_mods: inputpkg.Mods,
    ) !MatchSet {
        // Convert the viewport point to a screen point.
        const mouse_pin = screen.pages.pin(.{
            .viewport = mouse_vp_pt,
        }) orelse return .{};

        // This contains our list of matches. The matches are stored
        // as selections which contain the start and end points of
        // the match. There is no way to map these back to the link
        // configuration right now because we don't need to.
        var matches = std.ArrayList(terminal.Selection).init(alloc);
        defer matches.deinit();

        // If our mouse is over an OSC8 link, then we can skip the regex
        // matches below since OSC8 takes priority.
        try self.matchSetFromOSC8(
            alloc,
            &matches,
            screen,
            mouse_pin,
            mouse_mods,
        );

        // If we have no matches then we can try the regex matches.
        if (matches.items.len == 0) {
            try self.matchSetFromLinks(
                alloc,
                &matches,
                screen,
                mouse_pin,
                mouse_mods,
            );
        }

        return .{ .matches = try matches.toOwnedSlice() };
    }

    fn matchSetFromOSC8(
        self: *const Set,
        alloc: Allocator,
        matches: *std.ArrayList(terminal.Selection),
        screen: *Screen,
        mouse_pin: terminal.Pin,
        mouse_mods: inputpkg.Mods,
    ) !void {
        _ = alloc;

        // If the right mods aren't pressed, then we can't match.
        if (!mouse_mods.equal(inputpkg.ctrlOrSuper(.{}))) return;

        // Check if the cell the mouse is over is an OSC8 hyperlink
        const mouse_cell = mouse_pin.rowAndCell().cell;
        if (!mouse_cell.hyperlink) return;

        // Get our hyperlink entry
        const page: *terminal.Page = &mouse_pin.node.data;
        const link_id = page.lookupHyperlink(mouse_cell) orelse {
            log.warn("failed to find hyperlink for cell", .{});
            return;
        };
        const link = page.hyperlink_set.get(page.memory, link_id);

        // If our link has an implicit ID (no ID set explicitly via OSC8)
        // then we use an alternate matching technique that iterates forward
        // and backward until it finds boundaries.
        if (link.id == .implicit) {
            const uri = link.uri.offset.ptr(page.memory)[0..link.uri.len];
            return try self.matchSetFromOSC8Implicit(
                matches,
                mouse_pin,
                uri,
            );
        }

        // Go through every row and find matching hyperlinks for the given ID.
        // Note the link ID is not the same as the OSC8 ID parameter. But
        // we hash hyperlinks by their contents which should achieve the same
        // thing so we can use the ID as a key.
        var current: ?terminal.Selection = null;
        var row_it = screen.pages.getTopLeft(.viewport).rowIterator(.right_down, null);
        while (row_it.next()) |row_pin| {
            const row = row_pin.rowAndCell().row;

            // If the row doesn't have any hyperlinks then we're done
            // building our matching selection.
            if (!row.hyperlink) {
                if (current) |sel| {
                    try matches.append(sel);
                    current = null;
                }

                continue;
            }

            // We have hyperlinks, look for our own matching hyperlink.
            for (row_pin.cells(.right), 0..) |*cell, x| {
                const match = match: {
                    if (cell.hyperlink) {
                        if (row_pin.node.data.lookupHyperlink(cell)) |cell_link_id| {
                            break :match cell_link_id == link_id;
                        }
                    }
                    break :match false;
                };

                // If we have a match, extend our selection or start a new
                // selection.
                if (match) {
                    const cell_pin = row_pin.right(x);
                    if (current) |*sel| {
                        sel.endPtr().* = cell_pin;
                    } else {
                        current = terminal.Selection.init(
                            cell_pin,
                            cell_pin,
                            false,
                        );
                    }

                    continue;
                }

                // No match, if we have a current selection then complete it.
                if (current) |sel| {
                    try matches.append(sel);
                    current = null;
                }
            }
        }
    }

    /// Match OSC8 links around the mouse pin for an OSC8 link with an
    /// implicit ID. This only matches cells with the same URI directly
    /// around the mouse pin.
    fn matchSetFromOSC8Implicit(
        self: *const Set,
        matches: *std.ArrayList(terminal.Selection),
        mouse_pin: terminal.Pin,
        uri: []const u8,
    ) !void {
        _ = self;

        // Our selection starts with just our pin.
        var sel = terminal.Selection.init(mouse_pin, mouse_pin, false);

        // Expand it to the left.
        var it = mouse_pin.cellIterator(.left_up, null);
        while (it.next()) |cell_pin| {
            const page: *terminal.Page = &cell_pin.node.data;
            const rac = cell_pin.rowAndCell();
            const cell = rac.cell;

            // If this cell isn't a hyperlink then we've found a boundary
            if (!cell.hyperlink) break;

            const link_id = page.lookupHyperlink(cell) orelse {
                log.warn("failed to find hyperlink for cell", .{});
                break;
            };
            const link = page.hyperlink_set.get(page.memory, link_id);

            // If this link has an explicit ID then we found a boundary
            if (link.id != .implicit) break;

            // If this link has a different URI then we found a boundary
            const cell_uri = link.uri.offset.ptr(page.memory)[0..link.uri.len];
            if (!std.mem.eql(u8, uri, cell_uri)) break;

            sel.startPtr().* = cell_pin;
        }

        // Expand it to the right
        it = mouse_pin.cellIterator(.right_down, null);
        while (it.next()) |cell_pin| {
            const page: *terminal.Page = &cell_pin.node.data;
            const rac = cell_pin.rowAndCell();
            const cell = rac.cell;

            // If this cell isn't a hyperlink then we've found a boundary
            if (!cell.hyperlink) break;

            const link_id = page.lookupHyperlink(cell) orelse {
                log.warn("failed to find hyperlink for cell", .{});
                break;
            };
            const link = page.hyperlink_set.get(page.memory, link_id);

            // If this link has an explicit ID then we found a boundary
            if (link.id != .implicit) break;

            // If this link has a different URI then we found a boundary
            const cell_uri = link.uri.offset.ptr(page.memory)[0..link.uri.len];
            if (!std.mem.eql(u8, uri, cell_uri)) break;

            sel.endPtr().* = cell_pin;
        }

        try matches.append(sel);
    }

    /// Fills matches with the matches from regex link matches.
    fn matchSetFromLinks(
        self: *const Set,
        alloc: Allocator,
        matches: *std.ArrayList(terminal.Selection),
        screen: *Screen,
        mouse_pin: terminal.Pin,
        mouse_mods: inputpkg.Mods,
    ) !void {
        // Iterate over all the visible lines.
        var lineIter = screen.lineIterator(screen.pages.pin(.{
            .viewport = .{},
        }) orelse return);
        while (lineIter.next()) |line_sel| {
            const strmap: terminal.StringMap = strmap: {
                var strmap: terminal.StringMap = undefined;
                const str = screen.selectionString(alloc, .{
                    .sel = line_sel,
                    .trim = false,
                    .map = &strmap,
                }) catch |err| {
                    log.warn(
                        "failed to build string map for link checking err={}",
                        .{err},
                    );
                    continue;
                };
                alloc.free(str);
                break :strmap strmap;
            };
            defer strmap.deinit(alloc);

            // Go through each link and see if we have any matches.
            for (self.links) |link| {
                // Determine if our highlight conditions are met. We use a
                // switch here instead of an if so that we can get a compile
                // error if any other conditions are added.
                switch (link.highlight) {
                    .always => {},
                    .always_mods => |v| if (!mouse_mods.equal(v)) continue,
                    inline .hover, .hover_mods => |v, tag| {
                        if (!line_sel.contains(screen, mouse_pin)) continue;
                        if (comptime tag == .hover_mods) {
                            if (!mouse_mods.equal(v)) continue;
                        }
                    },
                }

                var it = strmap.searchIterator(link.regex);
                while (true) {
                    const match_ = it.next() catch |err| {
                        log.warn("failed to search for link err={}", .{err});
                        break;
                    };
                    var match = match_ orelse break;
                    defer match.deinit();
                    const sel = match.selection();

                    // If this is a highlight link then we only want to
                    // include matches that include our hover point.
                    switch (link.highlight) {
                        .always, .always_mods => {},
                        .hover,
                        .hover_mods,
                        => if (!sel.contains(screen, mouse_pin)) continue,
                    }

                    try matches.append(sel);
                }
            }
        }
    }
};

/// MatchSet is the result of matching links against a screen. This contains
/// all the matching links and operations on them such as whether a specific
/// cell is part of a matched link.
pub const MatchSet = struct {
    /// The matches.
    ///
    /// Important: this must be in left-to-right top-to-bottom order.
    matches: []const terminal.Selection = &.{},
    i: usize = 0,

    pub fn deinit(self: *MatchSet, alloc: Allocator) void {
        alloc.free(self.matches);
    }

    /// Checks if the matchset contains the given pin. This is slower than
    /// orderedContains but is stateless and more flexible since it doesn't
    /// require the points to be in order.
    pub fn contains(
        self: *MatchSet,
        screen: *const Screen,
        pin: terminal.Pin,
    ) bool {
        for (self.matches) |sel| {
            if (sel.contains(screen, pin)) return true;
        }

        return false;
    }

    /// Checks if the matchset contains the given pt. The points must be
    /// given in left-to-right top-to-bottom order. This is a stateful
    /// operation and giving a point out of order can cause invalid
    /// results.
    pub fn orderedContains(
        self: *MatchSet,
        screen: *const Screen,
        pin: terminal.Pin,
    ) bool {
        // If we're beyond the end of our possible matches, we're done.
        if (self.i >= self.matches.len) return false;

        // If our selection ends before the point, then no point will ever
        // again match this selection so we move on to the next one.
        while (self.matches[self.i].end().before(pin)) {
            self.i += 1;
            if (self.i >= self.matches.len) return false;
        }

        return self.matches[self.i].contains(screen, pin);
    }
};

test "matchset" {
    const testing = std.testing;
    const alloc = testing.allocator;

    // Initialize our screen
    var s = try Screen.init(alloc, 5, 5, 0);
    defer s.deinit();
    const str = "1ABCD2EFGH\n3IJKL";
    try s.testWriteString(str);

    // Get a set
    var set = try Set.fromConfig(alloc, &.{
        .{
            .regex = "AB",
            .action = .{ .open = {} },
            .highlight = .{ .always = {} },
        },

        .{
            .regex = "EF",
            .action = .{ .open = {} },
            .highlight = .{ .always = {} },
        },
    });
    defer set.deinit(alloc);

    // Get our matches
    var match = try set.matchSet(alloc, &s, .{}, .{});
    defer match.deinit(alloc);
    try testing.expectEqual(@as(usize, 2), match.matches.len);

    // Test our matches
    try testing.expect(!match.orderedContains(&s, s.pages.pin(.{ .screen = .{
        .x = 0,
        .y = 0,
    } }).?));
    try testing.expect(match.orderedContains(&s, s.pages.pin(.{ .screen = .{
        .x = 1,
        .y = 0,
    } }).?));
    try testing.expect(match.orderedContains(&s, s.pages.pin(.{ .screen = .{
        .x = 2,
        .y = 0,
    } }).?));
    try testing.expect(!match.orderedContains(&s, s.pages.pin(.{ .screen = .{
        .x = 3,
        .y = 0,
    } }).?));
    try testing.expect(match.orderedContains(&s, s.pages.pin(.{ .screen = .{
        .x = 1,
        .y = 1,
    } }).?));
    try testing.expect(!match.orderedContains(&s, s.pages.pin(.{ .screen = .{
        .x = 1,
        .y = 2,
    } }).?));
}

test "matchset hover links" {
    const testing = std.testing;
    const alloc = testing.allocator;

    // Initialize our screen
    var s = try Screen.init(alloc, 5, 5, 0);
    defer s.deinit();
    const str = "1ABCD2EFGH\n3IJKL";
    try s.testWriteString(str);

    // Get a set
    var set = try Set.fromConfig(alloc, &.{
        .{
            .regex = "AB",
            .action = .{ .open = {} },
            .highlight = .{ .hover = {} },
        },

        .{
            .regex = "EF",
            .action = .{ .open = {} },
            .highlight = .{ .always = {} },
        },
    });
    defer set.deinit(alloc);

    // Not hovering over the first link
    {
        var match = try set.matchSet(alloc, &s, .{}, .{});
        defer match.deinit(alloc);
        try testing.expectEqual(@as(usize, 1), match.matches.len);

        // Test our matches
        try testing.expect(!match.orderedContains(&s, s.pages.pin(.{ .screen = .{
            .x = 0,
            .y = 0,
        } }).?));
        try testing.expect(!match.orderedContains(&s, s.pages.pin(.{ .screen = .{
            .x = 1,
            .y = 0,
        } }).?));
        try testing.expect(!match.orderedContains(&s, s.pages.pin(.{ .screen = .{
            .x = 2,
            .y = 0,
        } }).?));
        try testing.expect(!match.orderedContains(&s, s.pages.pin(.{ .screen = .{
            .x = 3,
            .y = 0,
        } }).?));
        try testing.expect(match.orderedContains(&s, s.pages.pin(.{ .screen = .{
            .x = 1,
            .y = 1,
        } }).?));
        try testing.expect(!match.orderedContains(&s, s.pages.pin(.{ .screen = .{
            .x = 1,
            .y = 2,
        } }).?));
    }

    // Hovering over the first link
    {
        var match = try set.matchSet(alloc, &s, .{ .x = 1, .y = 0 }, .{});
        defer match.deinit(alloc);
        try testing.expectEqual(@as(usize, 2), match.matches.len);

        // Test our matches
        try testing.expect(!match.orderedContains(&s, s.pages.pin(.{ .screen = .{
            .x = 0,
            .y = 0,
        } }).?));
        try testing.expect(match.orderedContains(&s, s.pages.pin(.{ .screen = .{
            .x = 1,
            .y = 0,
        } }).?));
        try testing.expect(match.orderedContains(&s, s.pages.pin(.{ .screen = .{
            .x = 2,
            .y = 0,
        } }).?));
        try testing.expect(!match.orderedContains(&s, s.pages.pin(.{ .screen = .{
            .x = 3,
            .y = 0,
        } }).?));
        try testing.expect(match.orderedContains(&s, s.pages.pin(.{ .screen = .{
            .x = 1,
            .y = 1,
        } }).?));
        try testing.expect(!match.orderedContains(&s, s.pages.pin(.{ .screen = .{
            .x = 1,
            .y = 2,
        } }).?));
    }
}

test "matchset mods no match" {
    const testing = std.testing;
    const alloc = testing.allocator;

    // Initialize our screen
    var s = try Screen.init(alloc, 5, 5, 0);
    defer s.deinit();
    const str = "1ABCD2EFGH\n3IJKL";
    try s.testWriteString(str);

    // Get a set
    var set = try Set.fromConfig(alloc, &.{
        .{
            .regex = "AB",
            .action = .{ .open = {} },
            .highlight = .{ .always = {} },
        },

        .{
            .regex = "EF",
            .action = .{ .open = {} },
            .highlight = .{ .always_mods = .{ .ctrl = true } },
        },
    });
    defer set.deinit(alloc);

    // Get our matches
    var match = try set.matchSet(alloc, &s, .{}, .{});
    defer match.deinit(alloc);
    try testing.expectEqual(@as(usize, 1), match.matches.len);

    // Test our matches
    try testing.expect(!match.orderedContains(&s, s.pages.pin(.{ .screen = .{
        .x = 0,
        .y = 0,
    } }).?));
    try testing.expect(match.orderedContains(&s, s.pages.pin(.{ .screen = .{
        .x = 1,
        .y = 0,
    } }).?));
    try testing.expect(match.orderedContains(&s, s.pages.pin(.{ .screen = .{
        .x = 2,
        .y = 0,
    } }).?));
    try testing.expect(!match.orderedContains(&s, s.pages.pin(.{ .screen = .{
        .x = 3,
        .y = 0,
    } }).?));
    try testing.expect(!match.orderedContains(&s, s.pages.pin(.{ .screen = .{
        .x = 1,
        .y = 1,
    } }).?));
    try testing.expect(!match.orderedContains(&s, s.pages.pin(.{ .screen = .{
        .x = 1,
        .y = 2,
    } }).?));
}

test "matchset osc8" {
    const testing = std.testing;
    const alloc = testing.allocator;

    // Initialize our terminal
    var t = try Terminal.init(alloc, .{ .cols = 10, .rows = 10 });
    defer t.deinit(alloc);
    const s = &t.screen;

    try t.printString("ABC");
    try t.screen.startHyperlink("http://example.com", null);
    try t.printString("123");
    t.screen.endHyperlink();

    // Get a set
    var set = try Set.fromConfig(alloc, &.{});
    defer set.deinit(alloc);

    // No matches over the non-link
    {
        var match = try set.matchSet(
            alloc,
            &t.screen,
            .{ .x = 2, .y = 0 },
            inputpkg.ctrlOrSuper(.{}),
        );
        defer match.deinit(alloc);
        try testing.expectEqual(@as(usize, 0), match.matches.len);
    }

    // Match over link
    var match = try set.matchSet(
        alloc,
        &t.screen,
        .{ .x = 3, .y = 0 },
        inputpkg.ctrlOrSuper(.{}),
    );
    defer match.deinit(alloc);
    try testing.expectEqual(@as(usize, 1), match.matches.len);

    // Test our matches
    try testing.expect(!match.orderedContains(s, s.pages.pin(.{ .screen = .{
        .x = 2,
        .y = 0,
    } }).?));
    try testing.expect(match.orderedContains(s, s.pages.pin(.{ .screen = .{
        .x = 3,
        .y = 0,
    } }).?));
    try testing.expect(match.orderedContains(s, s.pages.pin(.{ .screen = .{
        .x = 4,
        .y = 0,
    } }).?));
    try testing.expect(match.orderedContains(s, s.pages.pin(.{ .screen = .{
        .x = 5,
        .y = 0,
    } }).?));
    try testing.expect(!match.orderedContains(s, s.pages.pin(.{ .screen = .{
        .x = 6,
        .y = 0,
    } }).?));
}
