package ru.yandex.webmaster3.storage.util.clickhouse2;

import org.apache.commons.io.IOUtils;
import org.apache.commons.lang3.ArrayUtils;
import org.apache.commons.lang3.StringUtils;
import org.joda.time.DateTime;
import org.joda.time.DateTimeZone;
import org.joda.time.Instant;
import org.joda.time.LocalDate;
import org.joda.time.format.DateTimeFormat;
import org.joda.time.format.ISODateTimeFormat;
import ru.yandex.webmaster3.core.util.enums.IntEnum;
import ru.yandex.webmaster3.core.util.enums.IntEnumResolver;
import ru.yandex.webmaster3.core.data.WebmasterHostId;
import ru.yandex.webmaster3.core.util.IdUtils;

import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.nio.charset.Charset;
import java.nio.charset.StandardCharsets;
import java.util.ArrayList;
import java.util.EnumSet;
import java.util.List;
import java.util.Optional;
import java.util.UUID;

/**
 * @author aherman
 */
public class CHRow {
    private final LineInputStream.Line line;
    private final int[] lineFiledEnd;
    private final String[] columnNames;
    private final CHType[] columnTypes;

    /**
     * Костыль для обхода нулевых дат ClickHouse. см:
     * Дата-с-временем. Хранится в 4 байтах, в виде (беззнакового) unix timestamp. Позволяет хранить значения в том же
     * интервале, что и для типа Date. Минимальное значение выводится как 0000-00-00 00:00:00.
     * Время хранится с точностью до одной секунды (без учёта секунд координации).
     */
    private static final String NULL_DATE_STRING = "0000-00-00 00:00:00";
    public static final DateTime NULL_DATE = new DateTime(0L);

    public CHRow(LineInputStream.Line line, String[] columnNames, CHType[] columnTypes, int[] lineFiledEnd) {
        this.columnNames = columnNames;
        this.columnTypes = columnTypes;
        this.line = line;
        this.lineFiledEnd = lineFiledEnd;
    }

    protected static EnumSet<CHPrimitiveType> INT_CONVERTIBLE = EnumSet.of(
            CHPrimitiveType.UInt8, CHPrimitiveType.UInt16,
            CHPrimitiveType.Int8, CHPrimitiveType.Int16, CHPrimitiveType.Int32
    );

    protected static EnumSet<CHPrimitiveType> INT_UNSAFE_CONVERTIBLE = EnumSet.of(
            CHPrimitiveType.UInt8, CHPrimitiveType.UInt16, CHPrimitiveType.UInt32,
            CHPrimitiveType.Int8, CHPrimitiveType.Int16, CHPrimitiveType.Int32
    );

    protected static EnumSet<CHPrimitiveType> LONG_CONVERTIBLE = EnumSet.of(
            CHPrimitiveType.UInt8, CHPrimitiveType.UInt16, CHPrimitiveType.UInt32,
            CHPrimitiveType.Int8, CHPrimitiveType.Int16, CHPrimitiveType.Int32, CHPrimitiveType.Int64
    );

    protected static EnumSet<CHPrimitiveType> LONG_UNSAFE_CONVERTIBLE = EnumSet.of(
            CHPrimitiveType.UInt8, CHPrimitiveType.UInt16, CHPrimitiveType.UInt32, CHPrimitiveType.UInt64,
            CHPrimitiveType.Int8, CHPrimitiveType.Int16, CHPrimitiveType.Int32, CHPrimitiveType.Int64
    );

    protected static EnumSet<CHPrimitiveType> FLOAT_CONVERTIBLE = EnumSet.of(
            CHPrimitiveType.UInt8, CHPrimitiveType.UInt16,
            CHPrimitiveType.Int8, CHPrimitiveType.Int16, CHPrimitiveType.Int32,
            CHPrimitiveType.Float32
    );

    protected static EnumSet<CHPrimitiveType> DOUBLE_CONVERTIBLE = EnumSet.of(
            CHPrimitiveType.UInt8, CHPrimitiveType.UInt16, CHPrimitiveType.UInt32,
            CHPrimitiveType.Int8, CHPrimitiveType.Int16, CHPrimitiveType.Int32, CHPrimitiveType.Int64,
            CHPrimitiveType.Float32, CHPrimitiveType.Float64
    );

    public WebmasterHostId getHostId(String name) {
        return IdUtils.stringToHostId(getString(name));
    }

