//
// Async image cache with on-disk persistence.
//
// UI calls `image_request(item_id, size)` from the draw loop; it always
// returns an *Image immediately whose .loaded flips true asynchronously.
//
// Lookup order:
//   1. In-memory cache (this session)              — instant
//   2. ~/.cache/player/images/<size>_<id>.bin      — read on the main thread,
//                                                    rate-limited by image_pump
//   3. Jellyfin /Items/{id}/Images/Primary         — async http_submit, capped
//
// On a fresh first run the user gets the slow path once; from that point on
// every relaunch hits disk and the artist/album walls render instantly.
//
// Concurrency:
//   - HTTP fetches are capped (MAX_CONCURRENT_IMAGE_FETCHES) to keep the
//     thread count sane on a 500-album library.
//   - Disk hits are capped per frame (PROCESS_PER_FRAME) so first-frame
//     hydration of hundreds of thumbs spreads across ~10 frames instead of
//     blocking the UI for 400 ms.
//
// Decode + texture upload runs in the same place that consumed the bytes:
//   - Disk hit  → image_pump (main thread)
//   - HTTP hit  → on_image_response (already main-thread per jellyfin/async.jai)
// OpenGL stays single-threaded.
//

Image :: struct {
    texture: Simp.Texture;
    loaded:  bool;
    loading: bool;
    failed:  bool;
}

Image_Size :: enum {
    THUMB;    // 128 px square — for list rows
    LARGE;    // 512 px square — for now-playing
    BACKDROP; // 1920x1080 — full-screen artist backdrop
}

#scope_module

Image_Request :: struct {
    image: *Image;
    id:    string;
    size:  Image_Size;
}

image_cache:     Table(string, *Image);   // key = "<size>:<itemid>"
image_pending:   [..] *Image_Request;
image_in_flight: int;

MAX_CONCURRENT_IMAGE_FETCHES :: 4;
PROCESS_PER_FRAME            :: 16;

#scope_export

//
// Idempotent — safe to call from app_init or first image_request, called once
// from app_init for clarity.
//
image_cache_init :: () {
    dir := image_cache_dir();
    if !dir  return;
    make_directory_if_it_does_not_exist(dir, recursive=true);
}

image_request :: (item_id: string, size: Image_Size) -> *Image {
    key := image_cache_key(item_id, size);
    found, existing := table_find(*image_cache, key);
    if found {
        free(key);
        return existing;
    }

    img := New(Image);
    img.loading = true;
    table_set(*image_cache, key, img);

    req := New(Image_Request);
    req.image = img;
    req.id    = copy_string(item_id);
    req.size  = size;
    // Backdrop and large images are needed immediately for the now-playing
    // screen; insert at front so they don't queue behind hundreds of thumbs.
    if size == .BACKDROP || size == .LARGE {
        array_insert_at(*image_pending, req, 0);
    } else {
        array_add(*image_pending, req);
    }
    return img;
}

//
// Once per frame, before draw. Drains the pending queue: synchronous disk
// reads where we have a cached file, http_submit for the rest. Both paths are
// rate-limited so a 400-thumb cold-start doesn't stall the frame.
//
image_pump :: () {
    processed := 0;
    i := 0;
    while processed < PROCESS_PER_FRAME && i < image_pending.count {
        req := image_pending[i];
        disk_path := image_disk_path(req.id, req.size);
        if file_exists(disk_path) {
            consume_disk_hit(req, disk_path);
            free(req.id);
            free(req);
            array_ordered_remove_by_index(*image_pending, i);
            processed += 1;
            // Do NOT increment i — the array shifted.
        } else if image_in_flight < MAX_CONCURRENT_IMAGE_FETCHES {
            url := build_image_url(req.id, req.size);
            log_info("image: fetching % (size=%) url=%", req.id, req.size, url);
            http_submit("GET", url, on_done=on_image_response, user_data=req);
            image_in_flight += 1;
            array_ordered_remove_by_index(*image_pending, i);
            processed += 1;
        } else {
            // Out of HTTP slots, disk miss — leave for next frame.
            i += 1;
        }
    }
}

// Render image filling the rect with cover-fit: maintains aspect ratio,
// crops the excess. The center of the image is always centered in the rect.
draw_image_cover :: (x: float, y: float, w: float, h: float, img: *Image, tint := Vector4.{1,1,1,1}) {
    if !img || !img.loaded {
        Simp.set_shader_for_color();
        col := ifx img && img.failed then Vector4.{0.18, 0.04, 0.10, 1} else Vector4.{0.10, 0.05, 0.18, 1};
        Simp.immediate_quad(x, y, x + w, y + h, col);
        return;
    }
    iw := cast(float) img.texture.width;
    ih := cast(float) img.texture.height;
    if iw < 1 || ih < 1 { draw_image(x, y, w, h, img, tint); return; }

    scale := max(w / iw, h / ih);
    u_range := w / (iw * scale);
    v_range := h / (ih * scale);
    u0 := (1.0 - u_range) * 0.5;
    v0 := (1.0 - v_range) * 0.5;
    u1 := u0 + u_range;
    v1 := v0 + v_range;

    Simp.set_shader_for_images(*img.texture);
    Simp.immediate_quad(
        Vector2.{x,     y    },
        Vector2.{x + w, y    },
        Vector2.{x + w, y + h},
        Vector2.{x,     y + h},
        color = tint,
        uv0 = .{u0, v0}, uv1 = .{u1, v0},
        uv2 = .{u1, v1}, uv3 = .{u0, v1},
    );
}

