#!/usr/bin/env nu

# AI Image Generation Pipeline
#
# Discovers content items missing images, constructs typed prompts via Tera
# templates, calls DALL-E 3, presents each image for human approval via
# typedialog, injects approved paths into both metadata files (.md + .ncl),
# and publishes a NATS event to trigger the content sync pipeline.
#
# Two-phase execution:
#   Phase 1 — generate: DALL-E 3 calls for all discovered items → images/pending/
#   Phase 2 — review:   typedialog batch approval → finalize approved items
#
# Prerequisites (runtime):
#   - nickel CLI (`nickel export`) for config loading
#   - tera nu plugin for prompt rendering
#   - typedialog CLI (`^typedialog select` or `^typedialog-tui`) for approval UI
#   - nats CLI (`^nats pub`) for event publishing
#
# Usage:
#   nu generate-images.nu [flags]
#
# Environment:
#   OPENAI_API_KEY     — required; DALL-E 3 authentication
#   NATS_NAMESPACE     — NATS subject prefix (default: "rustelo")
#   SITE_CONTENT_PATH  — overrides --content-dir
#   SITE_PUBLIC_PATH   — overrides --public-dir

source lib/frontmatter.nu

# ---------------------------------------------------------------------------
# Price table — updated 2025-05. Source: platform.openai.com/docs/pricing
# Keys: "{model}:{quality}:{size}"
# ---------------------------------------------------------------------------

def image_price_table [] {
    {
        # dall-e-3
        "dall-e-3:standard:1024x1024":  0.040,
        "dall-e-3:standard:1024x1792":  0.080,
        "dall-e-3:standard:1792x1024":  0.080,
        "dall-e-3:hd:1024x1024":        0.080,
        "dall-e-3:hd:1024x1792":        0.120,
        "dall-e-3:hd:1792x1024":        0.120,
        # gpt-image-1 (flat per-image equivalents from official pricing)
        "gpt-image-1:low:1024x1024":    0.011,
        "gpt-image-1:low:1024x1792":    0.016,
        "gpt-image-1:low:1792x1024":    0.016,
        "gpt-image-1:medium:1024x1024": 0.042,
        "gpt-image-1:medium:1024x1792": 0.063,
        "gpt-image-1:medium:1792x1024": 0.063,
        "gpt-image-1:high:1024x1024":   0.167,
        "gpt-image-1:high:1024x1792":   0.250,
        "gpt-image-1:high:1792x1024":   0.250,
    }
}

def cost_per_image [model: string, quality: string, size: string] {
    let key = $"($model):($quality):($size)"
    let table = image_price_table
    $table | get --optional $key | default 0.0
}

# For gpt-image-1: compute actual cost from the usage field returned in the API response.
# Token prices are far more stable than per-image equivalents.
# Source: platform.openai.com/docs/pricing (updated 2025-05)
def cost_from_usage [usage: record] {
    let input_price_per_token  = 0.000005    # $5.00  / 1M input tokens
    let output_price_per_token = 0.00004     # $40.00 / 1M output tokens
    let input_tokens  = $usage | get --optional input_tokens  | default 0
    let output_tokens = $usage | get --optional output_tokens | default 0
    ($input_tokens * $input_price_per_token) + ($output_tokens * $output_price_per_token)
}

def append_spend_log [log_path: string, entry: record] {
    $entry | to json --raw | save --append $log_path
    "\n" | save --append $log_path
}

# ---------------------------------------------------------------------------
# Entry point
# ---------------------------------------------------------------------------