    public String getString(String name) {
        return getString(name, StandardCharsets.UTF_8);
    }

    public String getString(String name, Charset charset) {
        int i = getColumnId(name);
        return getString(i, charset);
    }

    public byte[] getBytes(String name) {
        int i = getColumnId(name);
        return getBytes(i);
    }

    public UUID getStringUUID(String name) {
        return UUID.fromString(getString(name));
    }

    /*
    public UUID getBytesUUID(String name) {
        return UUIDUtil.bytesToUUID(getBytes(name));
    }*/

    public String getString(int i) {
        return getString(i, StandardCharsets.UTF_8);
    }

    public String getString(int i, Charset charset) {
        checkFieldExists(i);
        BAInputStream is = getBytesStream(i);
        ByteArrayOutputStream baos = new ByteArrayOutputStream();
        try {
            IOUtils.copy(ClickhouseEscapeUtils.unescape(is), baos);
        } catch (IOException e) {
            // should not happen
            throw new RuntimeException("Unable to read string", e);
        }
        byte[] bytes = baos.toByteArray();
        return new String(bytes, charset);
    }

    public byte[] getBytes(int i) {
        checkFieldExists(i);
        BAInputStream is = getBytesStream(i);
        ByteArrayOutputStream baos = new ByteArrayOutputStream();
        try {
            IOUtils.copy(ClickhouseEscapeUtils.unescape(is), baos);
        } catch (IOException e) {
            // should not happen
            throw new RuntimeException("Unable to read string", e);
        }

        return baos.toByteArray();
    }

    public int getInt(String name) {
        int i = getColumnId(name);
        return getInt(i);
    }

    public int getIntUnsafe(String name) {
        int i = getColumnId(name);
        return getIntUnsafe(i);
    }

    public int getInt(int i) {
        checkFieldExists(i);
        CHType from = columnTypes[i];
        checkConvertible(from, INT_CONVERTIBLE);
        return ByteConvertUtils.parseInt(getBytesStream(i));
    }

    public int getIntUnsafe(int i) {
        checkFieldExists(i);
        CHType from = columnTypes[i];
        checkConvertible(from, INT_UNSAFE_CONVERTIBLE);
        return ByteConvertUtils.parseInt(getBytesStream(i));
    }

    public long getLong(String name) {
        int i = getColumnId(name);
        return getLong(i);
    }

    public long getLongUnsafe(String name) {
        int i = getColumnId(name);
        return getLongUnsafe(i);
    }

    public long getLong(int i) {
        checkFieldExists(i);
        CHType from = columnTypes[i];
        checkConvertible(from, LONG_CONVERTIBLE);
        return ByteConvertUtils.parseLong(getBytesStream(i));
    }

    public long getLongUnsafe(int i) {
        checkFieldExists(i);
        CHType from = columnTypes[i];
        checkConvertible(from, LONG_UNSAFE_CONVERTIBLE);
        if (from == CHPrimitiveType.UInt64) {
            return ByteConvertUtils.parseUnsignedLong(getBytesStream(i));
        }
        return ByteConvertUtils.parseLong(getBytesStream(i));
    }

    public float getFloat(String name) {
        int i = getColumnId(name);
        return getFloat(i);
    }

    public float getFloat(int i) {
        checkFieldExists(i);
        CHType from = columnTypes[i];
        checkConvertible(from, FLOAT_CONVERTIBLE);
        return ByteConvertUtils.parseFloat(getBytesStream(i));
    }

    public double getDouble(String name) {
        int i = getColumnId(name);
        return getDouble(i);
    }

    public double getDouble(int i) {
        checkFieldExists(i);
        CHType from = columnTypes[i];
        checkConvertible(from, DOUBLE_CONVERTIBLE);
        return ByteConvertUtils.parseDouble(getBytesStream(i));
    }

    public LocalDate getLocalDate(String name) {
        int i = getColumnId(name);
        return getLocalDate(i);
    }

    public LocalDate getLocalDate(int i) {
        checkFieldExists(i);
        CHType from = columnTypes[i];
        if (from != CHPrimitiveType.Date && from != CHPrimitiveType.DateTime) {
            return null;
        }
        String string = getString(i, StandardCharsets.US_ASCII);
        return LocalDate.parse(string, ISODateTimeFormat.date());
    }

    public Instant getInstant(String name) {
        int i = getColumnId(name);
        return getInstant(i);
    }

