/*
 * This file is part of the Amalthea library.
 * Copyright (C) 2020-2021, 2024-2025 Eugene 'Vindex' Stulin
 * Distributed under the BSL 1.0 or the GNU LGPL 3.0 or later.
 */

/**
 * This module provides an implementation of the KOI8R and KOI8U encodings
 * (compatible with std.encoding.EncodingScheme),
 * and the encodeText() function (based on libiconv) to translate text
 * from any encoding to any other encoding.
 */
module amalthea.encoding;

public import amalthea.libcore;
import std.algorithm, std.encoding, std.range, std.string;

shared static this() {
    EncodingScheme.register!EncodingSchemeKOI8R;
    EncodingScheme.register!EncodingSchemeKOI8U;
}

/// Defines a KOI8-R character and string.
enum KOI8RChar : ubyte { init }
alias KOI8RString = immutable(KOI8RChar)[];

/// Defines an KOI8-U character and string.
enum KOI8UChar : ubyte { init }
alias KOI8UString = immutable(KOI8UChar)[];

/** American Standard Code for Information Interchange, 128 bit encoding */
immutable dstring asciiTable = iota!dchar('\x00', '\x80').array;


/*******************************************************************************
 * Abstract base class for all ASCII based encoding schemes of Amalthea.
 */
abstract class AsciiBasedEncoding : std.encoding.EncodingScheme {
    immutable static dchar[ubyte] conversionTableToUTF32;
    immutable static ubyte[dchar] conversionTableFromUTF32;

    const override {
        bool canEncode(dchar c) @safe pure nothrow @nogc {
            return cast(bool)(c in conversionTableFromUTF32);
        }

        size_t encodedLength(dchar c)
        in {
            assert(canEncode(c));
        } do {
            return 1;
        }

        size_t encode(dchar c, ubyte[] buffer)
        in {
            assert(canEncode(c));
            assert(buffer.length > 0);
        } do {
            buffer[0] = conversionTableFromUTF32[c];
            return 1;
        }

        dchar decode(ref const(ubyte)[] s)
        in {
            assert(s.length > 0);
        } do {
            ubyte c = s[0];
            dchar decodedSymbol = conversionTableToUTF32[c];
            s = (s.length == 1) ? [] : s[1 .. $];
            return decodedSymbol;
        }

        dchar safeDecode(ref const(ubyte)[] s)
        in {
            assert(s.length > 0);
        } do {
            dchar decodedSymbol;
            if (!canDecode(s[0])) {
                decodedSymbol = INVALID_SEQUENCE;
            } else {
                ubyte c = s[0];
                decodedSymbol = conversionTableToUTF32[c];
            }
            s = (s.length == 1) ? [] : s[1 .. $];
            return decodedSymbol;
        }

        @property immutable(ubyte)[] replacementSequence() {
            return cast(immutable(ubyte)[])"?";
        }

    }

    bool canDecode(ubyte c) const {
        return cast(bool)(c in conversionTableToUTF32);
    }

    // must be implemented in descendants
    protected immutable dstring specTable;
}


/*******************************************************************************
 * Template for use by all descendants of AsciiBasedEncoding.
 * This template forms conversion tables for current encoding and UTF-32.
 */
mixin template ConversionInjection(CodeUnitType) {
    immutable static dstring table = asciiTable ~ specTable;
    immutable static dchar[CodeUnitType] conversionTableToUTF32;
    immutable static CodeUnitType[dchar] conversionTableFromUTF32;

    shared static this() {
        for (size_t i = 0; i <= 0xFF; i++) {
            dchar ch = EncodingSchemeKOI8R.table[i];
            this.conversionTableToUTF32[cast(CodeUnitType)i] = ch;
            this.conversionTableFromUTF32[ch] = cast(CodeUnitType)i;
        }
    }
}


/*******************************************************************************
 * EncodingScheme to handle KOI8-R.
 */
class EncodingSchemeKOI8R : AsciiBasedEncoding {