def main [
    --content-dir: string = ""    # Content root (default: $SITE_CONTENT_PATH or site/content)
    --public-dir:  string = ""    # Public assets root (default: $SITE_PUBLIC_PATH or site/public)
    --config:      string = ""    # Nickel config path (default: site/config/image-generation.ncl)
    --type:        string = ""    # Filter: blog | recipes | projects | activities
    --lang:        string = ""    # Filter: en | es | ...
    --category:    string = ""    # Filter: specific category within type
    --slug:        string = ""    # Target one specific item
    --image-type:  string = "both" # thumbnail | feature | both
    --budget:          float = 0.0  # Abort if estimated cost exceeds this (USD). 0 = no limit
    --spend-log:       string = ""  # Path to JSONL spend log (default: scripts/content/.image-spend.jsonl)
    --propagate-langs               # Write the approved path to all other language variants
    --insert-hero                   # Insert hero image as first paragraph in the markdown body
    --force                         # Regenerate even if thumbnail already set
    --dry-run                       # Print prompts; no API calls, no file writes
    --no-nats                       # Skip NATS publish after approval
    --verbose                       # Per-item detail
] {
    let content_root = resolve_env "SITE_CONTENT_PATH" $content_dir "site/content"
    let public_root  = resolve_env "SITE_PUBLIC_PATH"  $public_dir  "site/public"
    let config_path  = if $config != "" { $config } else { "site/config/image-generation.ncl" }

    if not ($content_root | path exists) {
        error make { msg: $"Content directory not found: ($content_root)" }
    }
    if not ($config_path | path exists) {
        error make { msg: $"Config file not found: ($config_path)" }
    }

    let api_key  = resolve_env "OPENAI_API_KEY" "" ""
    if not $dry_run and ($api_key | is-empty) {
        error make { msg: "OPENAI_API_KEY is not set. Export it or add to .env." }
    }

    let log_path = if $spend_log != "" {
        $spend_log
    } else {
        "scripts/content/.image-spend.jsonl"
    }

    print $"(ansi cyan)AI Image Generation Pipeline(ansi reset)"
    print $"  content : ($content_root)"
    print $"  config  : ($config_path)"
    if $dry_run { print $"(ansi yellow)[dry-run] No API calls or file writes(ansi reset)" }

    let cfg = load_config $config_path

    let image_types = match $image_type {
        "thumbnail" => ["thumbnail"],
        "feature"   => ["feature"],
        _           => ["thumbnail", "feature"],
    }

    let items = discover_items $content_root $type $lang $category $slug

    if ($items | is-empty) {
        print "No content items found matching the given filters."
        return
    }

    let candidates = if $force {
        $items
    } else {
        $items | where { |it| not $it.thumbnail_set }
    }

    if ($candidates | is-empty) {
        print "All matching items already have images set. Use --force to regenerate."
        return
    }

    print $"  found   : ($candidates | length) items needing images"

    # --- Pre-flight cost estimate ---
    let n_calls   = ($candidates | length) * ($image_types | length)
    let unit_cost = $image_types | each { |t|
        let size = if $t == "thumbnail" { $cfg.sizes.thumbnail } else { $cfg.sizes.feature }
        cost_per_image $cfg.model $cfg.quality $size
    } | math sum
    let est_total = ($candidates | length) * $unit_cost

    let price_known = $unit_cost > 0.0
    if $price_known {
        print $"  model   : ($cfg.model) / ($cfg.quality)"
        print $"  calls   : ($n_calls)   est. cost: $($est_total | math round --precision 3) USD"
    } else {
        print $"  model   : ($cfg.model) / ($cfg.quality)  [price unknown — not in table]"
    }

    if not $dry_run and $budget > 0.0 and $price_known and $est_total > $budget {
        error make { msg: $"Estimated cost $($est_total | math round --precision 3) exceeds --budget $($budget). Aborting." }
    }

    if $dry_run {
        print ""
        print $"(ansi cyan_bold)Dry-run prompts:(ansi reset)"
        for item in $candidates {
            for img_type in $image_types {
                let prompt = build_prompt $item $cfg $item.ct $img_type
                print $"(ansi green)── ($item.ct)/($item.lang)/($item.cat)/($item.slug) [($img_type)](ansi reset)"
                print $prompt
                print ""
            }
        }
        return
    }

    # --- Phase 1: Generate all images ---

    print ""
    print $"(ansi cyan_bold)Phase 1: Generating images...(ansi reset)"

    mut pending = []
    mut gen_errors = 0

    for item in $candidates {
        for img_type in $image_types {
            let prompt   = build_prompt $item $cfg $item.ct $img_type
            let size     = if $img_type == "thumbnail" { $cfg.sizes.thumbnail } else { $cfg.sizes.feature }
            let ts       = date now | format date "%Y%m%d%H%M%S"
            let pending_dir  = $"($item.images_dir)/pending"
            let pending_name = $"($item.slug)_($img_type)_($ts).png"
            let pending_path = $"($pending_dir)/($pending_name)"

            if $verbose {
                print $"  generating ($item.ct)/($item.lang)/($item.cat)/($item.slug) [($img_type)]"
            }

            mkdir $pending_dir

            let gen_result = call_dalle $prompt $cfg.model $size $cfg.quality $pending_path $api_key
            if $gen_result.err != null {
                print $"  (ansi red)ERROR(ansi reset) ($item.slug) [($img_type)]: ($gen_result.err)"
                $gen_errors = $gen_errors + 1
                append_spend_log $log_path {
                    ts:          (date now | format date "%Y-%m-%dT%H:%M:%SZ"),
                    model:       $cfg.model,
                    quality:     $cfg.quality,
                    size:        $size,
                    ct:          $item.ct,
                    lang:        $item.lang,
                    slug:        $item.slug,
                    img_type:    $img_type,
                    cost_usd:    0.0,
                    cost_source: "none",
                    status:      "error",
                    error:       $gen_result.err,
                }
                continue
            }

            # Prefer actual cost from usage tokens (gpt-image-1); fall back to price table (dall-e-3)
            let img_cost   = $gen_result.actual_cost | default (cost_per_image $cfg.model $cfg.quality $size)
            let cost_src   = if ($gen_result.actual_cost | is-empty) { "table" } else { "usage" }
            append_spend_log $log_path {
                ts:          (date now | format date "%Y-%m-%dT%H:%M:%SZ"),
                model:       $cfg.model,
                quality:     $cfg.quality,
                size:        $size,
                ct:          $item.ct,
                lang:        $item.lang,
                slug:        $item.slug,
                img_type:    $img_type,
                cost_usd:    $img_cost,
                cost_source: $cost_src,
                status:      "generated",
                error:       null,
            }

            if $verbose { print $"  (ansi green)saved(ansi reset) → ($pending_path)  cost: $$($img_cost) [($cost_src)]" }
            $pending = ($pending | append {
                item:         $item,
                img_type:     $img_type,
                pending_path: $pending_path,
                prompt:       $prompt,
                model:        $cfg.model,
                quality:      $cfg.quality,
                size:         $size,
                cost_usd:     $img_cost,
                cost_source:  $cost_src,
            })
        }
    }

    if ($pending | is-empty) {
        print $"(ansi yellow)No images generated — ($gen_errors) errors.(ansi reset)"
        exit 1
    }

    print $"  generated: ($pending | length) images  errors: ($gen_errors)"

    # --- Phase 2: Batch review loop ---

    print ""
    print $"(ansi cyan_bold)Phase 2: Review and approve...(ansi reset)"

    mut approved  = 0
    mut skipped   = 0
    mut to_review = $pending

    loop {
        if ($to_review | is-empty) { break }

        let decisions = run_review_batch $to_review

        mut regen_batch = []

        for d in $decisions {
            match $d.decision {
                "approve" => {
                    finalize_image $d.entry $content_root $no_nats $propagate_langs $insert_hero
                    $approved = $approved + 1
                },
                "skip" => {
                    rm --force $d.entry.pending_path
                    $skipped = $skipped + 1
                    if $verbose { print $"  skip ($d.entry.item.slug) [($d.entry.img_type)]" }
                },
                "regenerate" => {
                    rm --force $d.entry.pending_path

                    let size = if $d.entry.img_type == "thumbnail" { $cfg.sizes.thumbnail } else { $cfg.sizes.feature }
                    let ts   = date now | format date "%Y%m%d%H%M%S"
                    let new_pending_path = $"($d.entry.item.images_dir)/pending/($d.entry.item.slug)_($d.entry.img_type)_($ts).png"

                    let gen_result = call_dalle $d.entry.prompt $cfg.model $size $cfg.quality $new_pending_path $api_key
                    if $gen_result.err != null {
                        print $"  (ansi red)REGEN ERROR(ansi reset) ($d.entry.item.slug): ($gen_result.err)"
                        $skipped = $skipped + 1
                        append_spend_log $log_path {
                            ts:          (date now | format date "%Y-%m-%dT%H:%M:%SZ"),
                            model:       $cfg.model,
                            quality:     $cfg.quality,
                            size:        $size,
                            ct:          $d.entry.item.ct,
                            lang:        $d.entry.item.lang,
                            slug:        $d.entry.item.slug,
                            img_type:    $d.entry.img_type,
                            cost_usd:    0.0,
                            cost_source: "none",
                            status:      "regen-error",
                            error:       $gen_result.err,
                        }
                        continue
                    }

                    let regen_cost = $gen_result.actual_cost | default (cost_per_image $cfg.model $cfg.quality $size)
                    let regen_src  = if ($gen_result.actual_cost | is-empty) { "table" } else { "usage" }
                    append_spend_log $log_path {
                        ts:          (date now | format date "%Y-%m-%dT%H:%M:%SZ"),
                        model:       $cfg.model,
                        quality:     $cfg.quality,
                        size:        $size,
                        ct:          $d.entry.item.ct,
                        lang:        $d.entry.item.lang,
                        slug:        $d.entry.item.slug,
                        img_type:    $d.entry.img_type,
                        cost_usd:    $regen_cost,
                        cost_source: $regen_src,
                        status:      "regenerated",
                        error:       null,
                    }

                    $regen_batch = ($regen_batch | append ($d.entry | merge { pending_path: $new_pending_path, cost_usd: $regen_cost, cost_source: $regen_src }))
                },
                _ => {
                    rm --force $d.entry.pending_path
                    $skipped = $skipped + 1
                },
            }
        }

        $to_review = $regen_batch
    }

    # --- Spend summary ---
    let session_spend = if ($log_path | path exists) {
        open $log_path
            | lines
            | where { |l| not ($l | is-empty) }
            | each { |l| $l | from json }
            | where status == "generated" or status == "regenerated"
            | get cost_usd
            | math sum
    } else {
        0.0
    }

    print ""
    print $"(ansi green)Done(ansi reset) — approved: ($approved)  skipped: ($skipped)  gen-errors: ($gen_errors)"
    if $price_known {
        print $"  session spend: $$($session_spend | math round --precision 4) USD"
        print $"  spend log:     ($log_path)"
    }
    if $gen_errors > 0 { exit 1 }
}

