#include "storage.hpp"

#include <algorithm>
#include <optional>
#include <stdexcept>

#include <osg/Image>
#include <osg/Plane>

#include <components/debug/debuglog.hpp>
#include <components/esm/esmterrain.hpp>
#include <components/esm/util.hpp>
#include <components/esm3/loadland.hpp>
#include <components/esm4/loadland.hpp>
#include <components/esm4/loadltex.hpp>
#include <components/esm4/loadtxst.hpp>
#include <components/misc/resourcehelpers.hpp>
#include <components/misc/strings/algorithm.hpp>
#include <components/vfs/manager.hpp>

#include "gridsampling.hpp"

namespace ESMTerrain
{
    namespace
    {
        UniqueTextureId getTextureIdAt(const LandObject* land, std::size_t x, std::size_t y)
        {
            assert(x < ESM::Land::LAND_TEXTURE_SIZE);
            assert(y < ESM::Land::LAND_TEXTURE_SIZE);

            if (land == nullptr)
                return { 0, 0 };

            const ESM::LandData* data = land->getData(ESM::Land::DATA_VTEX);
            if (data == nullptr)
                return { 0, 0 };

            const std::uint16_t tex = data->getTextures()[y * ESM::Land::LAND_TEXTURE_SIZE + x];
            if (tex == 0)
                return { 0, 0 }; // vtex 0 is always the base texture, regardless of plugin

            return { tex, land->getPlugin() };
        }
    }

    class LandCache
    {
    public:
        explicit LandCache(int offsetX, int offsetY, std::size_t size)
            : mOffsetX(offsetX)
            , mOffsetY(offsetY)
            , mSize(size)
            , mValues(size * size)
        {
        }

        std::optional<const LandObject*> find(int x, int y) const
        {
            const std::size_t index = getIndex(x, y);
            if (const auto& value = mValues[index])
                return value->get();
            return std::nullopt;
        }

        void insert(int x, int y, osg::ref_ptr<const LandObject>&& value)
        {
            const std::size_t index = getIndex(x, y);
            mValues[index] = std::move(value);
        }

    private:
        int mOffsetX;
        int mOffsetY;
        std::size_t mSize;
        std::vector<std::optional<osg::ref_ptr<const LandObject>>> mValues;

        std::size_t getIndex(int x, int y) const
        {
            return normalizeCoordinate(x, mOffsetX) * mSize + normalizeCoordinate(y, mOffsetY);
        }

        std::size_t normalizeCoordinate(int value, int offset) const
        {
            assert(value >= offset);
            assert(value < offset + static_cast<int>(mSize));
            return static_cast<std::size_t>(value - offset);
        }
    };

    LandObject::LandObject(const ESM4::Land& land, int loadFlags)
        : mData(land, loadFlags)
    {
        mEsm4DefaultLayerInfo.mDiffuseMap = land.mDefaultDiffuseMap;
        mEsm4DefaultLayerInfo.mNormalMap = land.mDefaultNormalMap;
    }

    LandObject::LandObject(const ESM::Land& land, int loadFlags)
        : mData(land, loadFlags)
    {
    }

    LandObject::LandObject(const LandObject& /*copy*/, const osg::CopyOp& /*copyOp*/)
    {
        throw std::logic_error("LandObject copy constructor is not implemented");
    }

    const float defaultHeight = ESM::Land::DEFAULT_HEIGHT;

    Storage::Storage(const VFS::Manager* vfs, std::string_view normalMapPattern,
        std::string_view normalHeightMapPattern, bool autoUseNormalMaps, std::string_view specularMapPattern,
        bool autoUseSpecularMaps)
        : mVFS(vfs)
        , mNormalMapPattern(normalMapPattern)
        , mNormalHeightMapPattern(normalHeightMapPattern)
        , mAutoUseNormalMaps(autoUseNormalMaps)
        , mSpecularMapPattern(specularMapPattern)
        , mAutoUseSpecularMaps(autoUseSpecularMaps)
    {
    }