    private immutable dstring specTable =
        "\u2500\u2502\u250c\u2510\u2514\u2518\u251c\u2524" ~
        "\u252c\u2534\u253c\u2580\u2584\u2588\u258c\u2590" ~
        "\u2591\u2592\u2593\u2320\u25a0\u2219\u221a\u2248" ~
        "\u2264\u2265\u00a0\u2321\u00b0\u00b2\u00b7\u00f7" ~
        "\u2550\u2551\u2552\u0451\u2553\u2554\u2555\u2556" ~
        "\u2557\u2558\u2559\u255a\u255b\u255c\u255d\u255e" ~
        "\u255f\u2560\u2561\u0401\u2562\u2563\u2564\u2565" ~
        "\u2566\u2567\u2568\u2569\u256a\u256b\u256c\u00a9" ~
        "\u044e\u0430\u0431\u0446\u0434\u0435\u0444\u0433" ~
        "\u0445\u0438\u0439\u043a\u043b\u043c\u043d\u043e" ~
        "\u043f\u044f\u0440\u0441\u0442\u0443\u0436\u0432" ~
        "\u044c\u044b\u0437\u0448\u044d\u0449\u0447\u044a" ~
        "\u042e\u0410\u0411\u0426\u0414\u0415\u0424\u0413" ~
        "\u0425\u0418\u0419\u041a\u041b\u041c\u041d\u041e" ~
        "\u041f\u042f\u0420\u0421\u0422\u0423\u0416\u0412" ~
        "\u042c\u042b\u0417\u0428\u042d\u0429\u0427\u042a";

    mixin ConversionInjection!KOI8RChar;

    override string[] names() @safe pure nothrow const {
        return ["cskoi8r", "koi", "koi8", "koi8-r", "koi8_r"];
    }

    override string toString() const {
        return "KOI8-R";
    }
}


/*******************************************************************************
 * EncodingScheme to handle KOI8-U.
 */
class EncodingSchemeKOI8U : AsciiBasedEncoding {

    private immutable dstring specTable =
        "\u2500\u2502\u250c\u2510\u2514\u2518\u251c\u2524" ~
        "\u252c\u2534\u253c\u2580\u2584\u2588\u258c\u2590" ~
        "\u2591\u2592\u2593\u2320\u25a0\u2219\u221a\u2248" ~
        "\u2264\u2265\u00a0\u2321\u00b0\u00b2\u00b7\u00f7" ~

        "\u2550\u2551\u2552\u0451\u0454\u2554\u0456\u0457" ~
        "\u2557\u2558\u2559\u255a\u255b\u0491\u255d\u255e" ~
        "\u255f\u2560\u2561\u0401\u0404\u2563\u0406\u0407" ~
        "\u2566\u2567\u2568\u2569\u256a\u256b\u0490\u00a9" ~

        "\u044e\u0430\u0431\u0446\u0434\u0435\u0444\u0433" ~
        "\u0445\u0438\u0439\u043a\u043b\u043c\u043d\u043e" ~
        "\u043f\u044f\u0440\u0441\u0442\u0443\u0436\u0432" ~
        "\u044c\u044b\u0437\u0448\u044d\u0449\u0447\u044a" ~
        "\u042e\u0410\u0411\u0426\u0414\u0415\u0424\u0413" ~
        "\u0425\u0418\u0419\u041a\u041b\u041c\u041d\u041e" ~
        "\u041f\u042f\u0420\u0421\u0422\u0423\u0416\u0412" ~
        "\u042c\u042b\u0417\u0428\u042d\u0429\u0427\u042a";

    mixin ConversionInjection!KOI8UChar;

    override string[] names() @safe pure nothrow const {
        return ["koi8-u", "koi8_u"];
    }

    override string toString() const {
        return "KOI8-U";
    }
}


/*******************************************************************************
 * Gets encoded Amalthea ASCII based string from dstring (UTF-32).
 *
 * Params:
 *     s = The UTF32-string for transcoding.
 *     safe = If false, the input has to be valid to avoid mistakes,
 *            if true, inappropriate characters will be replaced with '?'.
 */
