package ru.yandex.webmaster3.core.turbo.xml;

import com.google.common.base.Strings;
import org.apache.commons.io.input.CountingInputStream;
import org.apache.xerces.util.SecurityManager;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.w3c.dom.Document;
import org.w3c.dom.Element;
import org.w3c.dom.Node;
import org.xml.sax.Attributes;
import org.xml.sax.Locator;
import org.xml.sax.SAXException;
import org.xml.sax.XMLReader;
import org.xml.sax.ext.LexicalHandler;
import org.xml.sax.helpers.DefaultHandler;
import ru.yandex.webmaster3.core.turbo.TurboConstants;

import javax.xml.parsers.*;
import java.io.IOException;
import java.io.InputStream;
import java.util.Stack;

/**
 * Copy-paste from https://stackoverflow.com/questions/4915422/get-line-number-from-xml-node-java
 * Доработан для чтения турбо RSS/YML-фидов с возможностью ограничения некоторых элементов (item/offer-ов)
 */
public class TurboXMLReader {

    private static final Logger log = LoggerFactory.getLogger(TurboXMLReader.class);

    private final static String LINE_NUMBER_KEY_NAME = "lineNumber";

    /**
     * Парсит xml из потока с некоторыми изменениями
     * @param is что парсим
     * @param maxItems максимум item/offer-ов в итоговом фиде, 0 - без ограничений
     * @param itemUrl оставить только item/offer c указанным url-ом
     * @return распаршенный документ
     * @throws IOException
     * @throws SAXException
     */
    public static Document parseTurboFeed(final InputStream is, int maxItems, String itemUrl, long maxFeedSize)
            throws Exception {
        final Document doc;
        SAXParser parser;
        try {
            final SAXParserFactory factory = SAXParserFactory.newInstance();
            parser = factory.newSAXParser();
            final DocumentBuilderFactory docBuilderFactory = DocumentBuilderFactory.newInstance();
            final DocumentBuilder docBuilder = docBuilderFactory.newDocumentBuilder();
            doc = docBuilder.newDocument();
        } catch (final ParserConfigurationException e) {
            throw new RuntimeException("Can't create SAX parser / DOM builder.", e);
        }

        final Stack<Element> elementStack = new Stack<>();
        final StringBuilder textBuffer = new StringBuilder();
        final TurboFeedHandler handler = new TurboFeedHandler(doc, elementStack, textBuffer, maxItems, itemUrl);
        parser.setProperty("http://xml.org/sax/properties/lexical-handler", handler);
        XMLReader reader = parser.getXMLReader();
        reader.setFeature("http://apache.org/xml/features/validation/schema/augment-psvi", false);
        reader.setFeature("http://apache.org/xml/features/nonvalidating/load-dtd-grammar", false);
        reader.setFeature("http://apache.org/xml/features/nonvalidating/load-external-dtd", false);
        reader.setFeature("http://xml.org/sax/features/external-parameter-entities", false);
        reader.setFeature("http://xml.org/sax/features/external-general-entities", false);
        reader.setFeature("http://xml.org/sax/features/validation", false);
        reader.setProperty("http://apache.org/xml/properties/security-manager", new SecurityManager());
        if (maxFeedSize <= 0) {
            maxFeedSize = Long.MAX_VALUE;
        }
        LimitErrorInputStream leis = new LimitErrorInputStream(is, maxFeedSize);
        try {
            parser.parse(leis, handler);
        } catch (Exception e) {
            log.error("Error during parsing feed", e);
            if (leis.getByteCount() == 0L) {
                throw new TurboFeedIsEmptyException("Turbo feed is empty", e);
            }
            if (leis.getByteCount() > maxFeedSize) {
                throw new TurboFeedIsTooBigException("Turbo feed is too big", e);
            }
            throw e;
        }
        if (!handler.isItemFound()) {
            throw new NoItemsFoundException("Requested item url not found");
        }

        return doc;
    }

    private static class TurboFeedHandler extends DefaultHandler implements LexicalHandler {
        private final Document doc;
        private final Stack<Element> elementStack;
        private final StringBuilder textBuffer;
        private final int maxItems;
        private final String itemUrlFilter;

        private Locator locator;
        private boolean skipItem = false;
        private boolean removeTextBetweenElements = false;
        private boolean itemFound;
        private int itemCount = 0;

        public TurboFeedHandler(Document doc, Stack<Element> elementStack, StringBuilder textBuffer,
                                int maxItems, String itemUrlFilter) {
            this.doc = doc;
            this.elementStack = elementStack;
            this.textBuffer = textBuffer;
            this.maxItems = maxItems == 0 ? Integer.MAX_VALUE : maxItems;
            this.itemUrlFilter = itemUrlFilter;
        }

        @Override
        public void setDocumentLocator(final Locator locator) {
            this.locator = locator; // Save the locator, so that it can be used later for line tracking when traversing nodes.
        }

