/******************************************************************************
 * QSkinny - Copyright (C) 2016 Uwe Rathmann
 * This file may be used under the terms of the QSkinny License, Version 1.0
 *****************************************************************************/

#include "QskBoxMaterial.h"
#include "QskBoxOptions.h"

#include <QSGMaterialShader>
#include <QSGTexture>
#include <QSGGeometryNode>

#include <QGuiApplication>
#include <QImage>
#include <QOpenGLTexture>
#include <QOpenGLContext>
#include <QOpenGLFunctions>
#include <QPainter>
#include <QtMath>

#include <unordered_map>

#define SHADER_S(x) #x
#define SHADER(x) SHADER_S(x)

static qreal qskDevicePixelRatio = 0;

class QskBoxMaterial::TextureData
{
public:
    TextureData() :
        referenceCount( 1 )
    {
    }

    QSizeF size;
    QRectF coordinates;
    QRect bounds;
    qint64 referenceCount;
};

namespace
{
    class TextureCache : public std::unordered_map< uint, QskBoxMaterial::TextureData* >
    {
    public:
        iterator findByData( QskBoxMaterial::TextureData* data )
        {
            auto compare = 
                [ data ] ( const std::pair< uint, QskBoxMaterial::TextureData* >& item )
                {
                    return item.second == data;
                };

            return std::find_if( begin(), end(), compare );
        }
    };

    static TextureCache qskTextureCache;
}

namespace
{
    class TextureAtlas
    {
    public:
        TextureAtlas() :
            m_texture( QOpenGLTexture::Target2D )
        {
            auto context = QOpenGLContext::currentContext();
            Q_ASSERT_X( context, "QskSymbolMaterial",
                "Did you access qskAtlas from the wrong thread? It should be only "
                "called from the Scene Graph thread." );

            QObject::connect( context, &QOpenGLContext::aboutToBeDestroyed,
                &TextureAtlas::cleanup );

            GLint size;
            context->functions()->glGetIntegerv( GL_MAX_TEXTURE_SIZE, &size );
            m_texture.setSize( size, size );
            m_texture.setAutoMipMapGenerationEnabled( false );
            m_texture.setFormat( QOpenGLTexture::RGBA8_UNorm );
            m_texture.setMinMagFilters( QOpenGLTexture::Nearest, QOpenGLTexture::Nearest );
            m_texture.setWrapMode( QOpenGLTexture::ClampToEdge );
            m_texture.allocateStorage( QOpenGLTexture::RGBA, QOpenGLTexture::UInt8 );
        }

        void insert( QskBoxMaterial::TextureData* data );
        void remove( const QskBoxMaterial::TextureData* data );

        inline void bindTexture()
        {
            m_texture.bind();
        }

        inline void releaseTexture()
        {
            m_texture.release();
        }

        static void cleanup();

    private:
        QOpenGLTexture m_texture;
        QRegion m_region;
    };

    Q_GLOBAL_STATIC( TextureAtlas, qskAtlas )

    void TextureAtlas::cleanup()
    {
        if ( qskAtlas.exists() && !qskAtlas.isDestroyed() )
            qskAtlas->m_texture.destroy();
    }

    void TextureAtlas::insert( QskBoxMaterial::TextureData* data )
    {
        const qreal width = data->size.width() * qskDevicePixelRatio;
        const qreal height = data->size.height() * qskDevicePixelRatio;

        const int bucketWidth = int( std::ceil( width ) );
        const int bucketHeight = int( std::ceil( height ) );

        const int boundsWidth = m_texture.width();
        const int boundsHeight = m_texture.height();

        if ( bucketWidth > boundsWidth || bucketHeight > boundsHeight )
        {
            qWarning() << "Requested texture is larger than maximum size";
            return;
        }

        const auto freeRegion = QRegion( 0, 0, boundsWidth, boundsHeight ).subtracted( m_region );
        for ( const auto& rect : freeRegion.rects() )
        {
            const QRect bounds( rect.x(), rect.y(), bucketWidth, bucketHeight );
            if ( rect.contains( bounds ) )
            {
                m_region += bounds;

                data->bounds = bounds;
                data->coordinates = QRectF(
                    qreal( bounds.x() ) / boundsWidth,
                    qreal( bounds.y() ) / boundsHeight,
                    width / boundsWidth,
                    height / boundsHeight );

                return;
            }
        }

        qWarning() << "Texture atlas is full. Check that the application is "
            "releasing unused texture data.";
    }