# ---------------------------------------------------------------------------
# Config
# ---------------------------------------------------------------------------

def load_config [config_path: string] {
    # nickel-export requires an absolute path — plugins resolve relative to their own cwd
    nickel-export ($config_path | path expand) | get image_generation
}

# ---------------------------------------------------------------------------
# Discovery
# ---------------------------------------------------------------------------

def discover_items [
    content_root: string,
    type_filter:  string,
    lang_filter:  string,
    cat_filter:   string,
    slug_filter:  string,
] {
    let types = discover_dirs $content_root $type_filter
    mut items = []

    for ct in $types {
        let langs = discover_dirs $"($content_root)/($ct)" $lang_filter
        for language in $langs {
            let lang_dir = $"($content_root)/($ct)/($language)"
            let subdirs  = discover_dirs $lang_dir ""

            for sub in $subdirs {
                let sub_dir = $"($lang_dir)/($sub)"

                # Page-bundle flat layout (projects): lang_dir/slug/index.md
                if ($"($sub_dir)/index.md" | path exists) {
                    if $cat_filter != "" { continue }
                    let found = collect_bundle_item $sub_dir $ct $language "" $sub $slug_filter $content_root
                    $items = ($items | append $found)
                } else {
                    # Category directory: may contain page-bundles or flat .md files
                    if $cat_filter != "" and $sub != $cat_filter { continue }

                    # Page-bundle category layout: lang_dir/cat/slug/index.md
                    let slug_dirs = discover_dirs $sub_dir ""
                    for slug in $slug_dirs {
                        let slug_dir = $"($sub_dir)/($slug)"
                        let found = collect_bundle_item $slug_dir $ct $language $sub $slug $slug_filter $content_root
                        $items = ($items | append $found)
                    }

                    # Flat-file layout: lang_dir/cat/{slug}.md (blog posts)
                    let flat_items = collect_flat_items $sub_dir $ct $language $sub $slug_filter $content_root
                    $items = ($items | append $flat_items)
                }
            }
        }
    }

    $items
}