draw_image :: (x: float, y: float, w: float, h: float, img: *Image, tint := Vector4.{1,1,1,1}) {
    if img && img.loaded {
        Simp.set_shader_for_images(*img.texture);
        Simp.immediate_quad(
            Vector2.{x,     y    },
            Vector2.{x + w, y    },
            Vector2.{x + w, y + h},
            Vector2.{x,     y + h},
            color = tint,
        );
    } else {
        Simp.set_shader_for_color();
        col: Vector4;
        if img && img.failed  col = .{0.18, 0.04, 0.10, 1};
        else                  col = .{0.10, 0.05, 0.18, 1};
        Simp.immediate_quad(x, y, x + w, y + h, col);
    }
}

#scope_file

build_image_url :: (item_id: string, size: Image_Size) -> string {
    if size == .BACKDROP {
        // Backdrops are 16:9, fetch at 1080p quality
        return tprint(
            "/Items/%/Images/Backdrop/0?fillWidth=1920&fillHeight=1080&quality=85",
            item_id,
        );
    } else {
        side := ifx size == .THUMB then 128 else 512;
        return tprint(
            "/Items/%/Images/Primary?fillHeight=%&fillWidth=%&quality=80",
            item_id, side, side,
        );
    }
}

image_cache_key :: (item_id: string, size: Image_Size) -> string {
    prefix: string;
    if size == {
        case .THUMB;    prefix = "t:";
        case .LARGE;    prefix = "l:";
        case .BACKDROP; prefix = "b:";
    }
    b: String_Builder;
    append(*b, prefix);
    append(*b, item_id);
    return builder_to_string(*b);
}

image_cache_dir :: () -> string {
    home, ok := get_home_directory();
    if !ok  return "";
    #if OS == .MACOS {
        return tprint("%/Library/Caches/player/images", home);
    } else {
        return tprint("%/.cache/player/images", home);
    }
}

image_disk_path :: (item_id: string, size: Image_Size) -> string {
    prefix: string;
    if size == {
        case .THUMB;    prefix = "t";
        case .LARGE;    prefix = "l";
        case .BACKDROP; prefix = "b";
    }
    // Jellyfin item ids are 32-char hex strings — safe as filenames as-is.
    return tprint("%/%_%.bin", image_cache_dir(), prefix, item_id);
}

consume_disk_hit :: (req: *Image_Request, disk_path: string) {
    img := req.image;
    img.loading = false;

    log_info("image: disk hit id=% size=%", req.id, req.size);

    bytes, ok := read_entire_file(disk_path, log_errors=false);
    if !ok {
        log_warn("image: disk read failed id=% size=%", req.id, req.size);
        img.failed = true;
        return;
    }
    defer free(bytes);

    if req.size == .LARGE  palette_extract(req.id, bytes);

    buf: [] u8 = ---;
    buf.data  = bytes.data;
    buf.count = bytes.count;
    if !Simp.texture_load_from_memory(*img.texture, buf) {
        // Cached file is corrupt (e.g. a Jellyfin error response saved as
        // bytes). Delete it so the next launch re-fetches.
        log_warn("image: corrupt disk cache id=% size=%, deleting %", req.id, req.size, disk_path);
        file_delete(disk_path);
        img.failed = true;
        return;
    }
    log_info("image: disk loaded id=% size=% %x%", req.id, req.size, img.texture.width, img.texture.height);
    img.loaded = true;
}

on_image_response :: (task: *Http_Task) {
    image_in_flight -= 1;

    req := cast(*Image_Request) task.user_data;
    defer { free(req.id); free(req); }

    img := req.image;
    img.loading = false;

    if !task.response.ok || task.response.body.count == 0 {
        log_warn("image: FAILED id=% size=% status=% body_len=%",
            req.id, req.size, task.response.status_code, task.response.body.count);
        img.failed = true;
        return;
    }

    // Write to disk first so even if the texture upload fails we keep the
    // bytes for next time. write_entire_file failures (out of space, perm)
    // are silent — display is still possible.
    disk_path := image_disk_path(req.id, req.size);
    write_entire_file(disk_path, task.response.body);

    if req.size == .LARGE  palette_extract(req.id, task.response.body);

    body_bytes: [] u8 = ---;
    body_bytes.data  = task.response.body.data;
    body_bytes.count = task.response.body.count;
    if !Simp.texture_load_from_memory(*img.texture, body_bytes) {
        log_warn("image: texture decode failed id=% size=% body_len=%", req.id, req.size, task.response.body.count);
        img.failed = true;
        return;
    }
    log_info("image: loaded id=% size=% %x% body_len=%",
        req.id, req.size, img.texture.width, img.texture.height, task.response.body.count);
    img.loaded = true;
}