    void TextureAtlas::remove( const QskBoxMaterial::TextureData* data )
    {
        m_region -= data->bounds;
    }
}

namespace
{
    static QPainterPath qskBorderPath( QMarginsF borders, QMarginsF marginsExtra,
        const QskBoxOptions& options, bool inner )
    {
        qreal topLeftX = options.radius.topLeftX;
        qreal topRightX = options.radius.topRightX;
        qreal bottomRightX = options.radius.bottomRightX;
        qreal bottomLeftX = options.radius.bottomLeftX;
        qreal topLeftY = options.radius.topLeftY;
        qreal topRightY = options.radius.topRightY;
        qreal bottomRightY = options.radius.bottomRightY;
        qreal bottomLeftY = options.radius.bottomLeftY;

        const auto spacingX = marginsExtra.left() + marginsExtra.right();
        const auto spacingY = marginsExtra.top() + marginsExtra.bottom();

        auto width = borders.left() + std::max( topLeftX, bottomLeftX )
            + spacingX + borders.right() + std::max( topRightX, bottomRightX );

        auto height = borders.top() + std::max( topLeftY, topRightY )
            + spacingY + borders.bottom() + std::max( bottomLeftY, bottomRightY );

        if ( inner )
        {
            topLeftX = std::max( topLeftX - borders.left(), 0.0 );
            topLeftY = std::max( topLeftY - borders.top(), 0.0 );
            topRightX = std::max( topRightX - borders.right(), 0.0 );
            topRightY = std::max( topRightY - borders.top(), 0.0 );
            bottomLeftX = std::max( bottomLeftX - borders.left(), 0.0 );
            bottomLeftY = std::max( bottomLeftY - borders.bottom(), 0.0 );
            bottomRightX = std::max( bottomRightX - borders.right(), 0.0 );
            bottomRightY = std::max( bottomRightY - borders.bottom(), 0.0 );
        }
        else
        {
            borders = QMarginsF();
        }

        QPainterPath path;

        path.moveTo( borders.left(), borders.top() + topLeftY );

        path.arcTo( borders.left(), borders.top(), topLeftX * 2, topLeftY * 2, 180, -90 );
        path.lineTo( width - topRightX - borders.right(), path.currentPosition().y() );

        path.arcTo( path.currentPosition().x() - topRightX, path.currentPosition().y(),
            topRightX * 2, topRightY * 2, 90, -90 );
        path.lineTo( path.currentPosition().x(), height - bottomRightY - borders.bottom() );

        path.arcTo( path.currentPosition().x() - bottomRightX * 2,
            path.currentPosition().y() - bottomRightY,
            bottomRightX * 2, bottomRightY * 2, 0, -90 );
        path.lineTo( borders.left() + bottomLeftX, path.currentPosition().y() );

        path.arcTo( path.currentPosition().x() - bottomLeftX,
            path.currentPosition().y() - bottomLeftY * 2,
            bottomLeftX * 2, bottomLeftY * 2, -90, -90 );
        path.closeSubpath();

        return path;
    }

    static inline void swizzle( const QImage& inputImage, QImage& outputImage, int bit )
    {
        Q_ASSERT( inputImage.size() == outputImage.size() );
        for ( int i = 0; i < inputImage.height(); ++i )
        {
            auto input = inputImage.scanLine(i);
            auto output = outputImage.scanLine(i);
            for ( int j = 0; j < inputImage.width(); ++j )
                output[j * 4 + bit] = input[j];
        }
    }