# Collect one page-bundle item (requires slug/index.md); return [] if absent
def collect_bundle_item [
    item_dir:     string,
    ct:           string,
    lang:         string,
    cat:          string,
    slug:         string,
    slug_filter:  string,
    content_root: string,
] {
    if not ($"($item_dir)/index.md" | path exists) { return [] }
    if $slug_filter != "" and $slug != $slug_filter { return [] }

    let md_path    = $"($item_dir)/index.md"
    let ncl_path   = $"($item_dir)/index.ncl"
    # Images stored language-neutral: {ct}/_images/{cat}/{slug}/
    let images_dir = if $cat == "" {
        $"($content_root)/($ct)/_images/($slug)"
    } else {
        $"($content_root)/($ct)/_images/($cat)/($slug)"
    }

    let fm    = extract_frontmatter $md_path
    let title = extract_from_frontmatter $fm "title"   "Untitled"
    let excerpt   = extract_from_frontmatter $fm "excerpt"  ""
    let subtitle  = extract_from_frontmatter $fm "subtitle" ""
    let category  = extract_from_frontmatter $fm "category" $cat
    let tags_raw  = extract_from_frontmatter $fm "tags"     ""
    let tags      = parse_tags $tags_raw
    let event_type = extract_from_frontmatter $fm "event_type" "talk"

    # Check thumbnail set in both .md and .ncl
    let md_thumb  = extract_from_frontmatter $fm "thumbnail" ""
    let ncl_thumb = if ($ncl_path | path exists) {
        extract_ncl_field $ncl_path "thumbnail"
    } else { "" }
    let thumbnail_set = (not ($md_thumb | is-empty)) and (not ($ncl_thumb | is-empty))

    [{
        ct:            $ct,
        lang:          $lang,
        cat:           $cat,
        slug:          $slug,
        md_path:       $md_path,
        ncl_path:      $ncl_path,
        images_dir:    $images_dir,
        title:         $title,
        excerpt:       $excerpt,
        subtitle:      $subtitle,
        category:      $category,
        tags:          $tags,
        event_type:    $event_type,
        thumbnail_set: $thumbnail_set,
    }]
}