    bool Storage::getMinMaxHeights(float size, const osg::Vec2f& center, ESM::RefId worldspace, float& min, float& max)
    {
        assert(size <= 1 && "Storage::getMinMaxHeights, chunk size should be <= 1 cell");

        osg::Vec2f origin = center - osg::Vec2f(size / 2.f, size / 2.f);

        int cellX = static_cast<int>(std::floor(origin.x()));
        int cellY = static_cast<int>(std::floor(origin.y()));
        osg::ref_ptr<const LandObject> land = getLand(ESM::ExteriorCellLocation(cellX, cellY, worldspace));
        const ESM::LandData* data = land ? land->getData(ESM::Land::DATA_VHGT) : nullptr;
        const int landSize = ESM::getLandSize(worldspace);
        int startRow = static_cast<int>((origin.x() - cellX) * landSize);
        int startColumn = static_cast<int>((origin.y() - cellY) * landSize);

        int endRow = static_cast<int>(startRow + size * (landSize - 1) + 1);
        int endColumn = static_cast<int>(startColumn + size * (landSize - 1) + 1);

        if (data)
        {
            min = std::numeric_limits<float>::max();
            max = -std::numeric_limits<float>::max();
            for (int row = startRow; row < endRow; ++row)
            {
                for (int col = startColumn; col < endColumn; ++col)
                {
                    float h = data->getHeights()[col * landSize + row];
                    if (h > max)
                        max = h;
                    if (h < min)
                        min = h;
                }
            }
            return true;
        }

        min = defaultHeight;
        max = defaultHeight;
        return false;
    }

    void Storage::fixNormal(
        osg::Vec3f& normal, ESM::ExteriorCellLocation cellLocation, int col, int row, LandCache& cache)
    {

        const int landSize = ESM::getLandSize(cellLocation.mWorldspace);

        while (col >= landSize - 1)
        {
            ++cellLocation.mY;
            col -= landSize - 1;
        }
        while (row >= landSize - 1)
        {
            ++cellLocation.mX;
            row -= landSize - 1;
        }
        while (col < 0)
        {
            --cellLocation.mY;
            col += landSize - 1;
        }
        while (row < 0)
        {
            --cellLocation.mX;
            row += landSize - 1;
        }
        const LandObject* land = getLand(cellLocation, cache);
        const ESM::LandData* data = land ? land->getData(ESM::Land::DATA_VNML) : nullptr;
        if (data)
        {
            normal.x() = data->getNormals()[col * landSize * 3 + row * 3];
            normal.y() = data->getNormals()[col * landSize * 3 + row * 3 + 1];
            normal.z() = data->getNormals()[col * landSize * 3 + row * 3 + 2];
            normal.normalize();
        }
        else
            normal = osg::Vec3f(0, 0, 1);
    }

    void Storage::averageNormal(
        osg::Vec3f& normal, ESM::ExteriorCellLocation cellLocation, int col, int row, LandCache& cache)
    {
        osg::Vec3f n1, n2, n3, n4;
        fixNormal(n1, cellLocation, col + 1, row, cache);
        fixNormal(n2, cellLocation, col - 1, row, cache);
        fixNormal(n3, cellLocation, col, row + 1, cache);
        fixNormal(n4, cellLocation, col, row - 1, cache);
        normal = (n1 + n2 + n3 + n4);
        normal.normalize();
    }

    void Storage::fixColour(
        osg::Vec4ub& color, ESM::ExteriorCellLocation cellLocation, int col, int row, LandCache& cache)
    {

        const int landSize = ESM::getLandSize(cellLocation.mWorldspace);

        if (col == landSize - 1)
        {
            ++cellLocation.mY;
            col = 0;
        }
        if (row == landSize - 1)
        {
            ++cellLocation.mX;
            row = 0;
        }
        const LandObject* land = getLand(cellLocation, cache);
        const ESM::LandData* data = land ? land->getData(ESM::Land::DATA_VCLR) : nullptr;
        if (data)
        {
            color.r() = data->getColors()[col * landSize * 3 + row * 3];
            color.g() = data->getColors()[col * landSize * 3 + row * 3 + 1];
            color.b() = data->getColors()[col * landSize * 3 + row * 3 + 2];
        }
        else
        {
            color.r() = 255;
            color.g() = 255;
            color.b() = 255;
        }
    }