    public Instant getInstant(int i) {
        checkFieldExists(i);
        CHType from = columnTypes[i];
        if (from == CHPrimitiveType.Int64 || from == CHPrimitiveType.UInt64) {
            long unixMilliseconds = getLongUnsafe(i);
            return new Instant(unixMilliseconds);
        }
        if (from == CHPrimitiveType.DateTime) {
            String value = getString(i, StandardCharsets.US_ASCII);
            return DateTime.parse(value, DateTimeFormat.forPattern("yyyy-MM-dd HH:mm:ss").withZoneUTC()).toInstant();
        }
        return null;
    }

    public DateTime getDateTimeFixTimeZone(String name, DateTimeZone writtenInTZ) {
        DateTime result = getDateTime(name);
        return result.toLocalDateTime().toDateTime(writtenInTZ);
    }
    public DateTime getDateTime(String name) {
        int i = getColumnId(name);
        return getDateTime(i);
    }

    public DateTime getDateTime(int i) {
        checkFieldExists(i);
        CHType from = columnTypes[i];
        if (from == CHPrimitiveType.Int64 || from == CHPrimitiveType.UInt64) {
            long unixMilliseconds = getLongUnsafe(i);
            return new Instant(unixMilliseconds).toDateTime().withZone(DateTimeZone.UTC);
        }
        if (from == CHPrimitiveType.DateTime) {
            String value = getString(i, StandardCharsets.US_ASCII);
            // костыль для обхода особенности ClickHouse
            if (NULL_DATE_STRING.equals(value)) {
                return NULL_DATE;
            }
            return DateTime.parse(value, DateTimeFormat.forPattern("yyyy-MM-dd HH:mm:ss").withZoneUTC());
        }
        return null;
    }

    public Optional<DateTime> getDateTimeOptinal(int i) {
        checkFieldExists(i);
        CHType from = columnTypes[i];
        if (from == CHPrimitiveType.Int64 || from == CHPrimitiveType.UInt64) {
            long unixMilliseconds = getLongUnsafe(i);
            if (unixMilliseconds == 0L) {
                return Optional.empty();
            } else {
                return Optional.of(new Instant(unixMilliseconds).toDateTime().withZone(DateTimeZone.UTC));
            }
        }
        if (from == CHPrimitiveType.DateTime) {
            String value = getString(i, StandardCharsets.US_ASCII);
            if (StringUtils.isEmpty(value)) {
                return Optional.empty();
            } else {
                return Optional.of(
                        DateTime.parse(value, DateTimeFormat.forPattern("yyyy-MM-dd HH:mm:ss").withZoneUTC())
                );
            }
        }
        return null;
    }

    public Optional<DateTime> getDateTimeOptinal(String name) {
        int i = getColumnId(name);
        return getDateTimeOptinal(i);
    }

    public DateTime getDate(String name) {
        int i = getColumnId(name);
        return getDate(i);
    }

    public DateTime getDate(int i) {
        checkFieldExists(i);
        CHType from = columnTypes[i];
        if (from == CHPrimitiveType.Int64 || from == CHPrimitiveType.UInt64) {
            long unixMilliseconds = getLongUnsafe(i);
            return new Instant(unixMilliseconds).toDateTime().withZone(DateTimeZone.UTC);
        }
        if (from == CHPrimitiveType.Date) {
            String value = getString(i, StandardCharsets.US_ASCII);
            return DateTime.parse(value, DateTimeFormat.forPattern("yyyy-MM-dd").withZoneUTC());
        }
        return null;
    }

    public List<String> getStringList(String name, Charset charset) {
        int i = getColumnId(name);
        return getStringList(i, charset);
    }

    public List<String> getStringList(int i, Charset charset) {
        checkFieldExists(i);
        CHType from = columnTypes[i];
        if (!(from instanceof CHArrayType)) {
            throw new IllegalArgumentException("Unable to convert " + from + " to List<String>");
        }
        CHArrayType arrayType = (CHArrayType) from;
        if (arrayType.getElementType() != CHPrimitiveType.String) {
            throw new IllegalArgumentException("Unable to convert " + from + " to List<String>");
        }
        List<String> result = new ArrayList<>();
        BAInputStream is = getBytesStream(i);
        ClickhouseEscapeUtils.ArrayParser ap = ClickhouseEscapeUtils.unescapedArray(is, true);

        Optional<BAInputStream> valueStreamOpt;
        while ((valueStreamOpt = ap.nextValue()).isPresent()) {
            try {
                result.add(IOUtils.toString(valueStreamOpt.get(), charset));
            } catch (IOException e) {
                throw new RuntimeException("Failed to read clickhouse array entry", e);
            }
        }
        return result;
    }