# Collect flat .md items from a category dir (blog pattern: cat/{slug}.md)
def collect_flat_items [
    cat_dir:      string,
    ct:           string,
    lang:         string,
    cat:          string,
    slug_filter:  string,
    content_root: string,
] {
    if not ($cat_dir | path exists) { return [] }

    let md_files = (ls $cat_dir)
        | where type == "file"
        | get name
        | each { |p| $p | path basename }
        | where { |f| ($f | path parse | get extension) == "md" }
        | where { |f| not ($f | str starts-with "_") }

    mut results = []
    for md_file in $md_files {
        let slug = $md_file | path parse | get stem
        if $slug_filter != "" and $slug != $slug_filter { continue }

        let md_path    = $"($cat_dir)/($md_file)"
        let ncl_path   = $"($cat_dir)/($slug).ncl"
        # Images stored language-neutral: {ct}/_images/{cat}/{slug}/
        let images_dir = $"($content_root)/($ct)/_images/($cat)/($slug)"

        let fm         = extract_frontmatter $md_path
        let title      = extract_from_frontmatter $fm "title"      "Untitled"
        let excerpt    = extract_from_frontmatter $fm "excerpt"    ""
        let subtitle   = extract_from_frontmatter $fm "subtitle"   ""
        let category   = extract_from_frontmatter $fm "category"   $cat
        let tags_raw   = extract_from_frontmatter $fm "tags"       ""
        let tags       = parse_tags $tags_raw
        let event_type = extract_from_frontmatter $fm "event_type" "talk"

        let md_thumb   = extract_from_frontmatter $fm "thumbnail" ""
        let ncl_thumb  = if ($ncl_path | path exists) { extract_ncl_field $ncl_path "thumbnail" } else { "" }
        let thumbnail_set = (not ($md_thumb | is-empty)) and (not ($ncl_thumb | is-empty))

        $results = ($results | append [{
            ct:            $ct,
            lang:          $lang,
            cat:           $cat,
            slug:          $slug,
            md_path:       $md_path,
            ncl_path:      $ncl_path,
            images_dir:    $images_dir,
            title:         $title,
            excerpt:       $excerpt,
            subtitle:      $subtitle,
            category:      $category,
            tags:          $tags,
            event_type:    $event_type,
            thumbnail_set: $thumbnail_set,
        }])
    }
    $results
}

# Parse YAML inline tag arrays: ["rust", "leptos"] or [rust, leptos]
def parse_tags [raw: string] {
    if $raw == "" or $raw == "[]" { return [] }
    $raw
        | str replace --all '[' ''
        | str replace --all ']' ''
        | str replace --all '"' ''
        | str replace --all "'" ''
        | split row ','
        | each { |t| $t | str trim }
        | where { |t| not ($t | is-empty) }
}

# ---------------------------------------------------------------------------
# Prompt construction
# ---------------------------------------------------------------------------

def build_prompt [item: record, cfg: record, ct: string, image_type: string] {
    let template_path = $"($cfg.templates_dir)/($ct).j2" | path expand
    if not ($template_path | path exists) {
        error make { msg: $"Prompt template not found: ($template_path)" }
    }

    let style = $cfg.styles | get $ct
    let context = {
        title:      $item.title,
        category:   $item.category,
        tags:       ($item.tags | str join ", "),
        excerpt:    $item.excerpt,
        subtitle:   $item.subtitle,
        event_type: $item.event_type,
        style:      $style,
        image_type: $image_type,
    }

    # tera-render is a Nu plugin: pipe the context record, pass template as positional
    $context | tera-render $template_path
}

