const std = @import("std");
const builtin = @import("builtin");
const Io = std.Io;
const Environ = std.process.Environ;
const zigman = @import("root.zig");
const render = zigman.render;

const usage =
    \\zigman — the zig language reference in your terminal
    \\
    \\usage:
    \\  zigman <query>            show the matching section (paged on a terminal)
    \\  zigman -l [query]         list section names — all, or matching a query
    \\
    \\a query that names one section shows it; an ambiguous query lists the
    \\candidates. type any part of a section's name.
    \\
    \\examples:
    \\  zigman comptime           show the comptime section
    \\  zigman error              several match → lists them
    \\  zigman -o comptime        open the comptime section in your browser
    \\  zigman -l                 the whole table of contents
    \\  zigman -V 0.15.1 comptime show it from a specific langref version
    \\
    \\options:
    \\  -l, --list                list section names instead of rendering
    \\  -o, --open                open the section in your browser instead
    \\  -V, --version <ver>       langref version to read (default: master)
    \\  -r, --refresh             bypass the cache and refetch
    \\      --no-pager            don't page output on a terminal
    \\      --completions <shell> print a completion script (zsh|bash|fish)
    \\  -h, --help                show this help
    \\
    \\the reference is cached under $XDG_CACHE_HOME or ~/.cache/zigman.
    \\
;

const Args = struct {
    ver: []const u8 = "master",
    refresh: bool = false,
    no_pager: bool = false,
    list: bool = false,
    open: bool = false,
    help: bool = false,
    completions: ?[]const u8 = null,
    query: ?[]const u8 = null,
};

pub fn main(init: std.process.Init) !void {
    run(init) catch |e| switch (e) {
        // stdout/pager closed early (piping into `head`, quitting `less`)
        error.WriteFailed => {},
        else => return e,
    };
}

fn run(init: std.process.Init) !void {
    const a = init.gpa;
    const io = init.io;
    const env = init.environ_map.*;

    var out_buf: [16 * 1024]u8 = undefined;
    var fw = std.Io.File.stdout().writer(io, &out_buf);
    const out = &fw.interface;
    var ew = std.Io.File.stderr().writer(io, &.{});
    const err = &ew.interface;

    const args = parseArgs(init.minimal.args) catch {
        try err.writeAll(usage);
        try err.flush();
        std.process.exit(2);
    };

    if (args.help) {
        try out.writeAll(usage);
        try out.flush();
        return;
    }
    if (args.completions) |shell| {
        const script = completionScript(shell) orelse {
            try err.print("zigman: unknown shell '{s}' (want zsh, bash, or fish)\n", .{shell});
            try err.flush();
            std.process.exit(2);
        };
        try out.writeAll(script);
        try out.flush();
        return;
    }
    if (!zigman.validVersion(args.ver)) {
        try err.print("zigman: invalid version '{s}'\n", .{args.ver});
        try err.flush();
        std.process.exit(2);
    }

    // `--open` with no query opens the reference index — no fetch needed
    if (args.open and args.query == null) {
        try openInBrowser(a, io, env, err, args.ver, null);
        return;
    }

    // nothing to do without a query unless listing
    if (args.query == null and !args.list) {
        try out.writeAll(usage);
        try out.flush();
        return;
    }

    const dir = cacheDir(a, env) catch {
        try err.writeAll("zigman: neither $XDG_CACHE_HOME nor $HOME is set\n");
        try err.flush();
        std.process.exit(1);
    };
    defer a.free(dir);
    const html = loadLangref(a, io, dir, args.ver, args.refresh) catch |e| {
        try err.print("zigman: could not load langref ({t})\n", .{e});
        try err.flush();
        std.process.exit(1);
    };
    defer a.free(html);

    const want_pager = !args.no_pager and (std.Io.File.stdout().isTty(io) catch false);

    if (args.list and !args.open) {
        const text = try listText(a, html, args.query);
        defer a.free(text);
        try emit(io, out, env, text, want_pager);
        return;
    }

    // query mode: resolve to one section (then show or open it), or surface
    // the candidates. `--open` only changes what we do with a unique match.
    const query = args.query.?;
    if (exactId(html, query)) |id| {
        try act(a, io, out, env, err, html, id, args, want_pager);
        return;
    }
    // resolve a fuzzy query to its obvious primary section before listing
    if (zigman.primaryMatch(html, query)) |id| {
        try act(a, io, out, env, err, html, id, args, want_pager);
        return;
    }
    // a 1-2 char query that wasn't an exact section just substring-matches noise
    // (e.g. "Io" -> "Introduction"); treat as no-match so the scope hint fires.
    if (query.len < 3) try noMatch(err, query);
    // never dead-end a multi-word query: retry on individual words
    var matches = try collect(a, html, query);
    if (matches.len == 0) {
        a.free(matches);
        matches = try collectWords(a, html, query);
    }
    defer a.free(matches); // freed once, after matches settles on its final slice
    switch (matches.len) {
        0 => try noMatch(err, query),
        1 => try act(a, io, out, env, err, html, matches[0].id, args, want_pager),
        else => {
            const text = try formatMatches(a, matches);
            defer a.free(text);
            try emit(io, out, env, text, want_pager);
            try err.print("{d} sections match '{s}' — narrow it, or run `zigman <name>`\n", .{ matches.len, query });
            try err.flush();
        },
    }
}

