//! Native bridge handlers for YT Player.
//!
//! These run on the Zig side and are invoked from the web UI via
//! `window.zero.invoke(command, payload)`. They:
//!   * yt.search  — run yt-dlp search and return a slim result list
//!   * yt.streams — run yt-dlp on one video and return playable stream URLs
//!   * store.load — read the local playlists/history/settings JSON
//!   * store.save — write it back
//!
//! Handler signature follows the zero-native bridge contract:
//!   fn(context: *anyopaque, invocation: bridge.Invocation, output: []u8) anyerror![]const u8
//! The returned slice MUST point inside `output`. The bridge is size-limited,
//! so handlers parse yt-dlp's large JSON and emit only the compact fields the
//! UI needs.
//!
//! Targets Zig 0.14 std. If your installed zero-native exposes the Invocation
//! type under a different path, only the `payloadOf` helper and the handler
//! parameter type need adjusting — the logic below is self-contained.

const std = @import("std");
const zero_native = @import("zero_native");
const Invocation = zero_native.bridge.Invocation;

const MAX_OUTPUT = 4 * 1024 * 1024; // yt-dlp -J can be large; give it room.
const SEARCH_LIMIT = 25;

// ---------------------------------------------------------------------------
// Small JSON string escaper writing into any writer.
// ---------------------------------------------------------------------------
fn writeJsonString(w: anytype, s: []const u8) !void {
    try w.writeByte('"');
    for (s) |c| {
        switch (c) {
            '"' => try w.writeAll("\\\""),
            '\\' => try w.writeAll("\\\\"),
            '\n' => try w.writeAll("\\n"),
            '\r' => try w.writeAll("\\r"),
            '\t' => try w.writeAll("\\t"),
            0x00...0x08, 0x0b, 0x0c, 0x0e...0x1f => try w.print("\\u{x:0>4}", .{c}),
            else => try w.writeByte(c),
        }
    }
    try w.writeByte('"');
}

fn jsonStr(v: ?std.json.Value) []const u8 {
    if (v) |val| {
        return switch (val) {
            .string => |s| s,
            else => "",
        };
    }
    return "";
}

fn jsonNum(v: ?std.json.Value) f64 {
    if (v) |val| {
        return switch (val) {
            .integer => |i| @floatFromInt(i),
            .float => |f| f,
            .number_string => |s| std.fmt.parseFloat(f64, s) catch 0,
            else => 0,
        };
    }
    return 0;
}

// Channel display name, URL and id, falling back to the uploader_* variants.
fn pickChannel(obj: std.json.ObjectMap) []const u8 {
    const c = jsonStr(obj.get("channel"));
    return if (c.len > 0) c else jsonStr(obj.get("uploader"));
}
fn pickChannelUrl(obj: std.json.ObjectMap) []const u8 {
    const u = jsonStr(obj.get("channel_url"));
    return if (u.len > 0) u else jsonStr(obj.get("uploader_url"));
}
fn pickChannelId(obj: std.json.ObjectMap) []const u8 {
    const id = jsonStr(obj.get("channel_id"));
    return if (id.len > 0) id else jsonStr(obj.get("uploader_id"));
}

// Write one slim video card object into `w` from a flat-playlist record.
fn writeCard(w: anytype, a: std.mem.Allocator, obj: std.json.ObjectMap) !bool {
    const id = jsonStr(obj.get("id"));
    if (id.len == 0) return false;
    try w.writeAll("{\"id\":");
    try writeJsonString(w, id);
    try w.writeAll(",\"title\":");
    try writeJsonString(w, jsonStr(obj.get("title")));
    try w.writeAll(",\"channel\":");
    try writeJsonString(w, pickChannel(obj));
    try w.writeAll(",\"channelId\":");
    try writeJsonString(w, pickChannelId(obj));
    try w.writeAll(",\"channelUrl\":");
    try writeJsonString(w, pickChannelUrl(obj));
    try w.print(",\"duration\":{d}", .{jsonNum(obj.get("duration"))});
    try w.writeAll(",\"thumbnail\":");
    const thumb = try std.fmt.allocPrint(a, "https://i.ytimg.com/vi/{s}/mqdefault.jpg", .{id});
    try writeJsonString(w, thumb);
    try w.writeByte('}');
    return true;
}

// ---------------------------------------------------------------------------
// Locate the yt-dlp binary: prefer the bundled ./bin copy, fall back to PATH.
// ---------------------------------------------------------------------------
fn ytDlpPath(allocator: std.mem.Allocator) []const u8 {
    const candidates = [_][]const u8{ "bin/yt-dlp", "./bin/yt-dlp", "yt-dlp" };
    for (candidates) |c| {
        if (std.mem.eql(u8, c, "yt-dlp")) return c; // PATH fallback
        std.fs.cwd().access(c, .{}) catch continue;
        return allocator.dupe(u8, c) catch c;
    }
    return "yt-dlp";
}

