// SPDX-License-Identifier: GPL-3.0-or-later
//
// filmbuddy-raw-decode-libraw — LibRaw + librtprocess decode helper (master tier).
//
// This program is licensed GPL-3.0-or-later (it links librtprocess,
// GPL-3.0-or-later; LibRaw under LGPL-2.1). It runs as a standalone subprocess
// and communicates with Film Buddy at arm's length via CLI arguments and files.
// Nothing in this target may be linked into the app. See
// docs/libraw_migration.md.
//
// Phase 1: `probe <raw-file>` — unpack + metadata JSON + demosaic smoke test.
// Phase 2: `decode --input <raw> --output <tif> [--max-pixel-size N]` — the
// production contract: LibRaw unpack → per-channel black/white normalize →
// as-shot WB around demosaic only (applied for AMaZE quality, divided back out
// after) → AMaZE (Bayer) / markesteijn (X-Trans) → camera RGB → extended
// linear ROMM/ProPhoto D50 (Bradford) → RGBAh TIFF via ImageIO +
// `.raw_recipe.json` sidecar. Policy: libraw-amaze-linear-romm-scene-v3.
// v3: the master is UN-white-balanced with per-channel sensor-native clip at
// 1.0; as-shot gains ride the sidecar and the app applies WB live (lossless,
// no re-decode). Phase 3 may still A/B HLRecovery_inpaint.

#include <libraw/libraw.h>
#include <librtprocess.h>

#include <CoreGraphics/CoreGraphics.h>
#include <ImageIO/ImageIO.h>

#include <algorithm>
#include <array>
#include <chrono>
#include <cmath>
#include <cstdio>
#include <cstdlib>
#include <cstring>
#include <functional>
#include <string>
#include <vector>