# ---------------------------------------------------------------------------
# DALL-E 3 API
# ---------------------------------------------------------------------------

def call_dalle [
    prompt:  string,
    model:   string,
    size:    string,
    quality: string,
    dest:    string,
    api_key: string,
] {
    let body = {
        model:           $model,
        prompt:          $prompt,
        n:               1,
        size:            $size,
        quality:         $quality,
        response_format: "b64_json",
    } | to json

    # --fail-with-body: non-2xx exits non-zero AND returns the response body
    # (unlike --fail which silences the body). Needed to surface rate-limit details.
    let result = (^curl --silent --fail-with-body --show-error
        -X POST "https://api.openai.com/v1/images/generations"
        -H $"Authorization: Bearer ($api_key)"
        -H "Content-Type: application/json"
        -d $body) | complete

    if $result.exit_code != 0 {
        let api_err = if ($result.stdout | is-empty) { $result.stderr } else { $result.stdout }
        return { ok: null, actual_cost: null, err: $"DALL-E request failed: ($api_err)" }
    }

    let parsed = $result.stdout | from json
    let b64    = $parsed | get data | first | get b64_json

    # Nu's `decode base64 | save` is unreliable for multi-MB payloads: it may
    # write an internal temp-file path instead of the raw bytes. Use a shell
    # redirect through bash so the binary lands directly in $dest.
    let tmp_b64 = $"($nu.temp-dir)/dalle_b64.txt"
    $b64 | save --force $tmp_b64
    let decode_result = (^bash "-c" $"base64 -d < '($tmp_b64)' > '($dest)'") | complete
    rm --force $tmp_b64

    if $decode_result.exit_code != 0 {
        rm --force $dest
        return { ok: null, actual_cost: null, err: $"base64 decode failed: ($decode_result.stderr)" }
    }

    # gpt-image-1 returns a usage record; dall-e-3 does not.
    # When present, compute actual cost from real token consumption.
    let usage_field = $parsed | get --optional usage
    let actual_cost = if ($usage_field | is-empty) {
        null
    } else {
        cost_from_usage $usage_field
    }

    { ok: $dest, actual_cost: $actual_cost, err: null }
}

# ---------------------------------------------------------------------------
# Approval: sequential per-item via typedialog select (Nu plugin)
# ---------------------------------------------------------------------------

def run_review_batch [pending: list] {
    $pending | each { |e|
        { entry: $e, decision: (run_review_one $e) }
    }
}

def run_review_one [entry: record] {
    # Open image in system viewer (returns immediately on both macOS and Linux)
    let viewer = if $nu.os-info.name == "macos" { "open" } else { "xdg-open" }
    run-external $viewer $entry.pending_path

    let label = $"($entry.item.ct)/($entry.item.lang)/($entry.item.cat)/($entry.item.slug) [($entry.img_type)]"

    # typedialog select: Nu plugin — no ^ prefix, no | complete, takes list<string>
    typedialog select $label ["approve" "skip" "regenerate"]
}

# ---------------------------------------------------------------------------
# Finalize approved image
# ---------------------------------------------------------------------------

def finalize_image [
    entry:           record,
    content_root:    string,
    no_nats:         bool,
    propagate_langs: bool,
    insert_hero:     bool,
] {
    let item     = $entry.item
    let img_type = $entry.img_type

    let final_name = $"($item.slug)_($img_type).png"
    let final_path = $"($item.images_dir)/($final_name)"
    mkdir $item.images_dir
    mv --force $entry.pending_path $final_path

    # Language-neutral public path: /content/{ct}/_images/{cat}/{slug}/{file}
    let public_path = if $item.cat == "" {
        $"/content/($item.ct)/_images/($item.slug)/($final_name)"
    } else {
        $"/content/($item.ct)/_images/($item.cat)/($item.slug)/($final_name)"
    }

    let field = if $img_type == "thumbnail" { "thumbnail" } else { "image_url" }
    update_ncl_field $item.ncl_path $field $public_path

    if $insert_hero and $img_type == "thumbnail" {
        insert_hero_in_md $item.md_path $item.title $public_path
    }

    if $propagate_langs {
        propagate_to_other_langs $item $content_root $field $public_path
    }

    # Per-post approved-spend log
    let expenses_dir = $"($item.ncl_path | path dirname)/_expenses"
    mkdir $expenses_dir
    let expense_entry = {
        ts:          (date now | format date "%Y-%m-%dT%H:%M:%SZ"),
        model:       ($entry | get --optional model       | default ""),
        quality:     ($entry | get --optional quality     | default ""),
        size:        ($entry | get --optional size        | default ""),
        img_type:    $img_type,
        cost_usd:    ($entry | get --optional cost_usd    | default 0.0),
        cost_source: ($entry | get --optional cost_source | default ""),
        public_path: $public_path,
    }
    append_spend_log $"($expenses_dir)/image-spend.jsonl" $expense_entry

    # Per-type aggregate
    let type_expenses_dir = $"($content_root)/($item.ct)/_expenses"
    mkdir $type_expenses_dir
    append_spend_log $"($type_expenses_dir)/image-spend.jsonl" ($expense_entry | insert ct $item.ct | insert lang $item.lang | insert slug $item.slug)

    publish_approved $item $img_type $no_nats
}