fn runYtDlp(allocator: std.mem.Allocator, argv: []const []const u8) ![]const u8 {
    const result = try std.process.Child.run(.{
        .allocator = allocator,
        .argv = argv,
        .max_output_bytes = MAX_OUTPUT,
    });
    if (result.term != .Exited or result.term.Exited != 0) {
        // Surface yt-dlp's stderr to the caller.
        if (result.stderr.len > 0) return error.YtDlpFailed;
        return error.YtDlpFailed;
    }
    return result.stdout;
}

// ---------------------------------------------------------------------------
// Payload helpers — extract a string field from the invocation payload JSON.
// ---------------------------------------------------------------------------
fn payloadField(allocator: std.mem.Allocator, payload: []const u8, key: []const u8) !?[]const u8 {
    if (payload.len == 0) return null;
    var parsed = std.json.parseFromSlice(std.json.Value, allocator, payload, .{}) catch return null;
    defer parsed.deinit();
    if (parsed.value != .object) return null;
    const v = parsed.value.object.get(key) orelse return null;
    if (v != .string) return null;
    return try allocator.dupe(u8, v.string);
}

// ===========================================================================
// Handler: yt.search
// ===========================================================================
pub fn ytSearch(context: *anyopaque, invocation: Invocation, output: []u8) anyerror![]const u8 {
    _ = context;
    var arena = std.heap.ArenaAllocator.init(std.heap.page_allocator);
    defer arena.deinit();
    const a = arena.allocator();

    const query = (try payloadField(a, invocation.request.payload, "query")) orelse "";
    if (query.len == 0) return errorJson(output, "empty query");

    const exe = ytDlpPath(a);
    const search_arg = try std.fmt.allocPrint(a, "ytsearch{d}:{s}", .{ SEARCH_LIMIT, query });
    const argv = [_][]const u8{
        exe, search_arg, "--dump-json", "--flat-playlist", "--no-warnings", "--ignore-errors",
    };

    const out = runYtDlp(a, &argv) catch |e| {
        return errorJson(output, @errorName(e));
    };

    var fbs = std.io.fixedBufferStream(output);
    const w = fbs.writer();
    try w.writeAll("{\"ok\":true,\"results\":[");

    var first = true;
    var it = std.mem.splitScalar(u8, out, '\n');
    while (it.next()) |line| {
        const trimmed = std.mem.trim(u8, line, " \r\t");
        if (trimmed.len == 0) continue;
        var parsed = std.json.parseFromSlice(std.json.Value, a, trimmed, .{}) catch continue;
        defer parsed.deinit();
        if (parsed.value != .object) continue;
        const obj = parsed.value.object;

        if (jsonStr(obj.get("id")).len == 0) continue;

        if (!first) try w.writeByte(',');
        const wrote = try writeCard(w, a, obj);
        if (wrote) first = false;
    }

    try w.writeAll("]}");
    return fbs.getWritten();
}

// ===========================================================================
// Handler: yt.channel — list a channel's recent uploads
// ===========================================================================
pub fn ytChannel(context: *anyopaque, invocation: Invocation, output: []u8) anyerror![]const u8 {
    _ = context;
    var arena = std.heap.ArenaAllocator.init(std.heap.page_allocator);
    defer arena.deinit();
    const a = arena.allocator();

    const channel = (try payloadField(a, invocation.request.payload, "channel")) orelse "";
    if (channel.len == 0) return errorJson(output, "missing channel");

    // Resolve to a "/videos" tab URL whether we were handed a URL, @handle,
    // UC… id, or a bare name.
    var base: []const u8 = undefined;
    if (std.mem.startsWith(u8, channel, "http")) {
        base = std.mem.trimRight(u8, channel, "/");
    } else if (std.mem.startsWith(u8, channel, "@")) {
        base = try std.fmt.allocPrint(a, "https://www.youtube.com/{s}", .{channel});
    } else if (std.mem.startsWith(u8, channel, "UC")) {
        base = try std.fmt.allocPrint(a, "https://www.youtube.com/channel/{s}", .{channel});
    } else {
        base = try std.fmt.allocPrint(a, "https://www.youtube.com/@{s}", .{channel});
    }
    const url = if (std.mem.endsWith(u8, base, "/videos"))
        base
    else
        try std.fmt.allocPrint(a, "{s}/videos", .{base});

    const exe = ytDlpPath(a);
    const argv = [_][]const u8{
        exe, url, "--dump-json", "--flat-playlist", "--no-warnings", "--ignore-errors", "--playlist-end", "60",
    };

    const out = runYtDlp(a, &argv) catch |e| {
        return errorJson(output, @errorName(e));
    };

    var fbs = std.io.fixedBufferStream(output);
    const w = fbs.writer();

    // Two passes: first scan for the channel name/url, then stream the cards.
    var name: []const u8 = "";
    var chan_url: []const u8 = "";
    {
        var it = std.mem.splitScalar(u8, out, '\n');
        while (it.next()) |line| {
            const trimmed = std.mem.trim(u8, line, " \r\t");
            if (trimmed.len == 0) continue;
            var parsed = std.json.parseFromSlice(std.json.Value, a, trimmed, .{}) catch continue;
            defer parsed.deinit();
            if (parsed.value != .object) continue;
            const obj = parsed.value.object;
            if (name.len == 0) name = a.dupe(u8, pickChannel(obj)) catch "";
            if (chan_url.len == 0) chan_url = a.dupe(u8, pickChannelUrl(obj)) catch "";
            if (name.len > 0 and chan_url.len > 0) break;
        }
    }

    try w.writeAll("{\"ok\":true,\"channel\":");
    try writeJsonString(w, name);
    try w.writeAll(",\"channelUrl\":");
    try writeJsonString(w, chan_url);
    try w.writeAll(",\"results\":[");

    var first = true;
    var it = std.mem.splitScalar(u8, out, '\n');
    while (it.next()) |line| {
        const trimmed = std.mem.trim(u8, line, " \r\t");
        if (trimmed.len == 0) continue;
        var parsed = std.json.parseFromSlice(std.json.Value, a, trimmed, .{}) catch continue;
        defer parsed.deinit();
        if (parsed.value != .object) continue;
        const obj = parsed.value.object;
        if (jsonStr(obj.get("id")).len == 0) continue;
        if (!first) try w.writeByte(',');
        const wrote = try writeCard(w, a, obj);
        if (wrote) first = false;
    }

    try w.writeAll("]}");
    return fbs.getWritten();
}