    void Storage::fillVertexBuffers(int lodLevel, float size, const osg::Vec2f& center, ESM::RefId worldspace,
        osg::Vec3Array& positions, osg::Vec3Array& normals, osg::Vec4ubArray& colours)
    {
        if (lodLevel < 0 || 63 < lodLevel)
            throw std::invalid_argument("Invalid terrain lod level: " + std::to_string(lodLevel));

        if (size <= 0)
            throw std::invalid_argument("Invalid terrain size: " + std::to_string(size));

        // LOD level n means every 2^n-th vertex is kept
        const std::size_t sampleSize = std::size_t{ 1 } << lodLevel;
        const std::size_t cellSize = static_cast<std::size_t>(ESM::getLandSize(worldspace));
        const std::size_t numVerts = static_cast<std::size_t>(size * (cellSize - 1) / sampleSize) + 1;

        positions.resize(numVerts * numVerts);
        normals.resize(numVerts * numVerts);
        colours.resize(numVerts * numVerts);

        const bool alteration = useAlteration();
        const int landSizeInUnits = ESM::getCellSize(worldspace);
        const osg::Vec2f origin = center - osg::Vec2f(size, size) * 0.5f;
        const int startCellX = static_cast<int>(std::floor(origin.x()));
        const int startCellY = static_cast<int>(std::floor(origin.y()));
        LandCache cache(startCellX - 1, startCellY - 1, static_cast<std::size_t>(std::ceil(size)) + 2);
        std::pair lastCell{ startCellX, startCellY };
        const LandObject* land = getLand(ESM::ExteriorCellLocation(startCellX, startCellY, worldspace), cache);
        const ESM::LandData* heightData = nullptr;
        const ESM::LandData* normalData = nullptr;
        const ESM::LandData* colourData = nullptr;
        bool validHeightDataExists = false;

        if (land != nullptr)
        {
            heightData = land->getData(ESM::Land::DATA_VHGT);
            normalData = land->getData(ESM::Land::DATA_VNML);
            colourData = land->getData(ESM::Land::DATA_VCLR);
            validHeightDataExists = true;
        }

        const auto handleSample = [&](std::size_t cellShiftX, std::size_t cellShiftY, std::size_t row, std::size_t col,
                                      std::size_t vertX, std::size_t vertY) {
            const int cellX = startCellX + static_cast<int>(cellShiftX);
            const int cellY = startCellY + static_cast<int>(cellShiftY);
            const std::pair cell{ cellX, cellY };
            const ESM::ExteriorCellLocation cellLocation(cellX, cellY, worldspace);

            if (lastCell != cell)
            {
                land = getLand(cellLocation, cache);

                heightData = nullptr;
                normalData = nullptr;
                colourData = nullptr;

                if (land != nullptr)
                {
                    heightData = land->getData(ESM::Land::DATA_VHGT);
                    normalData = land->getData(ESM::Land::DATA_VNML);
                    colourData = land->getData(ESM::Land::DATA_VCLR);
                    validHeightDataExists = true;
                }

                lastCell = cell;
            }

            float height = defaultHeight;
            if (heightData != nullptr)
                height = heightData->getHeights()[col * cellSize + row];
            if (alteration)
                height += getAlteredHeight(static_cast<int>(col), static_cast<int>(row));

            const std::size_t vertIndex = vertX * numVerts + vertY;

            positions[vertIndex]
                = osg::Vec3f((vertX / static_cast<float>(numVerts - 1) - 0.5f) * size * landSizeInUnits,
                    (vertY / static_cast<float>(numVerts - 1) - 0.5f) * size * landSizeInUnits, height);

            const std::size_t srcArrayIndex = col * cellSize * 3 + row * 3;

            osg::Vec3f normal(0, 0, 1);

            if (normalData != nullptr)
            {
                for (unsigned short i = 0; i < 3; ++i)
                    normal[i] = normalData->getNormals()[srcArrayIndex + i];

                normal.normalize();
            }

            // Normals apparently don't connect seamlessly between cells
            if (col == cellSize - 1 || row == cellSize - 1)
                fixNormal(normal, cellLocation, static_cast<int>(col), static_cast<int>(row), cache);

            // some corner normals appear to be complete garbage (z < 0)
            if ((row == 0 || row == cellSize - 1) && (col == 0 || col == cellSize - 1))
                averageNormal(normal, cellLocation, static_cast<int>(col), static_cast<int>(row), cache);

            assert(normal.z() > 0);

            normals[vertIndex] = normal;

            osg::Vec4ub color(255, 255, 255, 255);

            if (colourData != nullptr)
                for (unsigned short i = 0; i < 3; ++i)
                    color[i] = colourData->getColors()[srcArrayIndex + i];

            // Does nothing by default, override in OpenMW-CS
            if (alteration)
                adjustColor(static_cast<int>(col), static_cast<int>(row), heightData, color);

            // Unlike normals, colors mostly connect seamlessly between cells, but not always...
            if (col == cellSize - 1 || row == cellSize - 1)
                fixColour(color, cellLocation, static_cast<int>(col), static_cast<int>(row), cache);

            colours[vertIndex] = color;
        };

        const std::size_t beginX = static_cast<std::size_t>((origin.x() - startCellX) * cellSize);
        const std::size_t beginY = static_cast<std::size_t>((origin.y() - startCellY) * cellSize);
        const std::size_t distance = static_cast<std::size_t>(size * (cellSize - 1)) + 1;

        sampleCellGrid(cellSize, sampleSize, beginX, beginY, distance, handleSample);

        if (!validHeightDataExists && ESM::isEsm4Ext(worldspace))
            std::fill(positions.begin(), positions.end(), osg::Vec3f());
    }