    QImage qskCreateImage( const QSize& size, const QVector< QPainterPath >& paths )
    {
        // Image to contain all layers
        QImage textureImage( size, QImage::Format_RGBA8888 );
        textureImage.fill( Qt::black );

        // Image to render single channels
        QImage channelImage( size, QImage::Format_Alpha8 );
        channelImage.setDevicePixelRatio( qskDevicePixelRatio );

        int currentColor = 0;
        for ( int i = 0; i < paths.size(); i++ )
        {
            channelImage.fill( Qt::transparent );

            QPainter painter( &channelImage );

            if ( size.width() > qskDevicePixelRatio
                 || size.height() > qskDevicePixelRatio )
            {
                painter.setRenderHint( QPainter::Antialiasing );
            }

            QBrush brush( Qt::black );
            if ( i == 2 )
            {
                static QBrush shadowBrush;
                if ( shadowBrush.style() == Qt::NoBrush )
                {
                    QRadialGradient gradient( 0.5, 0.5, 0.5 );
                    gradient.setCoordinateMode( QGradient::ObjectBoundingMode );
                    gradient.setStops( { { 0.0, Qt::black }, { 1.0, Qt::transparent } } );
                    shadowBrush = gradient;
                }

                brush = shadowBrush;
            }

            painter.fillPath( paths[i], brush );
            swizzle( channelImage, textureImage, currentColor++ );

            Q_ASSERT(currentColor < 4);
        }

        return textureImage;
    }

    static QVector< QPainterPath > qskCreateTexturePaths(
        const QskBoxOptions& options )
    {
        QVector< QPainterPath > paths;

        const QMarginsF allMargins = options.unitedMargins();
        const QMarginsF padding = options.padding();

        const QMarginsF extra = allMargins - options.borders - options.shadows - padding;

        const auto width = allMargins.left() + allMargins.right();
        const auto height = allMargins.top() + allMargins.bottom();

        const QSize size( std::ceil( width ), std::ceil( height ) );
        const auto translateX = options.shadows.left() + ( size.width() - width ) * 0.5f;
        const auto translateY = options.shadows.top() + ( size.height() - height ) * 0.5f;

        {
            auto outerPath = qskBorderPath( options.borders, extra, options, false );
            outerPath = outerPath.translated( translateX, translateY );

            paths.append( outerPath );
        }

        if ( !options.borders.isNull() )
        {
            auto innerPath = qskBorderPath( options.borders, extra, options, true );
            innerPath = innerPath.translated( translateX, translateY );

            paths.append( innerPath );
        }
        else
        {
            paths.prepend( QPainterPath() ); // make outerPath the innerPath
        }

        if ( !options.shadows.isNull() )
        {
            auto shadowPath = qskBorderPath(
                options.borders + options.shadows, extra, options, false );

            shadowPath = shadowPath.translated( translateX - options.shadows.left(),
                translateY - options.shadows.top() );

            paths.append( shadowPath );
        }

        return paths;
    }
}

class QskBoxMaterialShader final : public QSGMaterialShader
{
public:
    virtual const char* const* attributeNames() const override final
    {
        static char const* const attributeNames[] =
        {
            "position", "texture", "background", "foreground", nullptr
        };
        return attributeNames;
    }

    virtual void initialize() override final
    {
        qt_Matrix = program()->uniformLocation( "qt_Matrix" );
        qt_Opacity = program()->uniformLocation( "qt_Opacity" );
    }

    virtual void updateState( const RenderState& state,
        QSGMaterial* next, QSGMaterial* prev ) override final
    {
        Q_UNUSED( next );
        if ( !prev )
            qskAtlas->bindTexture();

        if ( state.isOpacityDirty() )
        {
            program()->setUniformValue( qt_Opacity, state.opacity() );
        }

        if ( state.isMatrixDirty() )
        {
            program()->setUniformValue( qt_Matrix, state.combinedMatrix() );
        }
    }

