/*
    This file is part of the KDE project
    SPDX-FileCopyrightText: 2005 Christoph Hormann <chris_hormann@gmx.de>
    SPDX-FileCopyrightText: 2005 Ignacio Castaño <castanyo@yahoo.es>

    SPDX-License-Identifier: LGPL-2.0-or-later
*/

#include "hdr_p.h"
#include "util_p.h"

#include <QColorSpace>
#include <QDataStream>
#include <QFloat16>
#include <QImage>
#include <QLoggingCategory>
#include <QRegularExpressionMatch>

/* *** HDR_HALF_QUALITY ***
 * If defined, a 16-bits float image is created, otherwise a 32-bits float ones (default).
 */
//#define HDR_HALF_QUALITY // default commented -> you should define it in your cmake file

/* *** HDR_MAX_IMAGE_WIDTH and HDR_MAX_IMAGE_HEIGHT ***
 * The maximum size in pixel allowed by the plugin.
 */
#ifndef HDR_MAX_IMAGE_WIDTH
#define HDR_MAX_IMAGE_WIDTH KIF_LARGE_IMAGE_PIXEL_LIMIT
#endif
#ifndef HDR_MAX_IMAGE_HEIGHT
#define HDR_MAX_IMAGE_HEIGHT HDR_MAX_IMAGE_WIDTH
#endif

typedef unsigned char uchar;

Q_LOGGING_CATEGORY(HDRPLUGIN, "kf.imageformats.plugins.hdr", QtWarningMsg)

#define MAXLINE 1024
#define MINELEN 8 // minimum scanline length for encoding
#define MAXELEN 0x7fff // maximum scanline length for encoding

class Header
{
public:
    Header()
    {
        m_colorSpace = QColorSpace(QColorSpace::SRgbLinear);
        m_transformation = QImageIOHandler::TransformationNone;
    }
    Header(const Header&) = default;
    Header& operator=(const Header&) = default;

    bool isValid() const
    {
        return width() > 0 && height() > 0 && width() <= HDR_MAX_IMAGE_WIDTH && height() <= HDR_MAX_IMAGE_HEIGHT;
    }
    qint32 width() const { return m_size.width(); }
    qint32 height() const { return m_size.height(); }
    QString software() const { return m_software; }
    QImageIOHandler::Transformations transformation() const { return m_transformation; }

    /*!
     * \brief colorSpace
     *
     * The color space for the image.
     *
     * The CIE (x,y) chromaticity coordinates of the three (RGB)
     * primaries and the white point used to standardize the picture's
     * color system. This is used mainly by the ra_xyze program to
     * convert between color systems. If no PRIMARIES line
     * appears, we assume the standard primaries defined in
     * src/common/color.h, namely "0.640 0.330 0.290
     * 0.600 0.150 0.060 0.333 0.333" for red, green, blue
     * and white, respectively.
     */
    QColorSpace colorSpace() const { return m_colorSpace; }

    /*!
     * \brief exposure
     *
     * A single floating point number indicating a multiplier that has
     * been applied to all the pixels in the file. EXPOSURE values are
     * cumulative, so the original pixel values (i.e., radiances in
     * watts/steradian/m^2) must be derived by taking the values in the
     * file and dividing by all the EXPOSURE settings multiplied
     * together. No EXPOSURE setting implies that no exposure
     * changes have taken place.
     */
    float exposure() const {
        float mul = 1;
        for (auto&& v : m_exposure)
            mul *= v;
        return mul;
    }

    QImageIOHandler::Transformations m_transformation;
    QColorSpace m_colorSpace;
    QString m_software;
    QSize m_size;
    QList<float> m_exposure;
};

class HDRHandlerPrivate
{
public:
    HDRHandlerPrivate()
    {
    }
    ~HDRHandlerPrivate()
    {
    }

    const Header& header(QIODevice *device)
    {
        auto&& h = m_header;
        if (h.isValid()) {
            return h;
        }
        h = readHeader(device);
        return h;
    }