    VFS::Path::Normalized Storage::getTextureName(UniqueTextureId id)
    {
        std::string_view texture = "_land_default.dds";
        if (id.first != 0)
        {
            // NB: All vtex ids are +1 compared to the ltex ids
            const std::string* ltex = getLandTexture(id.first - 1, id.second);
            if (ltex)
                texture = *ltex;
            else
            {
                Log(Debug::Warning) << "Warning: Unable to find land texture index " << id.first - 1 << " in plugin "
                                    << id.second << ", using default texture instead";
            }
        }
        // this is needed due to MWs messed up texture handling
        return Misc::ResourceHelpers::correctTexturePath(VFS::Path::Normalized(texture), *mVFS);
    }

    void Storage::getEsm4Blendmaps(float chunkSize, const osg::Vec2f& chunkCenter, ImageVector& blendmaps,
        std::vector<Terrain::LayerInfo>& layerList, ESM::RefId worldspace)
    {
        const osg::Vec2f origin = chunkCenter - osg::Vec2f(chunkSize - 1, chunkSize + 1) * 0.5f;
        const int startCellX = static_cast<int>(std::floor(origin.x()));
        const int startCellY = static_cast<int>(std::floor(origin.y()));

        constexpr int quadsPerCell = 2;
        constexpr int quadSize = ESM4::Land::sVertsPerSide / quadsPerCell;
        const int quadCount = static_cast<int>(chunkSize * quadsPerCell);
        assert(quadCount > 0);

        const int blendmapSize = quadCount * quadSize + 1;

        LandCache cache(startCellX - 1, startCellY - 1, static_cast<std::size_t>(std::ceil(chunkSize)) + 2);
        std::pair lastCell{ startCellX, startCellY };
        const LandObject* land = getLand(ESM::ExteriorCellLocation(startCellX, startCellY, worldspace), cache);

        std::map<ESM::FormId, std::size_t> textureIndicesMap;

        auto getOrCreateBlendmap = [&](ESM::FormId texId) -> unsigned char* {
            auto found = textureIndicesMap.find(texId);
            if (found != textureIndicesMap.end())
                return blendmaps[found->second]->data();
            Terrain::LayerInfo info
                = texId.isZeroOrUnset() ? land->getEsm4DefaultLayerInfo() : getLandTextureLayerInfo(texId);
            osg::ref_ptr<osg::Image> image(new osg::Image);
            image->allocateImage(blendmapSize, blendmapSize, 1, GL_ALPHA, GL_UNSIGNED_BYTE);
            std::memset(image->data(), 0, image->getTotalDataSize());
            textureIndicesMap.emplace(texId, blendmaps.size());
            blendmaps.push_back(std::move(image));
            layerList.push_back(std::move(info));
            return blendmaps.back()->data();
        };

        const auto handleSample = [&](const CellSample& sample) {
            const std::pair cell{ sample.mCellX, sample.mCellY };
            if (lastCell != cell)
            {
                land = getLand(ESM::ExteriorCellLocation(sample.mCellX, sample.mCellY, worldspace), cache);
                lastCell = cell;
            }
            if (!land)
                return;
            const ESM::LandData* ldata = land->getData(0);
            if (!ldata)
                return;
            int quad;
            if (sample.mSrcRow == 0)
                quad = sample.mSrcCol == 0 ? 0 : 2;
            else
                quad = sample.mSrcCol == 0 ? 1 : 3;
            const ESM4::Land::Texture& ltex = ldata->getEsm4Texture(quad);

            unsigned char* const baseBlendmap = getOrCreateBlendmap(ESM::FormId::fromUint32(ltex.base.formId));
            int starty = (static_cast<int>(sample.mDstCol) - 1) * quadSize;
            int startx = static_cast<int>(sample.mDstRow) * quadSize;
            for (int y = std::max(0, starty + 1); y <= starty + quadSize && y < blendmapSize; ++y)
            {
                unsigned char* const row = baseBlendmap + y * blendmapSize;
                for (int x = startx; x < startx + quadSize && x < blendmapSize; ++x)
                    row[x] = 255;
            }

            for (const auto& layer : ltex.layers)
            {
                unsigned char* const layerBlendmap = getOrCreateBlendmap(ESM::FormId::fromUint32(layer.texture.formId));
                for (const ESM4::Land::VTXT& v : layer.data)
                {
                    int y = v.position / (quadSize + 1);
                    int x = v.position % (quadSize + 1);
                    if (x == quadSize || startx + x >= blendmapSize || y == 0 || starty + y >= blendmapSize
                        || starty + y < 0)
                    {
                        continue;
                    }
                    size_t index = static_cast<size_t>((starty + y) * blendmapSize + startx + x);
                    auto delta = static_cast<unsigned char>(std::clamp(static_cast<int>(v.opacity * 255.f), 0, 255));
                    baseBlendmap[index] -= std::min(baseBlendmap[index], delta);
                    layerBlendmap[index] = delta;
                }
            }
        };

        sampleBlendmaps(chunkSize, origin.x(), origin.y(), quadsPerCell, handleSample);

        if (blendmaps.size() == 1)
            blendmaps.clear(); // If a single texture fills the whole terrain, there is no need to blend
    }