AsciiBasedString encodeFromUTF32(AsciiBasedString)(dstring s, bool safe = false)
if (
    is(AsciiBasedString == KOI8RString) || is(AsciiBasedString == KOI8UString)
) {
    static if (is(AsciiBasedString == KOI8RString)) {
        alias Scheme = EncodingSchemeKOI8R;
        alias CodeUnitType = KOI8RChar;
    } else static if (is(AsciiBasedString == KOI8UString)) {
        alias Scheme = EncodingSchemeKOI8U;
        alias CodeUnitType = KOI8UChar;
    }

    CodeUnitType[] line;
    line.length = s.length;
    if (safe) {
        immutable static replacementChar = cast(CodeUnitType)'?';
        foreach(i, ch; s) {
            line[i] = Scheme.conversionTableFromUTF32.get(ch, replacementChar);
        }
    } else {
        foreach(i, ch; s) {
            line[i] = Scheme.conversionTableFromUTF32[ch];
        }
    }
    return line.idup;
}
///
unittest {
    dstring russianText = "Привет, мир!"d;
    KOI8RString koi8rText = encodeFromUTF32!KOI8RString(russianText);
    ubyte[] expected = [
        0xf0, 0xd2, 0xc9, 0xd7, 0xc5, 0xd4, 0x2c, 0x20, 0xcd, 0xc9, 0xd2, 0x21
    ];
    assert(cast(ubyte[])koi8rText == expected);
}


/*******************************************************************************
 * Encodes dstring to KOI8RString or KOI8UString.
 * The input does not have to be valid.
 *
 * Params:
 *     s = The UTF32-string for transcoding.
 */
AsciiBasedString safeEncodeFromUTF32(AsciiBasedString)(dstring s) {
    return encodeFromUTF32!AsciiBasedString(s, true);
}
///
unittest {
    dstring invalidText = "你好，世界！"d;
    KOI8RString koi8rText = safeEncodeFromUTF32!KOI8RString(invalidText);
    assert(cast(ubyte[])koi8rText == ['?', '?', '?', '?', '?', '?']);
}


/*******************************************************************************
 * Convert a string from Amalthea ASCII based encoding to UTF-32.
 *
 * Params:
 *     source = Source string. It must be validly encoded.
 *     dest = Destination string.
 */
void transcode(AsciiBasedString)(AsciiBasedString source, out dstring dest) {
    dchar[] result;
    result.length = source.length;
    foreach(i, ch; source) {
        static if (is(AsciiBasedString == KOI8RString)) {
            result[i] = EncodingSchemeKOI8R.conversionTableToUTF32[ch];
        } else static if (is(AsciiBasedString == KOI8UString)) {
            result[i] = EncodingSchemeKOI8U.conversionTableToUTF32[ch];
        }
    }
    dest = result.idup;
}


/*******************************************************************************
 * Convert a string from Amalthea ASCII based encoding to UTF-8.
 *
 * Params:
 *     source = Source string. It must be validly encoded.
 *     dest = Destination string.
 */
void transcode(AsciiBasedString)(AsciiBasedString source, out string dest) {
    dstring result;
    transcode!AsciiBasedString(source, result);
    dest = result.to!string;
}


/*******************************************************************************
 * Convert a string from UTF-32 to Amalthea ASCII based encoding.
 *
 * Params:
 *     source = Source string. It must be validly encoded.
 *     dest = Destination string.
 */
void transcode(AsciiBasedString)(dstring source, out AsciiBasedString dest) {
    static if (is(AsciiBasedString == KOI8RString)) {
        alias Scheme = EncodingSchemeKOI8R;
        alias CodeUnitType = KOI8RChar;
    } else static if (is(AsciiBasedString == KOI8UString)) {
        alias Scheme = EncodingSchemeKOI8U;
        alias CodeUnitType = KOI8UChar;
    }

    CodeUnitType[] line;
    line.length = source.length;
    foreach(i, ch; source) {
        line[i] = Scheme.conversionTableFromUTF32[ch];
    }
    dest = cast(AsciiBasedString)line;
}


/*******************************************************************************
 * Convert a string from UTF-8 to Amalthea ASCII based encoding.
 *
 * Params:
 *     source = Source string. It must be validly encoded.
 *     dest = Destination string.
 */
void transcode(AsciiBasedString)(string source, out AsciiBasedString dest) {
    transcode!AsciiBasedString(source.to!dstring, dest);
}


/*******************************************************************************
 * A universal string containing string data and information about its encoding.
 */
