package allen.town.podcast.parser.media.id3;

import android.util.Log;
import androidx.annotation.NonNull;
import allen.town.podcast.parser.media.id3.model.FrameHeader;
import allen.town.podcast.parser.media.id3.model.TagHeader;
import org.apache.commons.io.IOUtils;
import org.apache.commons.io.input.CountingInputStream;

import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.nio.ByteBuffer;
import java.nio.charset.Charset;
import java.nio.charset.MalformedInputException;

/**
 * Reads the ID3 Tag of a given file.
 * See https://id3.org/id3v2.3.0
 */
public class ID3Reader {
    private static final String TAG = "ID3Reader";
    private static final int FRAME_ID_LENGTH = 4;
    public static final byte ENCODING_ISO = 0;
    public static final byte ENCODING_UTF16_WITH_BOM = 1;
    public static final byte ENCODING_UTF16_WITHOUT_BOM = 2;
    public static final byte ENCODING_UTF8 = 3;

    private TagHeader tagHeader;
    private final CountingInputStream inputStream;

    public ID3Reader(CountingInputStream input) {
        inputStream = input;
    }

    public void readInputStream() throws IOException, ID3ReaderException {
        tagHeader = readTagHeader();
        int tagContentStartPosition = getPosition();
        while (getPosition() < tagContentStartPosition + tagHeader.getSize()) {
            FrameHeader frameHeader = readFrameHeader();
            if (frameHeader.getId().charAt(0) < '0' || frameHeader.getId().charAt(0) > 'z') {
                Log.d(TAG, "Stopping because of invalid frame: " + frameHeader.toString());
                return;
            }
            readFrame(frameHeader);
        }
    }

    protected void readFrame(@NonNull FrameHeader frameHeader) throws IOException, ID3ReaderException {
        Log.d(TAG, "Skipping frame: " + frameHeader.toString());
        skipBytes(frameHeader.getSize());
    }

    int getPosition() {
        return inputStream.getCount();
    }

    /**
     * Skip a certain number of bytes on the given input stream.
     */
    void skipBytes(int number) throws IOException, ID3ReaderException {
        if (number < 0) {
            throw new ID3ReaderException("Trying to read a negative number of bytes");
        }
        IOUtils.skipFully(inputStream, number);
    }

    byte readByte() throws IOException {
        return (byte) inputStream.read();
    }

    short readShort() throws IOException {
        char firstByte = (char) inputStream.read();
        char secondByte = (char) inputStream.read();
        return (short) ((firstByte << 8) | secondByte);
    }

    int readInt() throws IOException {
        char firstByte = (char) inputStream.read();
        char secondByte = (char) inputStream.read();
        char thirdByte = (char) inputStream.read();
        char fourthByte = (char) inputStream.read();
        return (firstByte << 24) | (secondByte << 16) | (thirdByte << 8) | fourthByte;
    }

    void expectChar(char expected) throws ID3ReaderException, IOException {
        char read = (char) inputStream.read();
        if (read != expected) {
            throw new ID3ReaderException("Expected " + expected + " and got " + read);
        }
    }

    @NonNull
    TagHeader readTagHeader() throws ID3ReaderException, IOException {
        expectChar('I');
        expectChar('D');
        expectChar('3');
        short version = readShort();
        byte flags = readByte();
        int size = unsynchsafe(readInt());
        if ((flags & 0b01000000) != 0) {
            int extendedHeaderSize = readInt();
            skipBytes(extendedHeaderSize - 4);
        }
        return new TagHeader("ID3", size, version, flags);
    }

    @NonNull
    FrameHeader readFrameHeader() throws IOException {
        String id = readIsoStringFixed(FRAME_ID_LENGTH);
        int size = readInt();
        if (tagHeader != null && tagHeader.getVersion() >= 0x0400) {
            size = unsynchsafe(size);
        }
        short flags = readShort();
        return new FrameHeader(id, size, flags);
    }

    private int unsynchsafe(int in) {
        int out = 0;
        int mask = 0x7F000000;

        while (mask != 0) {
            out >>= 1;
            out |= in & mask;
            mask >>= 8;
        }

        return out;
    }

    /**
     * Reads a null-terminated string with encoding.
     */
    protected String readEncodingAndString(int max) throws IOException {
        byte encoding = readByte();
        return readEncodedString(encoding, max - 1);
    }

    @SuppressWarnings("CharsetObjectCanBeUsed")
    protected String readIsoStringFixed(int length) throws IOException {
        ByteArrayOutputStream bytes = new ByteArrayOutputStream();
        int bytesRead = 0;
        while (bytesRead < length) {
            bytes.write(readByte());
            bytesRead++;
        }
        return Charset.forName("ISO-8859-1").newDecoder().decode(ByteBuffer.wrap(bytes.toByteArray())).toString();
    }

    protected String readIsoStringNullTerminated(int max) throws IOException {
        return readEncodedString(ENCODING_ISO, max);
    }

    @SuppressWarnings("CharsetObjectCanBeUsed")
    String readEncodedString(int encoding, int max) throws IOException {
        if (encoding == ENCODING_UTF16_WITH_BOM || encoding == ENCODING_UTF16_WITHOUT_BOM) {
            return readEncodedString2(Charset.forName("UTF-16"), max);
        } else if (encoding == ENCODING_UTF8) {
            return readEncodedString2(Charset.forName("UTF-8"), max);
        } else {
            return readEncodedString1(Charset.forName("ISO-8859-1"), max);
        }
    }

    /**
     * Reads chars where the encoding uses 1 char per symbol.
     */
    private String readEncodedString1(Charset charset, int max) throws IOException {
        ByteArrayOutputStream bytes = new ByteArrayOutputStream();
        int bytesRead = 0;
        while (bytesRead < max) {
            byte c = readByte();
            bytesRead++;
            if (c == 0) {
                break;
            }
            bytes.write(c);
        }
        return charset.newDecoder().decode(ByteBuffer.wrap(bytes.toByteArray())).toString();
    }

    /**
     * Reads chars where the encoding uses 2 chars per symbol.
     */
    private String readEncodedString2(Charset charset, int max) throws IOException {
        ByteArrayOutputStream bytes = new ByteArrayOutputStream();
        int bytesRead = 0;
        boolean foundEnd = false;
        while (bytesRead + 1 < max) {
            byte c1 = readByte();
            byte c2 = readByte();
            if (c1 == 0 && c2 == 0) {
                foundEnd = true;
                break;
            }
            bytesRead += 2;
            bytes.write(c1);
            bytes.write(c2);
        }
        if (!foundEnd && bytesRead < max) {
            // Last character
            byte c = readByte();
            if (c != 0) {
                bytes.write(c);
            }
        }
        try {
            return charset.newDecoder().decode(ByteBuffer.wrap(bytes.toByteArray())).toString();
        } catch (MalformedInputException e) {
            return "";
        }
    }
}