        @Override
        public void startElement(final String uri, final String localName, final String qName, final Attributes attributes)
                throws SAXException {
            if (isItem(qName)) {
                itemCount++;
                if (itemCount > maxItems) {
                    skipItem = true;
                    removeTextBetweenElements = true;
                }
                if (removeTextBetweenElements) {
                    textBuffer.delete(0, textBuffer.length()); // igonring text between items if they skipped
                }
            }

            addTextIfNeeded();
            final Element el = doc.createElement(qName);
            for (int i = 0; i < attributes.getLength(); i++) {
                el.setAttribute(attributes.getQName(i), attributes.getValue(i));
            }
            el.setUserData(LINE_NUMBER_KEY_NAME, String.valueOf(this.locator.getLineNumber()), null);
            elementStack.push(el);
        }

        @Override
        public void endElement(final String uri, final String localName, final String qName) {
            boolean addElement = true;
            if (isItem(qName)) {
                addElement = !skipItem;
                skipItem = false;
            }
            if (!Strings.isNullOrEmpty(itemUrlFilter) && isUrl(qName)) {
                // check if inside item (for url filter)
                if (elementStack.size() >= 2 && isItem(elementStack.get(elementStack.size() - 2).getTagName())) {
                    // check item/offer url
                    String text = textBuffer.toString();
                    if (itemUrlFilter.equals(text)) {
                        itemFound = true;
                    } else {
                        skipItem = true;
                    }
                }
                removeTextBetweenElements = true;
            }
            addTextIfNeeded();
            final Element closedEl = elementStack.pop();
            if (elementStack.isEmpty()) { // Is this the root element?
                doc.appendChild(closedEl);
            } else if (addElement) {
                final Element parentEl = elementStack.peek();
                parentEl.appendChild(closedEl);
            }
        }

        @Override
        public void characters(final char ch[], final int start, final int length) throws SAXException {
            textBuffer.append(ch, start, length);
        }

        // Outputs text accumulated under the current node
        private void addTextIfNeeded() {
            if (textBuffer.length() > 0) {
                final Element el = elementStack.peek();
                final Node textNode = doc.createTextNode(textBuffer.toString());
                el.appendChild(textNode);
                textBuffer.delete(0, textBuffer.length());
            }
        }

        @Override
        public void startDTD(String name, String publicId, String systemId) throws SAXException {
            // ignore
        }

        @Override
        public void endDTD() throws SAXException {
            // ignore
        }

        @Override
        public void startEntity(String name) throws SAXException {
        }

        @Override
        public void endEntity(String name) throws SAXException {
        }

        @Override
        public void startCDATA() throws SAXException {
            addTextIfNeeded();
        }

        @Override
        public void endCDATA() throws SAXException {
            if (textBuffer.length() > 0) {
                final Element el = elementStack.peek();
                final Node textNode = doc.createCDATASection(textBuffer.toString());
                el.appendChild(textNode);
                textBuffer.delete(0, textBuffer.length());
            }
        }

        @Override
        public void comment(char[] ch, int start, int length) throws SAXException {
            // ignore
        }

        private boolean isItem(String qName) {
            return TurboConstants.TAG_ITEM.equalsIgnoreCase(qName) || TurboConstants.TAG_OFFER.equalsIgnoreCase(qName);
        }

        private boolean isUrl(String qName) {
            return TurboConstants.TAG_LINK.equalsIgnoreCase(qName) || TurboConstants.TAG_URL.equalsIgnoreCase(qName);
        }

        public boolean isItemFound() {
            return Strings.isNullOrEmpty(itemUrlFilter) || itemFound;
        }
    }

    public static final class LimitErrorInputStream extends CountingInputStream {

        private final long maxByteCount;

        public LimitErrorInputStream(InputStream in, long maxByteCount) {
            super(in);
            this.maxByteCount = maxByteCount;
        }

        @Override
        protected synchronized void afterRead(int n) {
            super.afterRead(n);
            if (getByteCount() > maxByteCount) {
                throw new RuntimeException("Max byte count limit reached");
            }
        }
    }

    public static class TurboFeedIsEmptyException extends Exception {

        public TurboFeedIsEmptyException() {
        }

        public TurboFeedIsEmptyException(String message) {
            super(message);
        }

        public TurboFeedIsEmptyException(String message, Throwable cause) {
            super(message, cause);
        }
    }

    public static class NoItemsFoundException extends Exception {

        public NoItemsFoundException() {
        }

        public NoItemsFoundException(String message) {
            super(message);
        }

        public NoItemsFoundException(String message, Throwable cause) {
            super(message, cause);
        }
    }

    public static class TurboFeedIsTooBigException extends Exception {
        public TurboFeedIsTooBigException() {
        }

        public TurboFeedIsTooBigException(String message) {
            super(message);
        }

        public TurboFeedIsTooBigException(String message, Throwable cause) {
            super(message, cause);
        }
    }

}