    void Storage::getBlendmaps(float chunkSize, const osg::Vec2f& chunkCenter, ImageVector& blendmaps,
        std::vector<Terrain::LayerInfo>& layerList, ESM::RefId worldspace)
    {
        if (ESM::isEsm4Ext(worldspace))
        {
            getEsm4Blendmaps(chunkSize, chunkCenter, blendmaps, layerList, worldspace);
            return;
        }

        const osg::Vec2f origin = chunkCenter - osg::Vec2f(chunkSize, chunkSize) * 0.5f;
        const int startCellX = static_cast<int>(std::floor(origin.x()));
        const int startCellY = static_cast<int>(std::floor(origin.y()));
        const std::size_t blendmapSize = getBlendmapSize(chunkSize, ESM::Land::LAND_TEXTURE_SIZE);
        // We need to upscale the blendmap 2x with nearest neighbor sampling to look like Vanilla
        constexpr std::size_t imageScaleFactor = 2;
        const std::size_t blendmapImageSize = blendmapSize * imageScaleFactor;

        std::vector<UniqueTextureId> textureIds(blendmapSize * blendmapSize);
        LandCache cache(startCellX - 1, startCellY - 1, static_cast<std::size_t>(std::ceil(chunkSize)) + 2);
        std::pair lastCell{ startCellX, startCellY };
        const LandObject* land = getLand(ESM::ExteriorCellLocation(startCellX, startCellY, worldspace), cache);

        const auto handleSample = [&](const CellSample& sample) {
            const std::pair cell{ sample.mCellX, sample.mCellY };
            if (lastCell != cell)
            {
                land = getLand(ESM::ExteriorCellLocation(sample.mCellX, sample.mCellY, worldspace), cache);
                lastCell = cell;
            }

            textureIds[sample.mDstCol * blendmapSize + sample.mDstRow]
                = getTextureIdAt(land, sample.mSrcRow, sample.mSrcCol);
        };

        sampleBlendmaps(chunkSize, origin.x(), origin.y(), ESM::Land::LAND_TEXTURE_SIZE, handleSample);

        std::map<UniqueTextureId, std::size_t> textureIndicesMap;

        for (std::size_t y = 0; y < blendmapSize; ++y)
        {
            for (std::size_t x = 0; x < blendmapSize; ++x)
            {
                const UniqueTextureId id = textureIds[y * blendmapSize + x];
                auto found = textureIndicesMap.find(id);
                if (found == textureIndicesMap.end())
                {
                    std::size_t layerIndex = layerList.size();
                    Terrain::LayerInfo info = getLayerInfo(getTextureName(id));

                    // look for existing diffuse map, which may be present when several plugins use the same texture
                    for (std::size_t i = 0; i < layerList.size(); ++i)
                    {
                        if (layerList[i].mDiffuseMap == info.mDiffuseMap)
                        {
                            layerIndex = i;
                            break;
                        }
                    }

                    found = textureIndicesMap.emplace(id, layerIndex).first;

                    if (layerIndex >= layerList.size())
                    {
                        osg::ref_ptr<osg::Image> image(new osg::Image);
                        image->allocateImage(static_cast<int>(blendmapImageSize), static_cast<int>(blendmapImageSize),
                            1, GL_ALPHA, GL_UNSIGNED_BYTE);
                        std::memset(image->data(), 0, image->getTotalDataSize());
                        blendmaps.push_back(std::move(image));
                        layerList.push_back(std::move(info));
                    }
                }
                const std::size_t layerIndex = found->second;
                unsigned char* const data = blendmaps[layerIndex]->data();
                const std::size_t realY = y * imageScaleFactor;
                const std::size_t realX = x * imageScaleFactor;
                data[((realY + 0) * blendmapImageSize + realX + 0)] = 255;
                data[((realY + 1) * blendmapImageSize + realX + 0)] = 255;
                data[((realY + 0) * blendmapImageSize + realX + 1)] = 255;
                data[((realY + 1) * blendmapImageSize + realX + 1)] = 255;
            }
        }

        if (blendmaps.size() == 1)
            blendmaps.clear(); // If a single texture fills the whole terrain, there is no need to blend
    }

