
package org.marc4j;

import java.io.IOException;
import java.io.OutputStream;
import java.nio.charset.StandardCharsets;
import java.text.Normalizer;

import org.marc4j.converter.CharConverter;
import org.marc4j.marc.ControlField;
import org.marc4j.marc.DataField;
import org.marc4j.marc.Record;
import org.marc4j.marc.Subfield;

public class MarcJsonWriter implements MarcWriter {

    public final static int MARC_IN_JSON = 0;

    public final static int MARC_JSON = 1;

    /**
     * Character encoding. Default is UTF-8.
     */
    private CharConverter converter = null;

    private final OutputStream os;

    private int useJsonFormat = MARC_IN_JSON;

    private boolean indent = false;

    private boolean escapeSlash = false;

    private boolean quoteLabels = true;

    private String ql = "\"";

    private boolean normalize = false;

    /**
     * Creates a {@link MarcJsonWriter} with the supplied {@link OutputStream}.
     *
     * @param os - the OutputStream to write to
     */
    public MarcJsonWriter(final OutputStream os) {
        this.os = os;
    }

    /**
     * Creates a {@link MarcJsonWriter} with the supplied {@link OutputStream} using the supplied {@link CharConverter}.
     *
     * @param os - the OutputStream to write to
     * @param conv - the Character converter to use to transform the data as it is being written out.
     */
    public MarcJsonWriter(final OutputStream os, final CharConverter conv) {
        this.os = os;
        setConverter(conv);
    }

    /**
     * Creates a {@link MarcJsonWriter} with the supplied {@link OutputStream} to write using the supplied JSON format.
     *
     * @param os - the OutputStream to write to
     * @param jsonFormat - whether to use the MARC_IN_JSON format (default) or the MARC_JSON format (1)
     */
    public MarcJsonWriter(final OutputStream os, final int jsonFormat) {
        this.os = os;
        useJsonFormat = jsonFormat;

        if (useJsonFormat == MARC_JSON) {
            this.setQuoteLabels(false);
        }
    }

    /**
     * Creates a {@link MarcJsonWriter} with the supplied {@link OutputStream} using the specified {@link CharConverter}
     * to write using the specified JSON format.
     *
     * @param os - the OutputStream to write to
     * @param conv - the Character converter to use to transform the data as it is being written out.
     * @param jsonFormat - whether to use the MARC_IN_JSON format (default) or the MARC_JSON format (1)
     */
    public MarcJsonWriter(final OutputStream os, final CharConverter conv, final int jsonFormat) {
        this.os = os;
        setConverter(conv);
        useJsonFormat = jsonFormat;

        if (useJsonFormat == MARC_JSON) {
            this.setQuoteLabels(false);
        }
    }

    /**
     * Closes the {@link MarcJsonWriter}
     */
    @Override
    public void close() {
        try {
            os.close();
        } catch (IOException e) {
            throw new MarcException("IO Error occurred closing marc writer", e);
        }
    }