/// No section matched. State what zigman *is* so a std-ish miss reads as out of
/// scope rather than a broken tool — but don't instruct how to use std/the rest
/// of the toolchain (that's the guide's job). Exits non-zero.
fn noMatch(err: *Io.Writer, query: []const u8) !noreturn {
    try err.print("zigman: no section matching '{s}' (the langref covers the Zig language, not std)\n", .{query});
    try err.flush();
    std.process.exit(1);
}

/// Apply the resolved section: open it in a browser, or render it.
fn act(a: std.mem.Allocator, io: Io, out: *Io.Writer, env: Environ.Map, err: *Io.Writer, html: []const u8, id: []const u8, args: Args, want_pager: bool) !void {
    if (args.open) {
        try openInBrowser(a, io, env, err, args.ver, id);
        return;
    }
    const sec = zigman.sliceSection(html, id) orelse return;
    const md = try render(a, sec);
    defer a.free(md);
    try emit(io, out, env, md, want_pager);
}

/// Launch the system browser at the langref, optionally at a section anchor.
/// Uses $BROWSER, else the platform opener (`open` / `xdg-open`).
fn openInBrowser(a: std.mem.Allocator, io: Io, env: Environ.Map, err: *Io.Writer, ver: []const u8, anchor: ?[]const u8) !void {
    const link = try zigman.url(a, ver, anchor);
    defer a.free(link);
    const opener = browserCmd(env);
    var child = std.process.spawn(io, .{
        .argv = &.{ opener, link },
        .stdin = .ignore,
        .stdout = .inherit,
        .stderr = .inherit,
    }) catch {
        // no browser launcher available — print the URL so it can be copied
        try err.print("zigman: open {s}\n", .{link});
        try err.flush();
        return;
    };
    _ = child.wait(io) catch {};
    try err.print("opening {s}\n", .{link});
    try err.flush();
}

fn browserCmd(env: Environ.Map) []const u8 {
    if (env.get("BROWSER")) |b| {
        if (b.len > 0) return b;
    }
    return switch (builtin.target.os.tag) {
        .macos => "open",
        .windows => "explorer",
        else => "xdg-open",
    };
}

// ----------------------------------------------------------------------------
// section matching
// ----------------------------------------------------------------------------

const Match = struct { level: u8, id: []const u8 };

fn exactId(html: []const u8, query: []const u8) ?[]const u8 {
    var it = zigman.Sections.init(html);
    while (it.next()) |s| {
        if (std.ascii.eqlIgnoreCase(s.id, query)) return s.id;
    }
    return null;
}

/// Collect sections whose anchor/title contains `query`. Caller owns the slice.
fn collect(a: std.mem.Allocator, html: []const u8, query: []const u8) ![]Match {
    var list: std.ArrayList(Match) = .empty;
    errdefer list.deinit(a);
    var it = zigman.Sections.init(html);
    while (it.next()) |s| {
        if (zigman.matchesQuery(s, query)) try list.append(a, .{ .level = s.level, .id = s.id });
    }
    return list.toOwnedSlice(a);
}