    float Storage::getHeightAt(const osg::Vec3f& worldPos, ESM::RefId worldspace)
    {
        const float cellSize = static_cast<float>(ESM::getCellSize(worldspace));
        int cellX = static_cast<int>(std::floor(worldPos.x() / cellSize));
        int cellY = static_cast<int>(std::floor(worldPos.y() / cellSize));

        osg::ref_ptr<const LandObject> land = getLand(ESM::ExteriorCellLocation(cellX, cellY, worldspace));
        if (!land)
            return ESM::isEsm4Ext(worldspace) ? std::numeric_limits<float>::lowest() : defaultHeight;

        const ESM::LandData* data = land->getData(ESM::Land::DATA_VHGT);
        if (!data)
            return defaultHeight;
        const int landSize = data->getLandSize();

        // Mostly lifted from Ogre::Terrain::getHeightAtTerrainPosition

        // Normalized position in the cell
        float nX = (worldPos.x() - (cellX * cellSize)) / cellSize;
        float nY = (worldPos.y() - (cellY * cellSize)) / cellSize;

        // get left / bottom points (rounded down)
        float factor = landSize - 1.0f;
        float invFactor = 1.0f / factor;

        int startX = static_cast<int>(nX * factor);
        int startY = static_cast<int>(nY * factor);
        int endX = startX + 1;
        int endY = startY + 1;

        endX = std::min(endX, landSize - 1);
        endY = std::min(endY, landSize - 1);

        // now get points in terrain space (effectively rounding them to boundaries)
        float startXTS = startX * invFactor;
        float startYTS = startY * invFactor;
        float endXTS = endX * invFactor;
        float endYTS = endY * invFactor;

        // get parametric from start coord to next point
        float xParam = (nX - startXTS) * factor;
        float yParam = (nY - startYTS) * factor;

        /* For even / odd tri strip rows, triangles are this shape:
        even     odd
        3---2   3---2
        | / |   | \ |
        0---1   0---1
        */

        // Build all 4 positions in normalized cell space, using point-sampled height
        osg::Vec3f v0(startXTS, startYTS, getVertexHeight(data, startX, startY) / cellSize);
        osg::Vec3f v1(endXTS, startYTS, getVertexHeight(data, endX, startY) / cellSize);
        osg::Vec3f v2(endXTS, endYTS, getVertexHeight(data, endX, endY) / cellSize);
        osg::Vec3f v3(startXTS, endYTS, getVertexHeight(data, startX, endY) / cellSize);
        // define this plane in terrain space
        osg::Plane plane;
        // FIXME: deal with differing triangle alignment
        if (true)
        {
            // odd row
            bool secondTri = ((1.0 - yParam) > xParam);
            if (secondTri)
                plane = osg::Plane(v0, v1, v3);
            else
                plane = osg::Plane(v1, v2, v3);
        }
        /*
        else
        {
            // even row
            bool secondTri = (yParam > xParam);
            if (secondTri)
                plane.redefine(v0, v2, v3);
            else
                plane.redefine(v0, v1, v2);
        }
        */

        // Solve plane equation for z
        return static_cast<float>(
            (-plane.getNormal().x() * nX - plane.getNormal().y() * nY - plane[3]) / plane.getNormal().z() * cellSize);
    }