struct UniString {
    /// Byte string representation.
    protected ubyte[] str;
    /// Name of string encoding.
    protected string encodingName;

    /// Returns a current string as a byte array.
    ubyte[] getRawData() {
        return str;
    }

    /// Returns name of current encoding.
    string getEncodingName() {
        return encodingName;
    }

    /// Creates an instance from an encoded string.
    this(T)(T s) {
        encodingName = getEncodingNameByType!T;
        str = cast(ubyte[])s.dup;
    }

    /// Creates an instance by encoding name and byte representaion.
    this(string encoding, ubyte[] s) {
        encodingName = encoding;
        str = s.dup;
    }

    /// Returns UTF-8 string represenation.
    string toString() const {
        return decodeByEncodingName(str, encodingName);
    }

    /// Returns string of specified encoding by data type in compile time.
    T toEncodedString(T)() {
        string temp = this.toString();
        T result;
        static if (is(T:EncodingSchemeKOI8R) || is(T:EncodingSchemeKOI8U)) {
            amalthea.encoding.transcode(temp, result);
        } else {
            std.encoding.transcode(temp, result);
        }
        return result;
    }

    /// Returns string as byte array of specified encoding by data type.
    ubyte[] toEncodedString(string encodingName) {
        return this.toString.encodeByEncodingName(encodingName);
    }

    /***************************************************************************
     * Recodes current data of this UniString object into a new encoding
     * by the string type passed as template parameter.
     */
    void recode(T)() {
        this = UniString(this.toEncodedString!T);
    }

    /***************************************************************************
     * Recodes current data of this UniString object to new encoding in runtime.
     *
     * Params:
     *     encodingName = Name of new encoding of this string.
     */
    void recode(string encodingName) {
        str = this.toEncodedString(encodingName);
        this.encodingName = encodingName;
    }

}


/// Decodes to UTF-8 string from byte representaion by encoding name.
string decodeByEncodingName(const ubyte[] s, string encodingName) {
    alias phTranscode = std.encoding.transcode;
    alias amTranscode = amalthea.encoding.transcode;
    string dest;
    switch(encodingName) {
        case "ASCII":        phTranscode(cast(AsciiString)s, dest);       break;
        case "ISO-8859-1":   phTranscode(cast(Latin1String)s, dest);      break;
        case "ISO-8859-2":   phTranscode(cast(Latin2String)s, dest);      break;
        case "windows-1250": phTranscode(cast(Windows1250String)s, dest); break;
static if (__traits(compiles, Windows1251String)) {
        case "windows-1251": phTranscode(cast(Windows1251String)s, dest); break;
}
        case "windows-1252": phTranscode(cast(Windows1252String)s, dest); break;
        case "UTF-8":        dest = cast(string)s;                        break;
        case "UTF-16":       phTranscode(cast(wstring)s, dest);           break;
        case "UTF-32":       phTranscode(cast(dstring)s, dest);           break;

        case "KOI8-R":       amTranscode(cast(KOI8RString)s, dest);       break;
        case "KOI8-U":       amTranscode(cast(KOI8UString)s, dest);       break;

        default: dest = cast(string)encodeText(s, encodingName, "UTF-8");
    }
    return dest;
}


/// Decodes to any type string from UTF-8 representaion by encoding name.
ubyte[] encodeByEncodingName(string s, string encodingName) {
    alias phTranscode = std.encoding.transcode;
    alias amTranscode = amalthea.encoding.transcode;
    ubyte[] dest;
    void transformData(T)() {
        T temp;
        static if (is(T == KOI8RString) || is(T == KOI8UString)) {
            amTranscode(s, temp);
        } else {
            phTranscode(s, temp);
        }
        dest = cast(ubyte[])temp;
    }
    switch(encodingName) {
        case "ASCII":        transformData!AsciiString();        break;
        case "ISO-8859-1":   transformData!Latin1String();       break;
        case "ISO-8859-2":   transformData!Latin2String();       break;
        case "windows-1250": transformData!Windows1250String();  break;
static if (__traits(compiles, Windows1251String)) {
        case "windows-1251": transformData!Windows1251String();  break;
}
        case "windows-1252": transformData!Windows1252String();  break;
        case "UTF-8":        dest = cast(ubyte[])(s);            break;
        case "UTF-16":       dest = cast(ubyte[])(s.to!wstring); break;
        case "UTF-32":       dest = cast(ubyte[])(s.to!dstring); break;

        case "KOI8-R":       transformData!KOI8RString();        break;
        case "KOI8-U":       transformData!KOI8UString();        break;

        default: dest = encodeText(s, "UTF-8", encodingName);
    }
    return dest;
}


