/**
 * Copyright (C) 2010 Orbeon, Inc.
 *
 * This program is free software; you can redistribute it and/or modify it under the terms of the
 * GNU Lesser General Public License as published by the Free Software Foundation; either version
 * 2.1 of the License, or (at your option) any later version.
 *
 * This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY;
 * without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.
 * See the GNU Lesser General Public License for more details.
 *
 * The full text of the license is available at http://www.gnu.org/copyleft/lesser.html
 */
package org.orbeon.oxf.util;

import org.orbeon.oxf.common.OXFException;
import org.orbeon.oxf.http.Headers;
import org.orbeon.oxf.xml.XMLConstants;
import org.xml.sax.ContentHandler;
import org.xml.sax.SAXException;
import org.xml.sax.helpers.AttributesImpl;

import java.io.IOException;
import java.io.OutputStream;

/**
 * An OutputStream that converts the bytes written into it into Base64-encoded characters written to
 * a ContentHandler.
 */
public class ContentHandlerOutputStream extends OutputStream {

    private static final String DEFAULT_BINARY_DOCUMENT_ELEMENT = "document";

    private final ContentHandler contentHandler;
    private final boolean doStartEndDocument;

    private final byte[] byteBuffer = new byte[76 * 3 / 4]; // maximum bytes that, once decoded, can fit in a line of 76 characters
    private int currentBufferSize = 0;
    private final char[] resultingLine = new char[76 + 1];

    private byte[] singleByte = new byte[1];

    private String contentType;
    private String statusCode;

    private boolean documentStarted;
    private boolean closed;

    public ContentHandlerOutputStream(ContentHandler contentHandler, boolean doStartEndDocument) {
        this.contentHandler = contentHandler;
        this.doStartEndDocument = doStartEndDocument;
    }

    public void setContentType(String contentType) {
        this.contentType = contentType;
    }

    public void setStatusCode(String statusCode) {
        this.statusCode = statusCode;
    }

    private void outputStartIfNeeded() throws SAXException {
        if (doStartEndDocument && ! documentStarted) {
            // Start document
            AttributesImpl attributes = new AttributesImpl();
            attributes.addAttribute(XMLConstants.XSI_URI, "type", "xsi:type", "CDATA", XMLConstants.XS_BASE64BINARY_QNAME.getQualifiedName());
            if (contentType != null)
                attributes.addAttribute("", Headers.ContentTypeLower(), Headers.ContentTypeLower(), "CDATA", contentType);
            if (statusCode != null)
                attributes.addAttribute("", "status-code", "status-code", "CDATA", statusCode);

            contentHandler.startDocument();
            contentHandler.startPrefixMapping(XMLConstants.XSI_PREFIX, XMLConstants.XSI_URI);
            contentHandler.startPrefixMapping(XMLConstants.XSD_PREFIX, XMLConstants.XSD_URI);
            contentHandler.startElement("", DEFAULT_BINARY_DOCUMENT_ELEMENT, DEFAULT_BINARY_DOCUMENT_ELEMENT, attributes);

            documentStarted = true;
        }
    }

    private void outputEndIfNeeded() {
        if (doStartEndDocument && documentStarted) {
            try {
                // End document
                contentHandler.endElement("", DEFAULT_BINARY_DOCUMENT_ELEMENT, DEFAULT_BINARY_DOCUMENT_ELEMENT);
                contentHandler.endPrefixMapping(XMLConstants.XSI_PREFIX);
                contentHandler.endPrefixMapping(XMLConstants.XSD_PREFIX);
                contentHandler.endDocument();
            } catch (SAXException e) {
                throw new OXFException(e);
            }
        }
    }

    /**
     * Close this output stream. This must be called in the end if startDocument() was called, otherwise the document
     * won't be properly produced.
     *
     * @throws IOException
     */
    public void close() throws IOException {
        if (!closed) {
            // Always flush
            flushBuffer();

            // Only close element and document if startDocument was called
            outputEndIfNeeded();
            closed = true;
        }
    }

    public void flush() throws IOException {
        if (!closed) {
            try {
                // NOTE: This will only flush on Base64 line boundaries. Is that what we want? Or
                // should we just ignore? We can't output an incomplete encoded line unless we have a
                // number of bytes in the buffer multiple of 3. Otherwise, we would have to output '='
                // characters, which do signal an end of transmission.
                contentHandler.processingInstruction("orbeon-serializer", "flush");
            } catch (SAXException e) {
                throw new OXFException(e);
            }
        }
    }

    public void write(byte b[]) throws IOException {
        addBytes(b, 0, b.length);
    }

    public void write(byte b[], int off, int len) throws IOException {
        addBytes(b, off, len);
    }

    public void write(int b) throws IOException {
        singleByte[0] = (byte) b;
        addBytes(singleByte, 0, 1);
    }

    private void addBytes(byte b[], int off, int len) throws IOException {
        if (closed)
            throw new IOException("ContentHandlerOutputStream already closed");

        // Check bounds
        if ((off < 0) || (len < 0) || (off > b.length) || ((off + len) > b.length))
	        throw new IndexOutOfBoundsException();
	    else if (len == 0)
	        return;

        try {
            outputStartIfNeeded();

            while (len > 0) {
                // Fill buffer as much as possible
                int lenToCopy = Math.min(len, byteBuffer.length - currentBufferSize);
                System.arraycopy(b, off, byteBuffer, currentBufferSize, lenToCopy);
                off += lenToCopy;
                len -= lenToCopy;
                currentBufferSize += lenToCopy;

                // If buffer is full, write it out
                if (currentBufferSize == byteBuffer.length) {
                    String encoded = Base64.encode(byteBuffer, true) + "\n";
                    // The terminating LF is already added by encode()
                    encoded.getChars(0, encoded.length(), resultingLine, 0);
                    // Output characters
                    contentHandler.characters(resultingLine, 0, encoded.length());
                    // Reset counter
                    currentBufferSize = 0;
                }
            }
        } catch (SAXException e) {
            throw new OXFException(e);
        }
    }

    private void flushBuffer() {
        if (currentBufferSize > 0) {
            try {
                outputStartIfNeeded();

                byte[] tempBuf = new byte[currentBufferSize];
                System.arraycopy(byteBuffer, 0, tempBuf, 0, currentBufferSize);
                String encoded = Base64.encode(tempBuf, true);
                encoded.getChars(0, encoded.length(), resultingLine, 0);
                // Output characters

                contentHandler.characters(resultingLine, 0, encoded.length());
            } catch (SAXException e) {
                throw new OXFException(e);
            }
            // Reset counter
            currentBufferSize = 0;
        }
    }
}