    const LandObject* Storage::getLand(ESM::ExteriorCellLocation cellLocation, LandCache& cache)
    {
        if (const auto land = cache.find(cellLocation.mX, cellLocation.mY))
            return *land;
        osg::ref_ptr<const LandObject> land = getLand(cellLocation);
        const LandObject* result = land.get();
        cache.insert(cellLocation.mX, cellLocation.mY, std::move(land));
        return result;
    }

    void Storage::adjustColor(int col, int row, const ESM::LandData* heightData, osg::Vec4ub& color) const {}

    float Storage::getAlteredHeight(int col, int row) const
    {
        return 0;
    }

    Terrain::LayerInfo Storage::getLayerInfo(VFS::Path::NormalizedView texture)
    {
        std::lock_guard<std::mutex> lock(mLayerInfoMutex);

        // Already have this cached?
        const auto it = mLayerInfoMap.find(texture);
        if (it != mLayerInfoMap.end())
            return it->second;

        Terrain::LayerInfo info;
        info.mParallax = false;
        info.mSpecular = false;
        info.mDiffuseMap = texture;

        if (mAutoUseNormalMaps)
        {
            std::string normalHeightMapValue(texture.value());
            Misc::StringUtils::replaceLast(normalHeightMapValue, ".", mNormalHeightMapPattern + ".");
            VFS::Path::Normalized normalHeightMap(std::move(normalHeightMapValue));
            if (mVFS->exists(normalHeightMap))
            {
                info.mNormalMap = std::move(normalHeightMap);
                info.mParallax = true;
            }
            else
            {
                std::string normalMapValue(texture.value());
                Misc::StringUtils::replaceLast(normalMapValue, ".", mNormalMapPattern + ".");
                VFS::Path::Normalized normalMap(std::move(normalMapValue));
                if (mVFS->exists(normalMap))
                    info.mNormalMap = std::move(normalMap);
            }
        }

        if (mAutoUseSpecularMaps)
        {
            std::string specularMapValue(texture.value());
            Misc::StringUtils::replaceLast(specularMapValue, ".", mSpecularMapPattern + ".");
            VFS::Path::Normalized specularMap(std::move(specularMapValue));
            if (mVFS->exists(specularMap))
            {
                info.mDiffuseMap = std::move(specularMap);
                info.mSpecular = true;
            }
        }

        mLayerInfoMap.emplace_hint(it, texture, info);

        return info;
    }