// ===========================================================================
// Handler: yt.streams
// ===========================================================================
pub fn ytStreams(context: *anyopaque, invocation: Invocation, output: []u8) anyerror![]const u8 {
    _ = context;
    var arena = std.heap.ArenaAllocator.init(std.heap.page_allocator);
    defer arena.deinit();
    const a = arena.allocator();

    const video_id = (try payloadField(a, invocation.request.payload, "videoId")) orelse "";
    if (video_id.len == 0) return errorJson(output, "missing videoId");

    const exe = ytDlpPath(a);
    const url = try std.fmt.allocPrint(a, "https://www.youtube.com/watch?v={s}", .{video_id});
    const argv = [_][]const u8{ exe, "-J", "--no-warnings", url };

    const out = runYtDlp(a, &argv) catch |e| {
        return errorJson(output, @errorName(e));
    };

    var parsed = std.json.parseFromSlice(std.json.Value, a, out, .{}) catch {
        return errorJson(output, "parse error");
    };
    defer parsed.deinit();
    if (parsed.value != .object) return errorJson(output, "bad info json");
    const info = parsed.value.object;

    const title = jsonStr(info.get("title"));
    const channel = pickChannel(info);
    const channel_id = pickChannelId(info);
    const channel_url = pickChannelUrl(info);
    const duration = jsonNum(info.get("duration"));

    var fbs = std.io.fixedBufferStream(output);
    const w = fbs.writer();

    try w.writeAll("{\"ok\":true,\"data\":{\"meta\":{\"id\":");
    try writeJsonString(w, video_id);
    try w.writeAll(",\"title\":");
    try writeJsonString(w, title);
    try w.writeAll(",\"channel\":");
    try writeJsonString(w, channel);
    try w.writeAll(",\"channelId\":");
    try writeJsonString(w, channel_id);
    try w.writeAll(",\"channelUrl\":");
    try writeJsonString(w, channel_url);
    try w.print(",\"duration\":{d}", .{duration});
    try w.writeAll(",\"thumbnail\":");
    const thumb = try std.fmt.allocPrint(a, "https://i.ytimg.com/vi/{s}/hqdefault.jpg", .{video_id});
    try writeJsonString(w, thumb);
    try w.writeByte('}');

    // Walk formats. Track best audio, and emit video/progressive qualities.
    var best_audio_url: []const u8 = "";
    var best_audio_abr: f64 = -1;

    const formats = info.get("formats");
    if (formats) |fv| {
        if (fv == .array) {
            // First pass: best audio-only stream (prefer m4a/mp4a).
            for (fv.array.items) |item| {
                if (item != .object) continue;
                const f = item.object;
                const vcodec = jsonStr(f.get("vcodec"));
                const acodec = jsonStr(f.get("acodec"));
                const furl = jsonStr(f.get("url"));
                if (furl.len == 0) continue;
                const has_video = vcodec.len > 0 and !std.mem.eql(u8, vcodec, "none");
                const has_audio = acodec.len > 0 and !std.mem.eql(u8, acodec, "none");
                if (!has_video and has_audio) {
                    var score = jsonNum(f.get("abr"));
                    if (std.mem.indexOf(u8, acodec, "mp4a") != null) score += 1000;
                    if (score > best_audio_abr) {
                        best_audio_abr = score;
                        best_audio_url = furl;
                    }
                }
            }
        }
    }

    try w.writeAll(",\"audioUrl\":");
    if (best_audio_url.len > 0) try writeJsonString(w, best_audio_url) else try w.writeAll("null");

    try w.writeAll(",\"qualities\":[");
    var firstq = true;
    // Track which heights we've already emitted to dedupe.
    var seen = std.AutoHashMap(i64, void).init(a);

    if (formats) |fv| {
        if (fv == .array) {
            // Adaptive video-only, then progressive — both keyed by height.
            const passes = [_]bool{ false, true }; // false=video-only, true=progressive
            for (passes) |want_progressive| {
                for (fv.array.items) |item| {
                    if (item != .object) continue;
                    const f = item.object;
                    const vcodec = jsonStr(f.get("vcodec"));
                    const acodec = jsonStr(f.get("acodec"));
                    const furl = jsonStr(f.get("url"));
                    if (furl.len == 0) continue;
                    const has_video = vcodec.len > 0 and !std.mem.eql(u8, vcodec, "none");
                    const has_audio = acodec.len > 0 and !std.mem.eql(u8, acodec, "none");
                    if (!has_video) continue;
                    const is_progressive = has_audio;
                    if (is_progressive != want_progressive) continue;
                    const height: i64 = @intFromFloat(jsonNum(f.get("height")));
                    if (height <= 0) continue;
                    if (seen.contains(height)) continue;
                    seen.put(height, {}) catch {};

                    if (!firstq) try w.writeByte(',');
                    firstq = false;
                    try w.print("{{\"label\":\"{d}p\",\"height\":{d},\"hasAudio\":{s},\"url\":", .{
                        height, height, if (is_progressive) "true" else "false",
                    });
                    try writeJsonString(w, furl);
                    try w.writeAll(",\"ext\":");
                    try writeJsonString(w, jsonStr(f.get("ext")));
                    try w.writeByte('}');
                }
            }
        }
    }

    try w.writeAll("]}}");
    return fbs.getWritten();
}