# Insert hero image as first paragraph after frontmatter (idempotent)
def insert_hero_in_md [md_path: string, title: string, public_path: string] {
    if not ($md_path | path exists) { return }

    let hero      = $"![($title)](($public_path))"
    let all_lines = open $md_path | lines

    # Primary: replace <!-- hero --> marker wherever it appears in the body
    let marker_rows = $all_lines | enumerate | where { |e| ($e.item | str trim) == "<!-- hero -->" }
    if not ($marker_rows | is-empty) {
        let marker_idx = $marker_rows | first | get index
        let new_lines  = $all_lines | enumerate | each { |e|
            if $e.index == $marker_idx { $hero } else { $e.item }
        }
        $new_lines | str join "\n" | save --force $md_path
        return
    }

    # Fallback: insert after frontmatter closing ---
    mut fm_end = -1
    mut idx    = 0
    mut in_fm  = false
    for line in $all_lines {
        if $idx == 0 and ($line | str trim) == "---" {
            $in_fm = true
        } else if $in_fm and ($line | str trim) == "---" {
            $fm_end = $idx
            break
        }
        $idx = $idx + 1
    }
    if $fm_end < 0 { return }

    # Skip if a hero image already exists in the body (idempotency)
    let body_lines = $all_lines | skip ($fm_end + 1)
    let already    = $body_lines | any { |l| ($l | str trim) | str starts-with "![" }
    if $already { return }

    let new_lines = ($all_lines | first ($fm_end + 1)) ++ ["", $hero, ""] ++ $body_lines
    $new_lines | str join "\n" | save --force $md_path
}

# Write the same image path to all other language variants of the same content item
def propagate_to_other_langs [
    item:         record,
    content_root: string,
    field:        string,
    public_path:  string,
] {
    let other_langs = discover_dirs $"($content_root)/($item.ct)" ""
        | where { |l| $l != $item.lang }

    for lang in $other_langs {
        # Flat-file layout
        let md_flat  = $"($content_root)/($item.ct)/($lang)/($item.cat)/($item.slug).md"
        let ncl_flat = $"($content_root)/($item.ct)/($lang)/($item.cat)/($item.slug).ncl"
        # Page-bundle layout
        let md_bundle  = $"($content_root)/($item.ct)/($lang)/($item.cat)/($item.slug)/index.md"
        let ncl_bundle = $"($content_root)/($item.ct)/($lang)/($item.cat)/($item.slug)/index.ncl"

        let md  = if ($md_flat  | path exists) { $md_flat  } else if ($md_bundle  | path exists) { $md_bundle  } else { "" }
        let ncl = if ($ncl_flat | path exists) { $ncl_flat } else if ($ncl_bundle | path exists) { $ncl_bundle } else { "" }

        if $ncl != "" { update_ncl_field $ncl $field $public_path }
        print $"  propagated ($field) → ($item.ct)/($lang)/($item.cat)/($item.slug)"
    }
}

# ---------------------------------------------------------------------------
# Metadata injection — Markdown YAML frontmatter
# ---------------------------------------------------------------------------