    Terrain::LayerInfo Storage::getLandTextureLayerInfo(ESM::FormId id)
    {
        if (const ESM4::LandTexture* ltex = getEsm4LandTexture(id))
        {
            if (!ltex->mTextureFile.empty())
            {
                constexpr VFS::Path::NormalizedView landscape("textures/landscape");
                return getLayerInfo(VFS::Path::join(landscape, ltex->mTextureFile)); // TES4
            }
            if (const ESM4::TextureSet* txst = getEsm4TextureSet(ltex->mTexture))
                return getTextureSetLayerInfo(*txst); // TES5
            else
                Log(Debug::Warning) << "TextureSet not found: " << ltex->mTexture.toString();
        }
        else
            Log(Debug::Warning) << "LandTexture not found: " << id.toString();
        return getLayerInfo(VFS::Path::NormalizedView());
    }

    Terrain::LayerInfo Storage::getTextureSetLayerInfo(const ESM4::TextureSet& txst)
    {
        Terrain::LayerInfo info;

        assert(!txst.mDiffuse.empty() && "getlayerInfo: empty diffuse map");

        constexpr VFS::Path::NormalizedView textures("textures");

        info.mDiffuseMap = VFS::Path::join(textures, txst.mDiffuse);

        if (!txst.mNormalMap.empty())
            info.mNormalMap = VFS::Path::join(textures, txst.mNormalMap);

        // FIXME: this flag indicates height info in alpha channel of normal map
        //        but the normal map alpha channel has specular info instead
        //        (probably needs some flag in the terrain shader to fix)
        info.mParallax = false;
        // FIXME: this flag indicates specular info in alpha channel of diffuse
        //        but the diffuse alpha channel has transparency data instead
        //        (probably needs some flag in the terrain shader to fix)
        info.mSpecular = false;

        // FIXME: should support other features of ESM4::TextureSet
        //        probably need corresponding support in the terrain shader

        return info;
    }

    float Storage::getCellWorldSize(ESM::RefId worldspace)
    {
        return static_cast<float>(ESM::getCellSize(worldspace));
    }

    int Storage::getCellVertices(ESM::RefId worldspace)
    {
        return ESM::getLandSize(worldspace);
    }

    int Storage::getTextureTileCount(float chunkSize, ESM::RefId worldspace)
    {
        if (ESM::isEsm4Ext(worldspace))
            return static_cast<int>(2 * ESM4::Land::sQuadTexturePerSide * chunkSize);
        else
            return static_cast<int>(ESM::Land::LAND_TEXTURE_SIZE * chunkSize);
    }

}