namespace {

double nowSeconds()
{
    using clock = std::chrono::steady_clock;
    return std::chrono::duration<double>(clock::now().time_since_epoch()).count();
}

int fail(const char *stage, int code, const char *detail)
{
    std::fprintf(stderr, "{\"ok\":false,\"stage\":\"%s\",\"code\":%d,\"detail\":\"%s\"}\n",
                 stage, code, detail ? detail : "");
    return 1;
}

bool progressNoop(double) { return true; }

int probe(const char *path)
{
    LibRaw raw;
    const double t0 = nowSeconds();
    int rc = raw.open_file(path);
    if (rc != LIBRAW_SUCCESS)
        return fail("open_file", rc, libraw_strerror(rc));
    rc = raw.unpack();
    if (rc != LIBRAW_SUCCESS)
        return fail("unpack", rc, libraw_strerror(rc));
    const double tUnpack = nowSeconds() - t0;

    const auto &sizes = raw.imgdata.sizes;
    const auto &color = raw.imgdata.color;
    const auto &idata = raw.imgdata.idata;
    const unsigned short *mosaic = raw.imgdata.rawdata.raw_image;
    if (!mosaic)
        return fail("raw_image", 0, "no bayer/x-trans plane (foveon/sraw unsupported in phase 1)");

    const bool xtrans = idata.filters == 9;
    const int width = sizes.width;
    const int height = sizes.height;

    // Normalize the active area to float [0,1]: per-channel black subtract + white scale.
    std::vector<float> plane(static_cast<size_t>(width) * height);
    std::vector<float *> rows(height);
    for (int y = 0; y < height; ++y) {
        rows[y] = plane.data() + static_cast<size_t>(y) * width;
        const unsigned short *src =
            mosaic + static_cast<size_t>(y + sizes.top_margin) * sizes.raw_width + sizes.left_margin;
        for (int x = 0; x < width; ++x) {
            int c = raw.COLOR(y, x); // handles both Bayer and X-Trans
            const float blackC = static_cast<float>(color.black + color.cblack[c < 4 ? c : 0]);
            const float denom = std::fmax(1.f, static_cast<float>(color.maximum) - blackC);
            rows[y][x] = std::fmax(0.f, static_cast<float>(src[x]) - blackC) / denom;
        }
    }

    // Destination RGB planes for librtprocess.
    std::vector<float> rBuf(plane.size()), gBuf(plane.size()), bBuf(plane.size());
    std::vector<float *> r(height), g(height), b(height);
    for (int y = 0; y < height; ++y) {
        r[y] = rBuf.data() + static_cast<size_t>(y) * width;
        g[y] = gBuf.data() + static_cast<size_t>(y) * width;
        b[y] = bBuf.data() + static_cast<size_t>(y) * width;
    }

    const double t1 = nowSeconds();
    const char *algo = nullptr;
    rpError err = RP_NO_ERROR;
    if (xtrans) {
        algo = "markesteijn(3-pass)";
        unsigned xtransArr[6][6];
        for (int i = 0; i < 6; ++i)
            for (int j = 0; j < 6; ++j)
                xtransArr[i][j] = idata.xtrans[i][j];
        err = markesteijn_demosaic(width, height, rows.data(), r.data(), g.data(), b.data(),
                                   xtransArr, color.rgb_cam, progressNoop, 3, true);
    } else {
        algo = "amaze";
        unsigned cfa[2][2] = {
            {static_cast<unsigned>(raw.COLOR(0, 0)), static_cast<unsigned>(raw.COLOR(0, 1))},
            {static_cast<unsigned>(raw.COLOR(1, 0)), static_cast<unsigned>(raw.COLOR(1, 1))}};
        for (auto &rowc : cfa)
            for (auto &c : rowc)
                if (c == 3) c = 1; // fold G2 onto G for the 2x2 CFA
        err = amaze_demosaic(width, height, 0, 0, width, height, rows.data(), r.data(), g.data(),
                             b.data(), cfa, progressNoop, 1.0, 0, 1.f, 1.f);
    }
    if (err != RP_NO_ERROR)
        return fail("demosaic", static_cast<int>(err), algo);
    const double tDemosaic = nowSeconds() - t1;

    // Mid-image sample as a smoke value (proves non-zero output).
    const size_t mid = (static_cast<size_t>(height / 2) * width) + width / 2;

    std::printf("{\n");
    std::printf("  \"ok\": true,\n");
    std::printf("  \"libraw_version\": \"%s\",\n", LibRaw::version());
    std::printf("  \"make\": \"%s\", \"model\": \"%s\", \"normalized_model\": \"%s\",\n",
                idata.make, idata.model, idata.normalized_model);
    std::printf("  \"raw_size\": [%d, %d], \"active_size\": [%d, %d], \"margins\": [%d, %d],\n",
                sizes.raw_width, sizes.raw_height, width, height, sizes.left_margin,
                sizes.top_margin);
    std::printf("  \"flip\": %d,\n", sizes.flip);
    std::printf("  \"raw_inset_crops\": [[%u, %u, %u, %u], [%u, %u, %u, %u]],\n",
                sizes.raw_inset_crops[0].cleft, sizes.raw_inset_crops[0].ctop,
                sizes.raw_inset_crops[0].cwidth, sizes.raw_inset_crops[0].cheight,
                sizes.raw_inset_crops[1].cleft, sizes.raw_inset_crops[1].ctop,
                sizes.raw_inset_crops[1].cwidth, sizes.raw_inset_crops[1].cheight);
    std::printf("  \"cfa\": \"%s\", \"filters\": %u,\n", xtrans ? "xtrans" : "bayer",
                idata.filters);
    std::printf("  \"black\": %u, \"cblack\": [%u, %u, %u, %u], \"white\": %u,\n", color.black,
                color.cblack[0], color.cblack[1], color.cblack[2], color.cblack[3],
                color.maximum);
    std::printf("  \"cam_mul\": [%.6f, %.6f, %.6f, %.6f],\n", color.cam_mul[0], color.cam_mul[1],
                color.cam_mul[2], color.cam_mul[3]);
    std::printf("  \"cam_xyz\": [");
    for (int i = 0; i < 3; ++i)
        for (int j = 0; j < 3; ++j)
            std::printf("%s%.6f", (i || j) ? ", " : "", color.cam_xyz[i][j]);
    std::printf("],\n");
    std::printf("  \"demosaic\": \"%s\",\n", algo);
    std::printf("  \"sample_rgb_mid\": [%.6f, %.6f, %.6f],\n", rBuf[mid], gBuf[mid], bBuf[mid]);
    std::printf("  \"unpack_ms\": %.1f, \"demosaic_ms\": %.1f\n", tUnpack * 1000.0,
                tDemosaic * 1000.0);
    std::printf("}\n");
    return 0;
}

// v3: master is un-white-balanced (WB applied around demosaic only, divided
// back out); as-shot gains ride the recipe sidecar and are applied live.
constexpr const char *kNormalizationPolicy = "libraw-amaze-linear-romm-scene-v3";

// Row-major 3x3 helpers.
using Mat3 = std::array<double, 9>;

Mat3 mul3(const Mat3 &a, const Mat3 &b)
{
    Mat3 o{};
    for (int i = 0; i < 3; ++i)
        for (int j = 0; j < 3; ++j)
            for (int k = 0; k < 3; ++k)
                o[i * 3 + j] += a[i * 3 + k] * b[k * 3 + j];
    return o;
}

// Camera linear RGB -> ROMM/ProPhoto linear D50.
// Route: rgb_cam (camera -> linear sRGB D65, LibRaw/Adobe-derived) -> XYZ D65
// -> Bradford D65->D50 -> ROMM. Constants are the standard published matrices.
Mat3 cameraToRommMatrix(const libraw_colordata_t &color, int colors)
{
    const Mat3 srgbToXyzD65 = {0.4124564, 0.3575761, 0.1804375,
                               0.2126729, 0.7151522, 0.0721750,
                               0.0193339, 0.1191920, 0.9503041};
    const Mat3 bradfordD65ToD50 = {1.0478112, 0.0228866, -0.0501270,
                                   0.0295424, 0.9904844, -0.0170491,
                                   -0.0092345, 0.0150436, 0.7521316};
    const Mat3 xyzD50ToRomm = {1.3459433, -0.2556075, -0.0511118,
                               -0.5445989, 1.5081673, 0.0205351,
                               0.0000000, 0.0000000, 1.2118128};
    Mat3 rgbCam{};
    for (int i = 0; i < 3; ++i) {
        for (int j = 0; j < 3; ++j)
            rgbCam[i * 3 + j] = color.rgb_cam[i][j];
        if (colors == 4)
            rgbCam[i * 3 + 1] += color.rgb_cam[i][3]; // fold G2 into G
    }
    return mul3(xyzD50ToRomm, mul3(bradfordD65ToD50, mul3(srgbToXyzD65, rgbCam)));
}

CGColorSpaceRef makeExtendedLinearRomm()
{
    CGColorSpaceRef romm = CGColorSpaceCreateWithName(kCGColorSpaceROMMRGB);
    if (!romm)
        return nullptr;
    CGColorSpaceRef ext = CGColorSpaceCreateExtendedLinearized(romm);
    if (ext) {
        CGColorSpaceRelease(romm);
        return ext;
    }
    return romm;
}

bool writeTiffRGBAh(const char *path, int width, int height, const std::vector<_Float16> &rgba)
{
    CGColorSpaceRef space = makeExtendedLinearRomm();
    if (!space)
        return false;
    CGDataProviderRef provider = CGDataProviderCreateWithData(
        nullptr, rgba.data(), rgba.size() * sizeof(_Float16), nullptr);
    // Real alpha (=1) rather than skip-alpha: CGImageDestination drops a
    // skipped channel and writes 48bpp RGB-half TIFFs, which downstream
    // RGBAh-stride consumers misread as banded/duplicated frames. Apple's
    // CIRAW masters are true 4-channel RGBAh; match that exactly.
    CGBitmapInfo info = kCGBitmapFloatComponents | kCGBitmapByteOrder16Little |
                        static_cast<CGBitmapInfo>(kCGImageAlphaLast);
    CGImageRef image =
        CGImageCreate(width, height, 16, 64, static_cast<size_t>(width) * 8, space, info,
                      provider, nullptr, false, kCGRenderingIntentDefault);
    bool ok = false;
    if (image) {
        CFStringRef tiffType = CFSTR("public.tiff");
        CFURLRef url = CFURLCreateFromFileSystemRepresentation(
            nullptr, reinterpret_cast<const UInt8 *>(path), std::strlen(path), false);
        CGImageDestinationRef dest = CGImageDestinationCreateWithURL(url, tiffType, 1, nullptr);
        if (dest) {
            CGImageDestinationAddImage(dest, image, nullptr);
            ok = CGImageDestinationFinalize(dest);
            CFRelease(dest);
        }
        CFRelease(url);
        CGImageRelease(image);
    }
    CGDataProviderRelease(provider);
    CGColorSpaceRelease(space);
    return ok;
}

// Deterministic separable area-average resize of interleaved RGB planes.
void areaResize(const std::vector<float> &src, int sw, int sh, std::vector<float> &dst, int dw,
                int dh)
{
    dst.assign(static_cast<size_t>(dw) * dh * 3, 0.f);
    std::vector<float> weight(static_cast<size_t>(dw) * dh, 0.f);
    const double sx = static_cast<double>(sw) / dw;
    const double sy = static_cast<double>(sh) / dh;
    for (int y = 0; y < sh; ++y) {
        const int oy = std::min(dh - 1, static_cast<int>(y / sy));
        for (int x = 0; x < sw; ++x) {
            const int ox = std::min(dw - 1, static_cast<int>(x / sx));
            const size_t si = (static_cast<size_t>(y) * sw + x) * 3;
            const size_t di = (static_cast<size_t>(oy) * dw + ox);
            dst[di * 3 + 0] += src[si + 0];
            dst[di * 3 + 1] += src[si + 1];
            dst[di * 3 + 2] += src[si + 2];
            weight[di] += 1.f;
        }
    }
    for (size_t i = 0; i < weight.size(); ++i) {
        const float w = std::fmax(1.f, weight[i]);
        dst[i * 3 + 0] /= w;
        dst[i * 3 + 1] /= w;
        dst[i * 3 + 2] /= w;
    }
}

int decodeCommand(const std::string &input, const std::string &output, int maxPixelSize)
{
    const double t0 = nowSeconds();
    LibRaw raw;
    int rc = raw.open_file(input.c_str());
    if (rc != LIBRAW_SUCCESS)
        return fail("open_file", rc, libraw_strerror(rc));
    rc = raw.unpack();
    if (rc != LIBRAW_SUCCESS)
        return fail("unpack", rc, libraw_strerror(rc));

    const auto &sizes = raw.imgdata.sizes;
    const auto &color = raw.imgdata.color;
    const auto &idata = raw.imgdata.idata;
    if (!raw.imgdata.rawdata.raw_image)
        return fail("raw_image", 0, "unsupported sensor layout (no CFA plane)");

    const bool xtrans = idata.filters == 9;
    const int width = sizes.width;
    const int height = sizes.height;

    // As-shot WB gains, green-normalized (recorded in the recipe for the app's
    // live WB), plus a max-normalized variant (<= 1) used only around demosaic.
    // v3 contract: the WRITTEN master is UN-white-balanced — WB is applied for
    // demosaic quality (AMaZE assumes green-correlated, WB'd input) and divided
    // back out afterwards, so live WB in the app is lossless and needs no
    // re-decode. Max-normalizing keeps demosaic input <= 1.0 (AMaZE's internal
    // highlight handling assumes that scale) and the post-demosaic un-bake
    // lands each channel's natural sensor clip back at exactly 1.0.
    float gains[4];
    const float greenMul = color.cam_mul[1] > 0.f ? color.cam_mul[1] : 1.f;
    float maxMul = 0.f;
    for (int c = 0; c < 4; ++c) {
        gains[c] = color.cam_mul[c] > 0.f ? color.cam_mul[c] / greenMul : 1.f;
        maxMul = std::fmax(maxMul, gains[c]);
    }
    if (maxMul <= 0.f)
        maxMul = 1.f;
    float demosaicGains[4];
    for (int c = 0; c < 4; ++c)
        demosaicGains[c] = gains[c] / maxMul;

    std::vector<float> plane(static_cast<size_t>(width) * height);
    std::vector<float *> rows(height);
    const unsigned short *mosaic = raw.imgdata.rawdata.raw_image;
    for (int y = 0; y < height; ++y) {
        rows[y] = plane.data() + static_cast<size_t>(y) * width;
        const unsigned short *src =
            mosaic + static_cast<size_t>(y + sizes.top_margin) * sizes.raw_width + sizes.left_margin;
        for (int x = 0; x < width; ++x) {
            const int c = raw.COLOR(y, x);
            const float blackC = static_cast<float>(color.black + color.cblack[c < 4 ? c : 0]);
            const float denom = std::fmax(1.f, static_cast<float>(color.maximum) - blackC);
            const float v = std::fmax(0.f, static_cast<float>(src[x]) - blackC) / denom;
            rows[y][x] = v * demosaicGains[c < 4 ? c : 1];
        }
    }

    std::vector<float> rBuf(plane.size()), gBuf(plane.size()), bBuf(plane.size());
    std::vector<float *> r(height), g(height), b(height);
    for (int y = 0; y < height; ++y) {
        r[y] = rBuf.data() + static_cast<size_t>(y) * width;
        g[y] = gBuf.data() + static_cast<size_t>(y) * width;
        b[y] = bBuf.data() + static_cast<size_t>(y) * width;
    }

    const char *algo = nullptr;
    rpError err = RP_NO_ERROR;
    if (xtrans) {
        algo = "markesteijn3";
        unsigned xtransArr[6][6];
        for (int i = 0; i < 6; ++i)
            for (int j = 0; j < 6; ++j)
                xtransArr[i][j] = idata.xtrans[i][j];
        err = markesteijn_demosaic(width, height, rows.data(), r.data(), g.data(), b.data(),
                                   xtransArr, color.rgb_cam, progressNoop, 3, true);
    } else {
        algo = "amaze";
        unsigned cfa[2][2] = {
            {static_cast<unsigned>(raw.COLOR(0, 0)), static_cast<unsigned>(raw.COLOR(0, 1))},
            {static_cast<unsigned>(raw.COLOR(1, 0)), static_cast<unsigned>(raw.COLOR(1, 1))}};
        for (auto &rowc : cfa)
            for (auto &c : rowc)
                if (c == 3) c = 1;
        err = amaze_demosaic(width, height, 0, 0, width, height, rows.data(), r.data(), g.data(),
                             b.data(), cfa, progressNoop, 1.0, 0, 1.f, 1.f);
    }
    if (err != RP_NO_ERROR)
        return fail("demosaic", static_cast<int>(err), algo);

    // Un-bake the demosaic WB: divide the max-normalized gains back out so the
    // master carries camera-native (as-metered) channel ratios. Where the
    // sensor clipped, v was 1.0 pre-gain and returns to exactly 1.0 here, so
    // per-channel clip structure survives for live highlight handling.
    {
        const float invR = 1.f / std::fmax(demosaicGains[0], 1e-6f);
        const float invG = 1.f / std::fmax(demosaicGains[1], 1e-6f);
        const float invB = 1.f / std::fmax(demosaicGains[2], 1e-6f);
        const size_t n = plane.size();
        for (size_t i = 0; i < n; ++i) {
            rBuf[i] *= invR;
            gBuf[i] *= invG;
            bBuf[i] *= invB;
        }
    }

    // Maker-recommended visible area (matches Apple's framing). raw_inset_crops
    // is in raw-frame coordinates; make it relative to the active area.
    int cropX = 0, cropY = 0, cropW = width, cropH = height;
    {
        const auto &inset = sizes.raw_inset_crops[0];
        if (inset.cwidth > 0 && inset.cheight > 0 && inset.cleft != 0xffff &&
            inset.ctop != 0xffff) {
            const int cx = static_cast<int>(inset.cleft) - sizes.left_margin;
            const int cy = static_cast<int>(inset.ctop) - sizes.top_margin;
            if (cx >= 0 && cy >= 0 && cx + static_cast<int>(inset.cwidth) <= width &&
                cy + static_cast<int>(inset.cheight) <= height) {
                cropX = cx;
                cropY = cy;
                cropW = static_cast<int>(inset.cwidth);
                cropH = static_cast<int>(inset.cheight);
            }
        }
    }

    // Camera RGB -> ROMM linear, interleaved, cropped to the maker area.
    Mat3 m = cameraToRommMatrix(color, idata.colors);
    // Decode-domain A/B hook: FILMBUDDY_LIBRAW_POST_MATRIX="9 floats" composes
    // a ROMM-domain 3x3 on top of the camera matrix (m := D * m), so the
    // written master equals D times the stock master. Experiment-only; recipe
    // sidecars record the composed matrix plus an override flag so a run can
    // never be mistaken for a stock decode.
    bool postMatrixApplied = false;
    if (const char *env = std::getenv("FILMBUDDY_LIBRAW_POST_MATRIX")) {
        Mat3 d{};
        if (std::sscanf(env, "%lf , %lf , %lf , %lf , %lf , %lf , %lf , %lf , %lf",
                        &d[0], &d[1], &d[2], &d[3], &d[4], &d[5], &d[6], &d[7], &d[8]) == 9) {
            m = mul3(d, m);
            postMatrixApplied = true;
        } else {
            return fail("post_matrix", 0, "FILMBUDDY_LIBRAW_POST_MATRIX must be 9 comma-separated floats");
        }
    }
    std::vector<float> rgb(static_cast<size_t>(cropW) * cropH * 3);
    for (int y = 0; y < cropH; ++y)
        for (int x = 0; x < cropW; ++x) {
            const size_t si = static_cast<size_t>(y + cropY) * width + (x + cropX);
            const size_t di = static_cast<size_t>(y) * cropW + x;
            const double cr = rBuf[si], cg = gBuf[si], cb = bBuf[si];
            rgb[di * 3 + 0] = static_cast<float>(std::fmax(0.0, m[0] * cr + m[1] * cg + m[2] * cb));
            rgb[di * 3 + 1] = static_cast<float>(std::fmax(0.0, m[3] * cr + m[4] * cg + m[5] * cb));
            rgb[di * 3 + 2] = static_cast<float>(std::fmax(0.0, m[6] * cr + m[7] * cg + m[8] * cb));
        }

    // Orientation: LibRaw flip 3=180, 5=90 CCW, 6=90 CW.
    int outW = cropW, outH = cropH;
    if (sizes.flip == 5 || sizes.flip == 6) {
        outW = cropH;
        outH = cropW;
        std::vector<float> rot(rgb.size());
        for (int y = 0; y < cropH; ++y)
            for (int x = 0; x < cropW; ++x) {
                const int dx = sizes.flip == 6 ? (cropH - 1 - y) : y;
                const int dy = sizes.flip == 6 ? x : (cropW - 1 - x);
                std::memcpy(&rot[(static_cast<size_t>(dy) * outW + dx) * 3],
                            &rgb[(static_cast<size_t>(y) * cropW + x) * 3], 3 * sizeof(float));
            }
        rgb.swap(rot);
    } else if (sizes.flip == 3) {
        for (size_t a = 0, z = static_cast<size_t>(cropW) * cropH - 1; a < z; ++a, --z)
            for (int k = 0; k < 3; ++k)
                std::swap(rgb[a * 3 + k], rgb[z * 3 + k]);
    }

    // Optional preview downscale.
    double scale = 1.0;
    if (maxPixelSize > 0) {
        const int longest = std::max(outW, outH);
        if (longest > maxPixelSize) {
            scale = static_cast<double>(maxPixelSize) / longest;
            const int dw = std::max(1, static_cast<int>(std::ceil(outW * scale - 1e-9)));
            const int dh = std::max(1, static_cast<int>(std::ceil(outH * scale - 1e-9)));
            std::vector<float> small;
            areaResize(rgb, outW, outH, small, dw, dh);
            rgb.swap(small);
            outW = dw;
            outH = dh;
        }
    }

    // Interleave to RGBAh.
    std::vector<_Float16> rgba(static_cast<size_t>(outW) * outH * 4);
    for (size_t i = 0; i < static_cast<size_t>(outW) * outH; ++i) {
        rgba[i * 4 + 0] = static_cast<_Float16>(rgb[i * 3 + 0]);
        rgba[i * 4 + 1] = static_cast<_Float16>(rgb[i * 3 + 1]);
        rgba[i * 4 + 2] = static_cast<_Float16>(rgb[i * 3 + 2]);
        rgba[i * 4 + 3] = static_cast<_Float16>(1.f);
    }
    if (!writeTiffRGBAh(output.c_str(), outW, outH, rgba))
        return fail("write_tiff", 0, output.c_str());

    const double elapsedMs = (nowSeconds() - t0) * 1000.0;

    // Payload (stdout) + sidecar recipe, mirroring filmbuddy.native_raw_decode.v2.
    char recipeJson[4096];
    std::snprintf(
        recipeJson, sizeof recipeJson,
        "  \"recipe\": {\n"
        "   \"decoder\": \"LibRaw+librtprocess\",\n"
        "   \"libraw_version\": \"%s\",\n"
        "   \"demosaic\": \"%s\",\n"
        "   \"white_balance\": \"unbaked\",\n"
        "   \"highlight_mode\": \"sensor-native\",\n"
        "   \"as_shot_neutral_gains\": [%.6f, 1.0, %.6f],\n"
        "   \"cam_mul\": [%.6f, %.6f, %.6f, %.6f],\n"
        "   \"black\": %u, \"white\": %u,\n"
        "   \"camera_to_romm\": [%.8f, %.8f, %.8f, %.8f, %.8f, %.8f, %.8f, %.8f, %.8f],\n"
        "   \"post_matrix_override\": %s,\n"
        "   \"flip\": %d,\n"
        "   \"working_profile_primaries\": \"ROMM/ProPhoto\",\n"
        "   \"working_profile_trc\": \"linear\",\n"
        "   \"working_profile_white_point\": \"D50\"\n"
        "  }",
        LibRaw::version(), algo, gains[0], gains[2], color.cam_mul[0], color.cam_mul[1],
        color.cam_mul[2], color.cam_mul[3], color.black, color.maximum, m[0], m[1], m[2], m[3],
        m[4], m[5], m[6], m[7], m[8], postMatrixApplied ? "true" : "false", sizes.flip);

    const std::string recipePath = [&] {
        std::string base = output;
        const size_t dot = base.rfind('.');
        if (dot != std::string::npos)
            base.resize(dot);
        return base + ".raw_recipe.json";
    }();

    auto emitPayload = [&](std::FILE *f, const char *status) {
        std::fprintf(f,
                     "{\n"
                     " \"schema\": \"filmbuddy.native_raw_decode.v2\",\n"
                     " \"status\": \"%s\",\n"
                     " \"backend\": \"native_libraw\",\n"
                     " \"decoder\": \"LibRaw+librtprocess\",\n"
                     " \"normalization_policy\": \"%s\",\n"
                     " \"raw_normalization\": true,\n"
                     " \"source_role\": \"%s\",\n"
                     " \"input\": \"%s\",\n"
                     " \"output\": \"%s\",\n"
                     " \"width\": %d,\n"
                     " \"height\": %d,\n"
                     " \"max_pixel_size\": %d,\n"
                     " \"scale_factor\": %.6f,\n"
                     " \"bit_depth\": 16,\n"
                     " \"floating_point\": true,\n"
                     " \"pixel_format\": \"RGBAh\",\n"
                     " \"working_color_space\": \"extended_linear_romm_rgb\",\n"
                     " \"elapsed_ms\": \"%.2f\",\n"
                     "%s\n"
                     "}\n",
                     status, kNormalizationPolicy,
                     maxPixelSize > 0 ? "develop_source_preview" : "develop_source_master",
                     input.c_str(), output.c_str(), outW, outH, maxPixelSize, scale, elapsedMs,
                     recipeJson);
    };
    if (std::FILE *f = std::fopen(recipePath.c_str(), "w")) {
        emitPayload(f, "cached");
        std::fclose(f);
    }
    emitPayload(stdout, "ok");
    return 0;
}

const char *argValue(int argc, char **argv, const char *name)
{
    for (int i = 2; i + 1 < argc; ++i)
        if (std::strcmp(argv[i], name) == 0)
            return argv[i + 1];
    return nullptr;
}

} // namespace