// ===========================================================================
// Handlers: store.load / store.save  (local JSON in the app data dir)
// ===========================================================================
fn dataFilePath(a: std.mem.Allocator) ![]const u8 {
    const dir = std.fs.getAppDataDir(a, "ytplayer") catch ".";
    std.fs.cwd().makePath(dir) catch {};
    return std.fs.path.join(a, &.{ dir, "ytplayer-data.json" });
}

pub fn storeLoad(context: *anyopaque, invocation: Invocation, output: []u8) anyerror![]const u8 {
    _ = context;
    _ = invocation;
    var arena = std.heap.ArenaAllocator.init(std.heap.page_allocator);
    defer arena.deinit();
    const a = arena.allocator();

    const path = try dataFilePath(a);
    const file = std.fs.cwd().openFile(path, .{}) catch {
        const def = "{\"playlists\":[],\"history\":[],\"settings\":{\"quality\":\"auto\",\"volume\":1,\"audioOnly\":false}}";
        if (def.len > output.len) return error.NoSpaceLeft;
        @memcpy(output[0..def.len], def);
        return output[0..def.len];
    };
    defer file.close();
    const n = try file.readAll(output);
    return output[0..n];
}

pub fn storeSave(context: *anyopaque, invocation: Invocation, output: []u8) anyerror![]const u8 {
    _ = context;
    var arena = std.heap.ArenaAllocator.init(std.heap.page_allocator);
    defer arena.deinit();
    const a = arena.allocator();

    const blob = (try payloadField(a, invocation.request.payload, "data")) orelse {
        // payload.data may itself be an object; re-serialize the whole payload's "data".
        return errorJson(output, "missing data");
    };

    const path = try dataFilePath(a);
    const tmp = try std.fmt.allocPrint(a, "{s}.tmp", .{path});
    {
        const file = try std.fs.cwd().createFile(tmp, .{ .truncate = true });
        defer file.close();
        try file.writeAll(blob);
    }
    try std.fs.cwd().rename(tmp, path);

    const ok = "{\"ok\":true}";
    @memcpy(output[0..ok.len], ok);
    return output[0..ok.len];
}

fn errorJson(output: []u8, msg: []const u8) []const u8 {
    var fbs = std.io.fixedBufferStream(output);
    const w = fbs.writer();
    w.writeAll("{\"ok\":false,\"error\":") catch return "{\"ok\":false}";
    writeJsonString(w, msg) catch return "{\"ok\":false}";
    w.writeByte('}') catch return "{\"ok\":false}";
    return fbs.getWritten();
}