/// Fallback for a multi-word query that matched nothing as a phrase: collect
/// sections matching ANY word (length >= 2), so `labeled block` surfaces
/// Blocks + Labeled-switch/while/for instead of dead-ending. Caller owns slice.
fn collectWords(a: std.mem.Allocator, html: []const u8, query: []const u8) ![]Match {
    var list: std.ArrayList(Match) = .empty;
    errdefer list.deinit(a);
    var it = zigman.Sections.init(html);
    while (it.next()) |s| {
        var words = std.mem.tokenizeScalar(u8, query, ' ');
        const hit = while (words.next()) |w| {
            if (w.len >= 2 and zigman.matchesQuery(s, w)) break true;
        } else false;
        if (hit) try list.append(a, .{ .level = s.level, .id = s.id });
    }
    return list.toOwnedSlice(a);
}

fn formatMatches(a: std.mem.Allocator, matches: []const Match) ![]u8 {
    var buf: Io.Writer.Allocating = .init(a);
    defer buf.deinit();
    for (matches) |m| {
        try buf.writer.splatByteAll(' ', (m.level - 1) * 2);
        try buf.writer.print("{s}\n", .{m.id});
    }
    return a.dupe(u8, buf.written());
}

/// The full table of contents, or just the sections matching `query`.
fn listText(a: std.mem.Allocator, html: []const u8, query: ?[]const u8) ![]u8 {
    var buf: Io.Writer.Allocating = .init(a);
    defer buf.deinit();
    var it = zigman.Sections.init(html);
    while (it.next()) |s| {
        if (query) |q| {
            if (!zigman.matchesQuery(s, q)) continue;
        }
        try buf.writer.splatByteAll(' ', (s.level - 1) * 2);
        try buf.writer.print("{s}\n", .{s.id});
    }
    return a.dupe(u8, buf.written());
}

// ----------------------------------------------------------------------------
// output: page on a terminal, raw when piped
// ----------------------------------------------------------------------------

fn emit(io: Io, out: *Io.Writer, env: Environ.Map, text: []const u8, want_pager: bool) !void {
    if (want_pager and page(io, env, text)) return;
    try out.writeAll(text);
    try out.flush();
}

/// Pipe `text` through $PAGER (default `less -FRX`). Returns false if no pager
/// could be spawned, so the caller falls back to raw output. `less -F` means
/// short output prints and exits without trapping you in the pager.
fn page(io: Io, env: Environ.Map, text: []const u8) bool {
    const pager = blk: {
        if (env.get("PAGER")) |p| {
            if (p.len > 0) break :blk p;
        }
        break :blk "less -FRX";
    };
    var child = std.process.spawn(io, .{
        .argv = &.{ "sh", "-c", pager },
        .stdin = .pipe,
        .stdout = .inherit,
        .stderr = .inherit,
    }) catch return false;
    if (child.stdin) |sin| {
        var pbuf: [16 * 1024]u8 = undefined;
        var cw = sin.writer(io, &pbuf);
        cw.interface.writeAll(text) catch {}; // EPIPE if the user quits the pager early
        cw.interface.flush() catch {};
        sin.close(io);
        child.stdin = null;
    }
    _ = child.wait(io) catch {};
    return true;
}

// ----------------------------------------------------------------------------
// arg parsing
// ----------------------------------------------------------------------------

fn parseArgs(args: std.process.Args) !Args {
    var it = args.iterate();
    _ = it.skip(); // argv0
    var r: Args = .{};
    var have_query = false;
    while (it.next()) |arg| {
        if (eqAny(arg, "-h", "--help")) {
            r.help = true;
        } else if (eqAny(arg, "-V", "--version")) {
            r.ver = it.next() orelse return error.MissingValue;
        } else if (eqAny(arg, "-l", "--list")) {
            r.list = true;
        } else if (eqAny(arg, "-o", "--open")) {
            r.open = true;
        } else if (eqAny(arg, "-r", "--refresh")) {
            r.refresh = true;
        } else if (std.mem.eql(u8, arg, "--no-pager")) {
            r.no_pager = true;
        } else if (std.mem.eql(u8, arg, "--completions")) {
            r.completions = it.next() orelse return error.MissingValue;
        } else if (arg.len > 0 and arg[0] == '-') {
            return error.UnknownFlag;
        } else {
            if (have_query) return error.TooManyArgs;
            r.query = arg;
            have_query = true;
        }
    }
    return r;
}