    static Header readHeader(QIODevice *device)
    {
        Header h;

        int cnt = 0;
        int len = 0;
        QByteArray line(MAXLINE, char());
        QByteArray format;

        // Parse header
        do {
            len = device->readLine(line.data(), line.size());
            if (len < 0) {
                break;
            }
            if (line.startsWith("FORMAT=")) {
                format = line.mid(7, len - 7).trimmed();
            }
            if (line.startsWith("SOFTWARE=")) {
                h.m_software = QString::fromUtf8(line.mid(9, len - 9)).trimmed();
            }
            if (line.startsWith("EXPOSURE=")) {
                auto ok = false;
                auto ex = QLocale::c().toFloat(QString::fromLatin1(line.mid(9, len - 9)).trimmed(), &ok);
                if (ok)
                    h.m_exposure << ex;
            }
            if (line.startsWith("PRIMARIES=")) {
                auto list = line.mid(10, len - 10).trimmed().split(' ');
                QList<double> primaries;
                for (auto&& v : list) {
                    auto ok = false;
                    auto d = QLocale::c().toDouble(QString::fromLatin1(v), &ok);
                    if (ok)
                        primaries << d;
                }
                if (primaries.size() == 8) {
                    auto cs = QColorSpace(QPointF(primaries.at(6), primaries.at(7)),
                                          QPointF(primaries.at(0), primaries.at(1)),
                                          QPointF(primaries.at(2), primaries.at(3)),
                                          QPointF(primaries.at(4), primaries.at(5)),
                                          QColorSpace::TransferFunction::Linear);
                    cs.setDescription(QStringLiteral("Embedded RGB"));
                    if (cs.isValid())
                        h.m_colorSpace = cs;
                }
            }

        } while ((len > 0) && (line[0] != '\n') && (cnt++ < 128));

        if (format != "32-bit_rle_rgbe") {
            qCDebug(HDRPLUGIN) << "Unknown HDR format:" << format;
            return h;
        }

        len = device->readLine(line.data(), line.size());
        if (len < 0) {
            qCDebug(HDRPLUGIN) << "Invalid HDR file, error while reading the first line after the header";
            return h;
        }
        line.resize(len);

        /*
         * Handle flipping and rotation, as per the spec below.
         * The single resolution line consists of 4 values, a X and Y label each followed by a numerical
         * integer value. The X and Y are immediately preceded by a sign which can be used to indicate
         * flipping, the order of the X and Y indicate rotation. The standard coordinate system for
         * Radiance images would have the following resolution string -Y N +X N. This indicates that the
         * vertical axis runs down the file and the X axis is to the right (imagining the image as a
         * rectangular block of data). A -X would indicate a horizontal flip of the image. A +Y would
         * indicate a vertical flip. If the X value appears before the Y value then that indicates that
         * the image is stored in column order rather than row order, that is, it is rotated by 90 degrees.
         * The reader can convince themselves that the 8 combinations cover all the possible image orientations
         * and rotations.
         */
        QRegularExpression resolutionRegExp(QStringLiteral("([+\\-][XY])\\s+([0-9]+)\\s+([+\\-][XY])\\s+([0-9]+)\n"));
        QRegularExpressionMatch match = resolutionRegExp.match(QString::fromLatin1(line));
        if (!match.hasMatch()) {
            qCDebug(HDRPLUGIN) << "Invalid HDR file, the first line after the header didn't have the expected format:" << line;
            return h;
        }

        auto c0 = match.captured(1);
        auto c1 = match.captured(3);
        if (c0.at(1) == u'Y') {
            if (c0.at(0) == u'-' && c1.at(0) == u'+')
                h.m_transformation = QImageIOHandler::TransformationNone;
            if (c0.at(0) == u'-' && c1.at(0) == u'-')
                h.m_transformation = QImageIOHandler::TransformationMirror;
            if (c0.at(0) == u'+' && c1.at(0) == u'+')
                h.m_transformation = QImageIOHandler::TransformationFlip;
            if (c0.at(0) == u'+' && c1.at(0) == u'-')
                h.m_transformation = QImageIOHandler::TransformationRotate180;
        }
        else {
            if (c0.at(0) == u'-' && c1.at(0) == u'+')
                h.m_transformation = QImageIOHandler::TransformationRotate90;
            if (c0.at(0) == u'-' && c1.at(0) == u'-')
                h.m_transformation = QImageIOHandler::TransformationMirrorAndRotate90;
            if (c0.at(0) == u'+' && c1.at(0) == u'+')
                h.m_transformation = QImageIOHandler::TransformationFlipAndRotate90;
            if (c0.at(0) == u'+' && c1.at(0) == u'-')
                h.m_transformation = QImageIOHandler::TransformationRotate270;
        }

        h.m_size = QSize(match.captured(4).toInt(), match.captured(2).toInt());
        return h;
    }

private:
    Header m_header;
};