    protected String toMarcJson(final Record record) {
        final StringBuffer buf = new StringBuffer();
        buf.append("{");

        indent(buf, "\n    ");

        buf.append(ql + "leader" + ql + ":\"").append(unicodeEscape(record.getLeader().toString())).append("\",");

        indent(buf, "\n    ");

        buf.append(ql + "controlfield" + ql + ":");

        indent(buf, "\n    ");

        buf.append("[");
        boolean firstField = true;

        for (final ControlField cf : record.getControlFields()) {
            if (!firstField) {
                buf.append(",");
            } else {
                firstField = false;
            }

            indent(buf, "\n        ");

            if (!cf.getTag().matches("[A-Z0-9][A-Z0-9][A-Z0-9]")) {
                throw new MarcException("Invalid tag: " + cf.getTag());
            }

            buf.append("{ " + ql + "tag" + ql + " : \"" + cf.getTag() + "\", " + ql + "data" + ql + " : ")
                    .append("\"" + unicodeEscape(cf.getData()) + "\" }");
        }

        indent(buf, "\n    ");

        buf.append("]");

        indent(buf, "\n    ");

        buf.append("datafield :");

        indent(buf, "\n    ");

        buf.append("[");
        firstField = true;

        for (final DataField df : record.getDataFields()) {
            if (!firstField) {
                buf.append(",");
            } else {
                firstField = false;
            }

            indent(buf, "\n        ");

            buf.append("{");

            indent(buf, "\n            ");

            if (!df.getTag().matches("[A-Z0-9][A-Z0-9][A-Z0-9]")) {
                throw new MarcException("Invalid tag: " + df.getTag());
            }

            buf.append(ql + "tag" + ql + " : \"" + df.getTag() + "\", " + ql + "ind" + ql + " : \"");
            unicodeEscape(buf, df.getIndicator1());
            unicodeEscape(buf, df.getIndicator2());
            buf.append("\",");

            indent(buf, "\n            ");

            buf.append(ql + "subfield" + ql + " :");

            indent(buf, "\n            ");

            buf.append("[");
            boolean firstSubfield = true;

            for (final Subfield sf : df.getSubfields()) {
                if (!firstSubfield) {
                    buf.append(",");
                } else {
                    firstSubfield = false;
                }

                indent(buf, "\n                ");

                if ((sf.getCode() < 'a' || 'z' < sf.getCode()) && (sf.getCode() < '0' || '9' < sf.getCode())) {
                    throw new MarcException("Invalid code: " + sf.getCode());
                }

                buf.append("{ " + ql + "code" + ql + " : \"" + sf.getCode() + "\", " + ql + "data" + ql + " : \"" +
                        unicodeEscape(sf.getData()) + "\" }");
            }

            indent(buf, "\n            ");

            buf.append("]");

            indent(buf, "\n        ");

            buf.append("}");
        }

        indent(buf, "\n    ");

        buf.append("]");

        indent(buf, "\n");

        buf.append("}\n");

        return (buf.toString());
    }

    private void indent(StringBuffer buf, String indentStr) {
        if (indent) {
            buf.append(indentStr);
        }
        
    }

    protected String toMarcInJson(final Record record) {
        final StringBuffer buf = new StringBuffer();
        buf.append("{");

        indent(buf, "\n    ");

        buf.append(ql + "leader" + ql + ":\"").append(unicodeEscape(record.getLeader().toString())).append("\",");

        indent(buf, "\n    ");

        buf.append(ql + "fields" + ql + ":");

        indent(buf, "\n    ");

        buf.append("[");
        boolean firstField = true;

        for (final ControlField cf : record.getControlFields()) {
            if (!firstField) {
                buf.append(",");
            } else {
                firstField = false;
            }

            indent(buf, "\n        ");

            buf.append("{");

            indent(buf, "\n            ");

            if (!cf.getTag().matches("[A-Z0-9][A-Z0-9][A-Z0-9]")) {
                throw new MarcException("Invalid tag: " + cf.getTag());
            }

            buf.append(ql + cf.getTag() + ql + ":").append("\"" + unicodeEscape(cf.getData()) + "\"");

            indent(buf, "\n        ");

            buf.append("}");
        }

        for (final DataField df : record.getDataFields()) {
            if (!firstField) {
                buf.append(",");
            } else {
                firstField = false;
            }

            indent(buf, "\n        ");

            buf.append("{");

            indent(buf, "\n            ");

            if (!df.getTag().matches("[A-Z0-9][A-Z0-9][A-Z0-9]")) {
                throw new MarcException("Invalid tag: " + df.getTag());
            }

            buf.append(ql + df.getTag() + ql + ":");

            indent(buf, "\n                ");

            buf.append("{");

            buf.append(ql + "subfields" + ql + ":");

            indent(buf, "\n                ");

            buf.append("[");
            boolean firstSubfield = true;

            for (final Subfield sf : df.getSubfields()) {
                if (!firstSubfield) {
                    buf.append(",");
                } else {
                    firstSubfield = false;
                }

                indent(buf, "\n                    ");

                buf.append("{");

                indent(buf, "\n                        ");

                if ((sf.getCode() < 'a' || 'z' < sf.getCode()) && (sf.getCode() < '0' || '9' < sf.getCode())) {
                    throw new MarcException("Invalid code: " + sf.getCode());
                }

                buf.append(ql + sf.getCode() + ql + ":\"" + unicodeEscape(sf.getData()) + "\"");

                indent(buf, "\n                    ");

                buf.append("}");
            }

            indent(buf, "\n                ");

            buf.append("],");

            indent(buf, "\n                ");

            buf.append(ql + "ind1" + ql + ":\"");
            unicodeEscape(buf, df.getIndicator1());
            buf.append("\",");

            indent(buf, "\n                ");

            buf.append(ql + "ind2" + ql + ":\"");
            unicodeEscape(buf, df.getIndicator2());
            buf.append("\"");

            indent(buf, "\n            ");

            buf.append("}");

            indent(buf, "\n        ");

            buf.append("}");
        }

        indent(buf, "\n    ");

        buf.append("]");

        indent(buf, "\n");

        buf.append("}\n");

        return (buf.toString());
    }