    public List<Integer> getIntListUnsafe(String name) {
        int i = getColumnId(name);
        return getIntListUnsafe(i);
    }

    public List<Integer> getIntListUnsafe(int i) {
        checkFieldExists(i);
        CHType from = columnTypes[i];
        if (!(from instanceof CHArrayType)) {
            throw new IllegalArgumentException("Unable to convert " + from + " to List<Integer>");
        }
        CHArrayType arrayType = (CHArrayType) from;
        checkConvertible(arrayType.getElementType(), INT_UNSAFE_CONVERTIBLE);
        List<Integer> result = new ArrayList<>();
        BAInputStream is = getBytesStream(i);
        ClickhouseEscapeUtils.ArrayParser ap = ClickhouseEscapeUtils.unescapedArray(is, false);

        Optional<BAInputStream> valueStreamOpt;
        while ((valueStreamOpt = ap.nextValue()).isPresent()) {
            result.add(ByteConvertUtils.parseInt(valueStreamOpt.get()));
        }
        return result;
    }

    public List<Long> getLongListUnsafe(String name) {
        int i = getColumnId(name);
        return getLongListUnsafe(i);
    }

    public List<Long> getLongListUnsafe(int i) {
        checkFieldExists(i);
        CHType from = columnTypes[i];
        if (!(from instanceof CHArrayType)) {
            throw new IllegalArgumentException("Unable to convert " + from + " to List<Integer>");
        }
        CHArrayType arrayType = (CHArrayType) from;
        checkConvertible(arrayType.getElementType(), LONG_UNSAFE_CONVERTIBLE);
        List<Long> result = new ArrayList<>();
        BAInputStream is = getBytesStream(i);
        ClickhouseEscapeUtils.ArrayParser ap = ClickhouseEscapeUtils.unescapedArray(is, false);

        Optional<BAInputStream> valueStreamOpt;
        while ((valueStreamOpt = ap.nextValue()).isPresent()) {
            result.add(ByteConvertUtils.parseLong(valueStreamOpt.get()));
        }
        return result;
    }

    public <E extends Enum<E> & IntEnum> E getIntEnum(int i, IntEnumResolver<E> resolver) {
        int intValue = getInt(i);
        return resolver.fromValueOrUnknown(intValue);
    }

    public <E extends Enum<E> & IntEnum> E getIntEnum(String name, IntEnumResolver<E> resolver) {
        int i = getColumnId(name);
        return getIntEnum(i, resolver);
    }

    private BAInputStream getBytesStream(int i) {
        int start = 0;
        int end;
        if (i == 0) {
            end = lineFiledEnd[0];
        } else {
            start = lineFiledEnd[i - 1] + 1;
            end = lineFiledEnd[i];
        }
        return line.getBytes(start, end);
    }

    private void checkFieldExists(int i) {
        if (i < 0 || i >= lineFiledEnd.length) {
            throw new IllegalArgumentException("No such column " + i + " in response");
        }
    }

    private int getColumnId(String name) {
        int i = ArrayUtils.indexOf(columnNames, name);
        if (i < 0 || i >= lineFiledEnd.length) {
            throw new IllegalArgumentException("No such column " + name + " in response");
        }

        return i;
    }

    private static void checkConvertible(CHType from, EnumSet<CHPrimitiveType> convertSet) {
        if (!convertSet.contains(from)) {
            throw new IllegalArgumentException("Unable to convert " + from + " to Int");
        }
    }

    public static CHRow createFromLine(LineInputStream.Line line, String[] columnNames, CHType[] columnTypes) {
        int[] fieldEnds;
        if (columnNames.length == 1) {
            fieldEnds = new int[]{line.length()};
            return new CHRow(line, columnNames, columnTypes, fieldEnds);
        }

        int fieldCount = line.count((byte) '\t') + 1;
        fieldEnds = new int[fieldCount];
        int start = line.indexOf((byte) '\t');
        int i = 0;
        do {
            fieldEnds[i++] = start;
            start = line.indexOf((byte) '\t', start + 1);
        } while (start >= 0);
        fieldEnds[fieldEnds.length - 1] = line.length();
        return new CHRow(line, columnNames, columnTypes, fieldEnds);
    }
}