// read an old style line from the hdr image file
// if 'first' is true the first byte is already read
static bool Read_Old_Line(uchar *image, int width, QDataStream &s)
{
    int rshift = 0;
    int i;

    uchar *start = image;
    while (width > 0) {
        s >> image[0];
        s >> image[1];
        s >> image[2];
        s >> image[3];

        if (s.status() != QDataStream::Ok) {
            return false;
        }

        if ((image[0] == 1) && (image[1] == 1) && (image[2] == 1)) {
            // NOTE: we don't have an image sample that cover this code
            if (rshift > 31) {
                return false;
            }
            for (i = image[3] << rshift; i > 0 && width > 0; i--) {
                if (image == start) {
                    return false; // you cannot be here at the first run
                }
                // memcpy(image, image-4, 4);
                (uint &)image[0] = (uint &)image[0 - 4];
                image += 4;
                width--;
            }
            rshift += 8;
        } else {
            image += 4;
            width--;
            rshift = 0;
        }
    }
    return true;
}

template<class float_T>
void RGBE_To_QRgbLine(uchar *image, float_T *scanline, const Header& h)
{
    auto exposure = h.exposure();
    for (int j = 0, width = h.width(); j < width; j++) {
        // v = ldexp(1.0, int(image[3]) - 128);
        float v;
        int e = qBound(-31, int(image[3]) - 128, 31);
        if (e > 0) {
            v = float(1 << e);
        } else {
            v = 1.0f / float(1 << -e);
        }

        auto j4 = j * 4;
        auto vn = v / 255.0f;
        if (exposure > 0) {
            vn /= exposure;
        }

        scanline[j4] = float_T(float(image[0]) * vn);
        scanline[j4 + 1] = float_T(float(image[1]) * vn);
        scanline[j4 + 2] = float_T(float(image[2]) * vn);
        scanline[j4 + 3] = float_T(1.0f);
        image += 4;
    }
}

QImage::Format imageFormat()
{
#ifdef HDR_HALF_QUALITY
    return QImage::Format_RGBX16FPx4;
#else
    return QImage::Format_RGBX32FPx4;
#endif
}

// Load the HDR image.
static bool LoadHDR(QDataStream &s, const Header& h, QImage &img)
{
    uchar val;
    uchar code;

    const int width = h.width();
    const int height = h.height();

    // Create dst image.
    img = imageAlloc(width, height, imageFormat());
    if (img.isNull()) {
        qCDebug(HDRPLUGIN) << "Couldn't create image with size" << width << height << "and format RGB32";
        return false;
    }

    QByteArray lineArray(4 * width, char());
    uchar *image = reinterpret_cast<uchar *>(lineArray.data());

    for (int cline = 0; cline < height; cline++) {
#ifdef HDR_HALF_QUALITY
        auto scanline = reinterpret_cast<qfloat16 *>(img.scanLine(cline));
#else
        auto scanline = reinterpret_cast<float *>(img.scanLine(cline));
#endif

        // determine scanline type
        if ((width < MINELEN) || (MAXELEN < width)) {
            if (!Read_Old_Line(image, width, s)) {
                return false;
            }
            RGBE_To_QRgbLine(image, scanline, h);
            continue;
        }

        s >> val;

        if (s.status() != QDataStream::Ok) {
            return false;
        }

        if (val != 2) {
            s.device()->ungetChar(val);
            if (!Read_Old_Line(image, width, s)) {
                return false;
            }
            RGBE_To_QRgbLine(image, scanline, h);
            continue;
        }

        s >> image[1];
        s >> image[2];
        s >> image[3];

        if (s.status() != QDataStream::Ok) {
            return false;
        }

        if ((image[1] != 2) || (image[2] & 128)) {
            image[0] = 2;
            if (!Read_Old_Line(image + 4, width - 1, s)) {
                return false;
            }
            RGBE_To_QRgbLine(image, scanline, h);
            continue;
        }

        if ((image[2] << 8 | image[3]) != width) {
            qCDebug(HDRPLUGIN) << "Line of pixels had width" << (image[2] << 8 | image[3]) << "instead of" << width;
            return false;
        }

        // read each component
        for (int i = 0, len = int(lineArray.size()); i < 4; i++) {
            for (int j = 0; j < width;) {
                s >> code;
                if (s.status() != QDataStream::Ok) {
                    qCDebug(HDRPLUGIN) << "Truncated HDR file";
                    return false;
                }
                if (code > 128) {
                    // run
                    code &= 127;
                    s >> val;
                    while (code != 0) {
                        auto idx = i + j * 4;
                        if (idx < len) {
                            image[idx] = val;
                        }
                        j++;
                        code--;
                    }
                } else {
                    // non-run
                    while (code != 0) {
                        auto idx = i + j * 4;
                        if (idx < len) {
                            s >> image[idx];
                        }
                        j++;
                        code--;
                    }
                }
            }
        }
        RGBE_To_QRgbLine(image, scanline, h);
    }

    return true;
}