    virtual const char* vertexShader() const override final
    {
        return SHADER(

            uniform highp mat4 qt_Matrix;
            attribute highp vec4 position;
            attribute highp vec2 texture;
            attribute highp vec4 background;
            attribute highp vec4 foreground;
            varying highp vec2 texCoord;
            varying lowp vec4 color0;
            varying lowp vec4 color1;
            varying lowp vec4 color2;

            void main()
            {
                texCoord = texture;
                color0 = foreground.bgra;
                color1 = background.bgra;
                color2 = foreground.bgra;
                gl_Position = qt_Matrix * position;
            }
        );
    }

    virtual const char* fragmentShader() const override final
    {
        return SHADER(

            varying lowp vec4 color0;
            varying lowp vec4 color1;
            varying lowp vec4 color2;
            uniform lowp float qt_Opacity;
            uniform sampler2D texture;
            varying highp vec2 texCoord;

            void main()
            {
                highp vec4 texel = texture2D( texture, texCoord );
                highp vec4 effect = color2 * texel.r;
                highp vec4 bg = color0 * texel.b;
                highp vec4 fg = color1 * texel.g;
                fg = fg + bg * ( 1.0 - fg.a );
                gl_FragColor = ( fg + effect * ( 1.0 - fg.a ) ) * qt_Opacity;
            }
        );
    }

private:
    int qt_Matrix;
    int qt_Opacity;
};

QskBoxMaterial::QskBoxMaterial() :
    m_data( nullptr )
{
    if ( qskDevicePixelRatio == 0 )
        qskDevicePixelRatio = qGuiApp->devicePixelRatio();

    setFlag( Blending );
}

QskBoxMaterial::~QskBoxMaterial()
{
    releaseTexture();
}

bool QskBoxMaterial::isValid() const
{
    return m_data != nullptr;
}

void QskBoxMaterial::setBoxOptions( const QskBoxOptions& options )
{
    const auto key = options.metricsHash();

    auto it = qskTextureCache.find( key );
    if ( it != qskTextureCache.cend() )
    {
        if ( m_data == it->second )
            return;

        // De-ref and possibly release texture
        releaseTexture();

        m_data = it->second;
        ++m_data->referenceCount;
    }
    else
    {
        it = qskTextureCache.emplace( key, new TextureData ).first;
        m_data = it->second;

        const QVector< QPainterPath > paths = qskCreateTexturePaths( options );

        const QMarginsF borders = options.unitedMargins();

        m_data->size = QSizeF( borders.left() + borders.right(),
            borders.top() + borders.bottom() );

        qskAtlas->insert( m_data );

        const QImage textureImage = qskCreateImage( m_data->bounds.size(), paths );

        qskAtlas->bindTexture();

        auto gl = QOpenGLContext::currentContext()->functions();

        gl->glTexSubImage2D( GL_TEXTURE_2D, 0,
            m_data->bounds.x(), m_data->bounds.y(),
            m_data->bounds.width(), m_data->bounds.height(),
            QOpenGLTexture::BGRA, GL_UNSIGNED_BYTE, textureImage.constBits() );

        qskAtlas->releaseTexture();
    }
}

QSizeF QskBoxMaterial::textureSize() const
{
    return m_data ? m_data->size : QSizeF();
}

QRectF QskBoxMaterial::textureCoordinates() const
{
    return m_data ? m_data->coordinates : QRectF();
}

int QskBoxMaterial::compare( const QSGMaterial* other ) const
{
    if ( this == other )
        return 0;

    auto material = static_cast< decltype( this ) >( other );
    if ( textureCoordinates() == material->textureCoordinates() )
        return 0;

    return QSGMaterial::compare( other );
}

void QskBoxMaterial::releaseTexture()
{
    if ( m_data == nullptr || --m_data->referenceCount > 0 )
        return;

    qskTextureCache.erase( qskTextureCache.findByData( m_data ) );
    qskAtlas->remove( m_data );

    delete m_data;
    m_data = nullptr;
}

QSGMaterialShader* QskBoxMaterial::createShader() const
{
    return new QskBoxMaterialShader;
}

QSGMaterialType* QskBoxMaterial::type() const
{
    static QSGMaterialType materialType;
    return &materialType;
}