int main(int argc, char **argv)
{
    if (argc >= 3 && std::strcmp(argv[1], "probe") == 0)
        return probe(argv[2]);
    if (argc >= 2 && std::strcmp(argv[1], "status") == 0) {
        std::printf("{\"schema\": \"filmbuddy.native_raw_decode.status.v1\", \"status\": \"ok\", "
                    "\"decoder\": \"LibRaw+librtprocess\", \"libraw_version\": \"%s\", "
                    "\"normalization_policy\": \"%s\"}\n",
                    LibRaw::version(), kNormalizationPolicy);
        return 0;
    }
    if (argc >= 2 && std::strcmp(argv[1], "decode") == 0) {
        const char *in = argValue(argc, argv, "--input");
        const char *out = argValue(argc, argv, "--output");
        const char *maxPx = argValue(argc, argv, "--max-pixel-size");
        if (!in || !out)
            return fail("args", 2, "decode requires --input and --output");
        return decodeCommand(in, out, maxPx ? std::atoi(maxPx) : 0);
    }
    std::fprintf(stderr,
                 "usage: filmbuddy-raw-decode-libraw probe <raw-file>\n"
                 "       filmbuddy-raw-decode-libraw decode --input <raw> --output <tif> "
                 "[--max-pixel-size N]\n"
                 "       filmbuddy-raw-decode-libraw status\n"
                 "GPL-3.0 helper; see native/raw-decoder/main.cpp header.\n");
    return 2;
}