fn completionScript(shell: []const u8) ?[]const u8 {
    if (std.mem.eql(u8, shell, "zsh")) return zsh_completion;
    if (std.mem.eql(u8, shell, "bash")) return bash_completion;
    if (std.mem.eql(u8, shell, "fish")) return fish_completion;
    return null;
}

// section names contain no spaces, so callers strip the list's indentation.
// ends with `compdef` so the script works when sourced (source <(zigman ...)).
const zsh_completion =
    \\#compdef zigman
    \\_zigman() {
    \\  local -a ids
    \\  ids=("${(@f)$(command zigman -l 2>/dev/null)}")
    \\  compadd -- ${(@)ids//[[:space:]]/}
    \\}
    \\compdef _zigman zigman
    \\
;

const bash_completion =
    \\_zigman() {
    \\  local cur="${COMP_WORDS[COMP_CWORD]}"
    \\  COMPREPLY=($(compgen -W "$(command zigman -l 2>/dev/null)" -- "$cur"))
    \\}
    \\complete -F _zigman zigman
    \\
;

const fish_completion =
    \\complete -c zigman -f -a "(command zigman -l 2>/dev/null | string trim)"
    \\
;

// ----------------------------------------------------------------------------
// langref loading: cache under $XDG_CACHE_HOME or ~/.cache/zigman
// ----------------------------------------------------------------------------

const day_ns: i96 = 24 * 60 * 60 * std.time.ns_per_s;

/// Resolve the zigman cache directory per the XDG Base Directory spec:
/// `$XDG_CACHE_HOME/zigman` when that variable is set to an absolute path,
/// otherwise `$HOME/.cache/zigman`. Caller owns the returned path.
fn cacheDir(a: std.mem.Allocator, env: Environ.Map) ![]u8 {
    if (env.get("XDG_CACHE_HOME")) |xdg| {
        if (xdg.len > 0 and xdg[0] == '/') return std.fmt.allocPrint(a, "{s}/zigman", .{xdg});
    }
    const home = env.get("HOME") orelse return error.NoCacheHome;
    return std.fmt.allocPrint(a, "{s}/.cache/zigman", .{home});
}

fn loadLangref(a: std.mem.Allocator, io: Io, dir: []const u8, ver: []const u8, refresh: bool) ![]u8 {
    const path = try std.fmt.allocPrint(a, "{s}/{s}.html", .{ dir, ver });
    defer a.free(path);

    const cwd = Io.Dir.cwd();
    if (!refresh and cacheFresh(io, cwd, path, ver)) {
        if (cwd.readFileAlloc(io, path, a, .unlimited)) |cached| {
            return cached;
        } else |_| {}
    }

    const url = try std.fmt.allocPrint(a, "https://ziglang.org/documentation/{s}/", .{ver});
    defer a.free(url);
    const html = try fetch(a, io, url);

    cwd.createDirPath(io, dir) catch {};
    cwd.writeFile(io, .{ .sub_path = path, .data = html }) catch {};
    return html;
}

/// pinned versions are immutable, so any cache hit is fresh; `master` moves,
/// so its cache is only fresh for a day.
fn cacheFresh(io: Io, cwd: Io.Dir, path: []const u8, ver: []const u8) bool {
    const st = cwd.statFile(io, path, .{}) catch return false;
    if (!std.mem.eql(u8, ver, "master")) return true;
    const now = Io.Timestamp.now(io, .real).nanoseconds;
    return now - st.mtime.nanoseconds < day_ns;
}

fn fetch(a: std.mem.Allocator, io: Io, url: []const u8) ![]u8 {
    var client: std.http.Client = .{ .allocator = a, .io = io };
    defer client.deinit();
    var body: Io.Writer.Allocating = .init(a);
    errdefer body.deinit();
    const res = try client.fetch(.{ .location = .{ .url = url }, .response_writer = &body.writer });
    if (res.status != .ok) return error.HttpStatus;
    return body.toOwnedSlice();
}

fn eqAny(arg: []const u8, short: []const u8, long: []const u8) bool {
    return std.mem.eql(u8, arg, short) or std.mem.eql(u8, arg, long);
}