def update_md_field [md_path: string, key: string, value: string] {
    if not ($md_path | path exists) { return }

    let content = open $md_path
    let all_lines = $content | lines

    # Locate frontmatter boundaries (first two `---` lines)
    mut fm_start = -1
    mut fm_end   = -1
    mut idx      = 0
    for line in $all_lines {
        if $idx == 0 and ($line | str trim) == "---" {
            $fm_start = $idx
        } else if $fm_start >= 0 and $fm_end < 0 and ($line | str trim) == "---" {
            $fm_end = $idx
            break
        }
        $idx = $idx + 1
    }

    if $fm_start < 0 or $fm_end < 0 {
        # No frontmatter — prepend one
        let new_content = $"---\n($key): \"($value)\"\n---\n\n($content)"
        $new_content | save --force $md_path
        return
    }

    # Scan frontmatter for existing key
    mut found_at = -1
    mut scan_idx = $fm_start + 1
    while $scan_idx < $fm_end {
        let line = $all_lines | get $scan_idx
        if ($line | str starts-with $"($key):") {
            $found_at = $scan_idx
            break
        }
        $scan_idx = $scan_idx + 1
    }

    let new_line      = $"($key): \"($value)\""
    let found_at_imm  = $found_at
    let fm_end_imm    = $fm_end
    let updated_lines = if $found_at_imm >= 0 {
        $all_lines | enumerate | each { |e|
            if $e.index == $found_at_imm { $new_line } else { $e.item }
        }
    } else {
        # Insert before the closing `---`
        $all_lines | enumerate | each { |e|
            if $e.index == $fm_end_imm {
                [$new_line, $e.item]
            } else {
                [$e.item]
            }
        } | flatten
    }

    $updated_lines | str join "\n" | save --force $md_path
}

# ---------------------------------------------------------------------------
# Metadata injection — Nickel NCL
# ---------------------------------------------------------------------------

def update_ncl_field [ncl_path: string, key: string, value: string] {
    if not ($ncl_path | path exists) { return }

    let content = open $ncl_path
    let all_lines = $content | lines

    let field_prefix = $"  ($key) ="
    mut found_at = -1
    mut idx = 0
    for line in $all_lines {
        if ($line | str starts-with $field_prefix) {
            $found_at = $idx
            break
        }
        $idx = $idx + 1
    }

    let new_line     = $"  ($key) = \"($value)\","
    let found_at_imm = $found_at

    let updated_lines = if $found_at_imm >= 0 {
        $all_lines | enumerate | each { |e|
            if $e.index == $found_at_imm { $new_line } else { $e.item }
        }
    } else {
        # Find the last `}` line (closing brace of the make_* call)
        let brace_rows     = ($all_lines | enumerate | where { |e| ($e.item | str trim) == "}" })
        let last_brace_idx = if ($brace_rows | is-empty) { -1 } else { $brace_rows | last | get index }

        if $last_brace_idx < 0 {
            # Unusual format — append before EOF
            $all_lines | append $new_line
        } else {
            $all_lines | enumerate | each { |e|
                if $e.index == $last_brace_idx {
                    [$new_line, $e.item]
                } else {
                    [$e.item]
                }
            } | flatten
        }
    }

    $updated_lines | str join "\n" | save --force $ncl_path
}

# ---------------------------------------------------------------------------
# NCL field extraction (for thumbnail_set detection)
# ---------------------------------------------------------------------------

def extract_ncl_field [ncl_path: string, key: string] {
    if not ($ncl_path | path exists) { return "" }

    let field_prefix = $"  ($key) ="
    let matches = open $ncl_path
        | lines
        | where { |l| $l | str starts-with $field_prefix }
    if ($matches | is-empty) { return "" }
    $matches
        | first
        | str replace --regex $"^  ($key) = " ""
        | str replace --all '"' ''
        | str replace --all ',' ''
        | str trim
}

# ---------------------------------------------------------------------------
# NATS notification
# ---------------------------------------------------------------------------

def publish_approved [item: record, image_type: string, no_nats: bool] {
    if $no_nats { return }

    let ns      = $env | get --optional NATS_NAMESPACE | default "rustelo"
    let subject = $"($ns).content.image-approved"

    # nats pub is a Nu plugin: pipe the record, pass subject as positional
    {
        content_type: $item.ct,
        language:     $item.lang,
        id:           $item.slug,
        image_type:   $image_type,
    } | nats pub $subject | ignore
}

# ---------------------------------------------------------------------------
# Shared utilities (mirrors copy-content-images.nu)
# ---------------------------------------------------------------------------

def resolve_env [var_name: string, cli_arg: string, fallback: string] {
    if $cli_arg != "" { return $cli_arg }
    $env | get --optional $var_name | default $fallback
}

def discover_dirs [parent: string, filter: string] {
    if not ($parent | path exists) { return [] }
    let all = (ls $parent)
        | where type == "dir"
        | get name
        | each { |p| $p | path basename }
        | where { |d| not ($d | str starts-with ".") }
        | where { |d| not ($d | str starts-with "_") }  # exclude _images, _shared, etc.
    if $filter != "" { $all | where { |d| $d == $filter } } else { $all }
}