bool HDRHandler::read(QImage *outImage)
{
    QDataStream s(device());

    const Header& h = d->header(s.device());
    if (!h.isValid()) {
        return false;
    }

    QImage img;
    if (!LoadHDR(s, h, img)) {
        // qCWarning(HDRPLUGIN) << "Error loading HDR file.";
        return false;
    }

    // By setting the linear color space, programs that support profiles display HDR files as in GIMP and Photoshop.
    img.setColorSpace(h.colorSpace());

    // Metadata
    if (!h.software().isEmpty()) {
        img.setText(QStringLiteral(META_KEY_SOFTWARE), h.software());
    }

    *outImage = img;
    return true;
}

bool HDRHandler::supportsOption(ImageOption option) const
{
    if (option == QImageIOHandler::Size) {
        return true;
    }
    if (option == QImageIOHandler::ImageFormat) {
        return true;
    }
    if (option == QImageIOHandler::ImageTransformation) {
        return true;
    }
    return false;
}

QVariant HDRHandler::option(ImageOption option) const
{
    QVariant v;

    if (option == QImageIOHandler::Size) {
        if (auto dev = device()) {
            auto&& h = d->header(dev);
            if (h.isValid()) {
                v = QVariant::fromValue(h.m_size);
            }
        }
    }

    if (option == QImageIOHandler::ImageFormat) {
        v = QVariant::fromValue(imageFormat());
    }

    if (option == QImageIOHandler::ImageTransformation) {
        if (auto dev = device()) {
            auto&& h = d->header(dev);
            if (h.isValid()) {
                v = QVariant::fromValue(h.transformation());
            }
        }
    }

    return v;
}

HDRHandler::HDRHandler()
    : QImageIOHandler()
    , d(new HDRHandlerPrivate)
{
}

bool HDRHandler::canRead() const
{
    if (canRead(device())) {
        setFormat("hdr");
        return true;
    }
    return false;
}

bool HDRHandler::canRead(QIODevice *device)
{
    if (!device) {
        qCWarning(HDRPLUGIN) << "HDRHandler::canRead() called with no device";
        return false;
    }

    // the .pic taken from official test cases does not start with this string but can be loaded.
    if (device->peek(11) == "#?RADIANCE\n" || device->peek(7) == "#?RGBE\n") {
        return true;
    }

    // allow to load official test cases: https://radsite.lbl.gov/radiance/framed.html
    device->startTransaction();
    auto h = HDRHandlerPrivate::readHeader(device);
    device->rollbackTransaction();
    if (h.isValid()) {
        return true;
    }

    return false;
}

QImageIOPlugin::Capabilities HDRPlugin::capabilities(QIODevice *device, const QByteArray &format) const
{
    if (format == "hdr") {
        return Capabilities(CanRead);
    }
    if (!format.isEmpty()) {
        return {};
    }
    if (!device->isOpen()) {
        return {};
    }

    Capabilities cap;
    if (device->isReadable() && HDRHandler::canRead(device)) {
        cap |= CanRead;
    }
    return cap;
}

QImageIOHandler *HDRPlugin::create(QIODevice *device, const QByteArray &format) const
{
    QImageIOHandler *handler = new HDRHandler;
    handler->setDevice(device);
    handler->setFormat(format);
    return handler;
}

#include "moc_hdr_p.cpp"