/// Gets encoding name by string type.
string getEncodingNameByType(T)() {
    if (is(T == AsciiString)) {
        return "ASCII";
    } else if (is(T == Latin1String)) {
        return "ISO-8859-1";
    } else if (is(T == Latin2String)) {
        return "ISO-8859-2";
    } else if (is(T == Windows1250String)) {
        return "windows-1250";
    } else if (is(T == Windows1251String)) {
        return "windows-1251";
    } else if (is(T == Windows1252String)) {
        return "windows-1252";
    } else if (is(T == string)) {
        return "UTF-8";
    } else if (is(T == wstring)) {
        return "UTF-16";
    } else if (is(T == dstring)) {
        return "UTF-32";
    }

    if (is(T == KOI8RString)) {
        return "KOI8-R";
    } else if (is(T == KOI8UString)) {
        return "KOI8-U";
    }

    return "";
}


/*
   LIBICONV
*/

import core.stdc.errno;
import amalthea.dataprocessing : makeFilledArray;

private {
    alias iconv_t = void*;
    extern(C) iconv_t iconv_open(const char* tocode, const char* fromcode);
    extern(C) size_t iconv(
        iconv_t cd,
        const ubyte** inbuf,
        size_t* inbytesleft,
        ubyte** outbuf,
        size_t* outbytesleft
    );
    extern(C) int iconv_close(iconv_t cd);
}


/*******************************************************************************
 * The function tries to encode text sequence to new encoding.
 * The convertation is based on libiconv.
 * The list is available with 'iconv --list'.
 *
 * Homepage of libiconv: $(EXT_LINK https://www.gnu.org/software/libiconv/)
 *
 * Params:
 *     seq = Array of characters (string, dstring, KOI8RString, ubyte[], etc.).
 *     encSrc = The start encoding of the transmitted sequence.
 *     encDst = The destination encoding for the returned value.
 */
ubyte[] encodeText(T)(const T[] seq, string encSrc, string encDst) {
    const ubyte[] text = cast(const ubyte[])seq;
    auto conversionDescriptor = iconv_open(encDst.toStringz, encSrc.toStringz);
    if (conversionDescriptor == cast(iconv_t)(-1)) {
        throw new EncodingException("Unavailable conversion");
    }
    scope(exit) iconv_close(conversionDescriptor);

    // large array in reserve
    ubyte[] encodedText = makeFilledArray!ubyte(text.length*4, 0);
    auto bufLen = encodedText.length;
    auto origLen = text.length;

    errno = 0;
    const ubyte* inbufPtr = text.ptr;
    ubyte* outbufPtr = encodedText.ptr;
    auto badChars = iconv(
        conversionDescriptor, &inbufPtr, &origLen, &outbufPtr, &bufLen
    );
    const err = errno;

    if (badChars == size_t.max) {
        if (err == EILSEQ || err == EINVAL) {
            throw new EncodingException("Invalid sequence");
        } else if (err == E2BIG) {  // unattainable situation
            throw new EncodingException("Insufficient buffer size");
        }
    }
    return encodedText.stripRight(0);
}
///
unittest {
    string text = "Привет, мир!";
    ubyte[] koi8rText = encodeText(text, "utf-8", "koi8-r");
    ubyte[] expected = [
        0xf0, 0xd2, 0xc9, 0xd7, 0xc5, 0xd4, 0x2c, 0x20, 0xcd, 0xc9, 0xd2, 0x21
    ];
    assert(koi8rText == expected);

    text = "你好，世界！";
    bool error = false;
    try {
        koi8rText = encodeText(text, "utf-8", "koi8-r");
    } catch (EncodingException e) {
        error = true;
    }
    assert(error);
}