    private String unicodeEscape(String data) {
        if (converter != null) {
            data = converter.convert(data);
        }

        if (normalize) {
            data = Normalizer.normalize(data, Normalizer.Form.NFC);
        }

        final StringBuffer buffer = new StringBuffer();

        for (int i = 0; i < data.length(); i++) {
            final char c = data.charAt(i);
            unicodeEscape(buffer, c);
        }
        return buffer.toString();
    }

    private void unicodeEscape(StringBuffer buffer, char c) {
        switch (c) {
            case '/':
                if (escapeSlash) {
                    buffer.append("\\/");
                } else {
                    buffer.append("/");
                }
                break;
            case '"':
                buffer.append("\\\"");
                break;
            case '\\':
                buffer.append("\\\\");
                break;
            case '\b':
                buffer.append("\\b");
                break;
            case '\f':
                buffer.append("\\f");
                break;
            case '\n':
                buffer.append("\\n");
                break;
            case '\r':
                buffer.append("\\r");
                break;
            case '\t':
                buffer.append("\\t");
                break;
            default:
                if (c > 0xff || c <= 0x1f) {
                    final String val = "0000" + Integer.toHexString(c);
                    buffer.append("\\u" + val.substring(val.length() - 4));
                } else {
                    buffer.append(c);
                }
                break;
        }
    }

    /**
     * Returns the character converter.
     *
     * @return the character converter being used
     */
    @Override
    public CharConverter getConverter() {
        return converter;
    }

    /**
     * Sets the character converter.
     *
     * @param converter the character converter
     */
    @Override
    public void setConverter(final CharConverter converter) {
        this.converter = converter;
    }

    /**
     * Returns true if indentation is active, false otherwise.
     *
     * @return Returns true if indentation is active
     */
    public boolean hasIndent() {
        return indent;
    }

    /**
     * Activates or deactivates indentation. Default value is false.
     *
     * @param indent - true to produce pretty-printed indented output
     */
    public void setIndent(final boolean indent) {
        this.indent = indent;
    }

    /**
     * Writes the supplied {@link Record}.
     */
    @Override
    public void write(final Record record) {
        String recordAsJson = "";

        if (useJsonFormat == MARC_IN_JSON) {
            recordAsJson = toMarcInJson(record);
        } else if (useJsonFormat == MARC_JSON) {
            recordAsJson = toMarcJson(record);
        }

        try {
            os.write(recordAsJson.getBytes(StandardCharsets.UTF_8));
            os.flush();
        } catch (final IOException e) {
            // TODO Auto-generated catch block
            e.printStackTrace();
        }
    }

    /**
     * Returns true if escape slashes are turned on; else, false.
     *
     * @return True if escape slashes are turned on; else, false
     */
    public boolean isEscapeSlash() {
        return escapeSlash;
    }

    /**
     * Turns on escape slashes.
     *
     * @param escapeSlash True if escape slashes should be turned on; else, false
     */
    public void setEscapeSlash(final boolean escapeSlash) {
        this.escapeSlash = escapeSlash;
    }

    /**
     * Returns true if quote labels are turned on; else, false.
     *
     * @return True if quote labels are turned on; else, false
     */
    public boolean isQuoteLabels() {
        return quoteLabels;
    }

    /**
     * Turns on quote labels.
     *
     * @param quoteLabels - true to enable quotes being placed around certain items.
     */
    public void setQuoteLabels(final boolean quoteLabels) {
        this.quoteLabels = quoteLabels;
        ql = (quoteLabels) ? "\"" : "";
    }

    /**
     * Returns true if JSON output is indented; else, false.
     *
     * @return True if JSON output is indented; else, false
     */
    public boolean isIndent() {
        return indent;
    }

    /**
     * Turns on Unicode normalization.
     *
     * @param b - true to turn on Unicode normalization (NFC)
     */
    public void setUnicodeNormalization(final boolean b) {
        this.normalize = b;
    }

    /**
     * Whether the writer expects the record passed in to be encoded in Unicode.
     */
    @Override
    public boolean expectsUnicode()
    {
        return (converter == null) ? true : false; 
    }

}
