/*
 * Copyright (C) 2011 Teleal GmbH, Switzerland
 *
 * 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 3 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.
 *
 * You should have received a copy of the GNU Lesser General Public License
 * along with this program.  If not, see <http://www.gnu.org/licenses/>.
 */

package org.teleal.cling.binding.xml;

import org.teleal.cling.binding.staging.MutableDevice;
import org.teleal.cling.binding.staging.MutableIcon;
import org.teleal.cling.binding.staging.MutableService;
import org.teleal.cling.model.Namespace;
import org.teleal.cling.model.ValidationException;
import org.teleal.cling.model.XMLUtil;
import org.teleal.cling.model.meta.Device;
import org.teleal.cling.model.meta.DeviceDetails;
import org.teleal.cling.model.meta.Icon;
import org.teleal.cling.model.meta.LocalService;
import org.teleal.cling.model.meta.RemoteService;
import org.teleal.cling.model.meta.Service;
import org.teleal.cling.model.profile.ControlPointInfo;
import org.teleal.cling.model.types.DLNACaps;
import org.teleal.cling.model.types.DLNADoc;
import org.teleal.cling.model.types.InvalidValueException;
import org.teleal.cling.model.types.ServiceId;
import org.teleal.cling.model.types.ServiceType;
import org.teleal.cling.model.types.UDN;
import org.teleal.common.util.Exceptions;
import org.w3c.dom.Document;
import org.w3c.dom.Element;
import org.w3c.dom.Node;
import org.w3c.dom.NodeList;
import org.xml.sax.InputSource;

import javax.xml.parsers.DocumentBuilderFactory;
import java.io.StringReader;
import java.net.URI;
import java.net.URL;
import java.util.logging.Logger;

import static org.teleal.cling.binding.xml.Descriptor.Device.ELEMENT;
import static org.teleal.cling.model.XMLUtil.appendNewElement;
import static org.teleal.cling.model.XMLUtil.appendNewElementIfNotNull;

/**
 * Implementation based on JAXP DOM.
 *
 * @author Christian Bauer
 */
public class UDA10DeviceDescriptorBinderImpl implements DeviceDescriptorBinder {

    private static Logger log = Logger.getLogger(DeviceDescriptorBinder.class.getName());

    public <D extends Device> D describe(D undescribedDevice, String descriptorXml) throws DescriptorBindingException, ValidationException {

        if (descriptorXml == null || descriptorXml.length() == 0) {
            throw new DescriptorBindingException("Null or empty descriptor");
        }

        try {
            log.fine("Populating device from XML descriptor: " + undescribedDevice);
            // We can not validate the XML document. There is no possible XML schema (maybe RELAX NG) that would properly
            // constrain the UDA 1.0 device descriptor documents: Any unknown element or attribute must be ignored, order of elements
            // is not guaranteed. Try to write a schema for that! No combination of <xsd:any namespace="##any"> and <xsd:choice>
            // works with that... But hey, MSFT sure has great tech guys! So what we do here is just parsing out the known elements
            // and ignoring the other shit. We'll also do some very very basic validation of required elements, but that's it.

            // And by the way... try this with JAXB instead of manual DOM processing! And you thought it couldn't get worse....

            DocumentBuilderFactory factory = DocumentBuilderFactory.newInstance();
            factory.setNamespaceAware(true);

            Document d = factory.newDocumentBuilder().parse(
                    new InputSource(
                            // TODO: UPNP VIOLATION: Virgin Media Superhub sends trailing spaces/newlines after last XML element, need to trim()
                            new StringReader(descriptorXml.trim())
                    )
            );

            return describe(undescribedDevice, d);

        } catch (ValidationException ex) {
            throw ex;
        } catch (Exception ex) {
            throw new DescriptorBindingException("Could not parse device descriptor: " + ex.toString(), ex);
        }
    }

    public <D extends Device> D describe(D undescribedDevice, Document dom) throws DescriptorBindingException, ValidationException {
        try {
            log.fine("Populating device from DOM: " + undescribedDevice);

            // Read the XML into a mutable descriptor graph
            MutableDevice descriptor = new MutableDevice();
            Element rootElement = dom.getDocumentElement();
            hydrateRoot(descriptor, rootElement);

            // Build the immutable descriptor graph
            return buildInstance(undescribedDevice, descriptor);

        } catch (ValidationException ex) {
            throw ex;
        } catch (Exception ex) {
            throw new DescriptorBindingException("Could not parse device DOM: " + ex.toString(), ex);
        }
    }

    public <D extends Device> D buildInstance(D undescribedDevice, MutableDevice descriptor) throws ValidationException {
        return (D) descriptor.build(undescribedDevice);
    }

    protected void hydrateRoot(MutableDevice descriptor, Element rootElement) throws DescriptorBindingException {

        if (rootElement.getNamespaceURI() == null || !rootElement.getNamespaceURI().equals(Descriptor.Device.NAMESPACE_URI)) {
            log.warning("Wrong XML namespace declared on root element: " + rootElement.getNamespaceURI());
        }

        if (!rootElement.getNodeName().equals(ELEMENT.root.name())) {
            throw new DescriptorBindingException("Root element name is not <root>: " + rootElement.getNodeName());
        }

        NodeList rootChildren = rootElement.getChildNodes();

        Node deviceNode = null;

        for (int i = 0; i < rootChildren.getLength(); i++) {
            Node rootChild = rootChildren.item(i);

            if (rootChild.getNodeType() != Node.ELEMENT_NODE)
                continue;

            if (ELEMENT.specVersion.equals(rootChild)) {
                hydrateSpecVersion(descriptor, rootChild);
            } else if (ELEMENT.URLBase.equals(rootChild)) {
                try {
                    // We hope it's  RFC 2396 and RFC 2732 compliant
                    descriptor.baseURL = new URL(XMLUtil.getTextContent(rootChild));
                } catch (Exception ex) {
                    throw new DescriptorBindingException("Invalid URLBase: " + ex.getMessage());
                }
            } else if (ELEMENT.device.equals(rootChild)) {
                // Just sanity check here...
                if (deviceNode != null)
                    throw new DescriptorBindingException("Found multiple <device> elements in <root>");
                deviceNode = rootChild;
            } else {
                log.finer("Ignoring unknown element: " + rootChild.getNodeName());
            }
        }

        if (deviceNode == null) {
            throw new DescriptorBindingException("No <device> element in <root>");
        }
        hydrateDevice(descriptor, deviceNode);
    }

    public void hydrateSpecVersion(MutableDevice descriptor, Node specVersionNode) throws DescriptorBindingException {

        NodeList specVersionChildren = specVersionNode.getChildNodes();
        for (int i = 0; i < specVersionChildren.getLength(); i++) {
            Node specVersionChild = specVersionChildren.item(i);

            if (specVersionChild.getNodeType() != Node.ELEMENT_NODE)
                continue;

            if (ELEMENT.major.equals(specVersionChild)) {
                descriptor.udaVersion.major = Integer.valueOf(XMLUtil.getTextContent(specVersionChild));
            } else if (ELEMENT.minor.equals(specVersionChild)) {
                descriptor.udaVersion.minor = Integer.valueOf(XMLUtil.getTextContent(specVersionChild));
            }

        }

    }

    public void hydrateDevice(MutableDevice descriptor, Node deviceNode) throws DescriptorBindingException {

        NodeList deviceNodeChildren = deviceNode.getChildNodes();
        for (int i = 0; i < deviceNodeChildren.getLength(); i++) {
            Node deviceNodeChild = deviceNodeChildren.item(i);

            if (deviceNodeChild.getNodeType() != Node.ELEMENT_NODE)
                continue;

            if (ELEMENT.deviceType.equals(deviceNodeChild)) {
                descriptor.deviceType = XMLUtil.getTextContent(deviceNodeChild);
            } else if (ELEMENT.friendlyName.equals(deviceNodeChild)) {
                descriptor.friendlyName = XMLUtil.getTextContent(deviceNodeChild);
            } else if (ELEMENT.manufacturer.equals(deviceNodeChild)) {
                descriptor.manufacturer = XMLUtil.getTextContent(deviceNodeChild);
            } else if (ELEMENT.manufacturerURL.equals(deviceNodeChild)) {
                descriptor.manufacturerURI = parseURI(XMLUtil.getTextContent(deviceNodeChild));
            } else if (ELEMENT.modelDescription.equals(deviceNodeChild)) {
                descriptor.modelDescription = XMLUtil.getTextContent(deviceNodeChild);
            } else if (ELEMENT.modelName.equals(deviceNodeChild)) {
                descriptor.modelName = XMLUtil.getTextContent(deviceNodeChild);
            } else if (ELEMENT.modelNumber.equals(deviceNodeChild)) {
                descriptor.modelNumber = XMLUtil.getTextContent(deviceNodeChild);
            } else if (ELEMENT.modelURL.equals(deviceNodeChild)) {
                descriptor.modelURI = parseURI(XMLUtil.getTextContent(deviceNodeChild));
            } else if (ELEMENT.presentationURL.equals(deviceNodeChild)) {
                descriptor.presentationURI = parseURI(XMLUtil.getTextContent(deviceNodeChild));
            } else if (ELEMENT.UPC.equals(deviceNodeChild)) {
                descriptor.upc = XMLUtil.getTextContent(deviceNodeChild);
            } else if (ELEMENT.serialNumber.equals(deviceNodeChild)) {
                descriptor.serialNumber = XMLUtil.getTextContent(deviceNodeChild);
            } else if (ELEMENT.UDN.equals(deviceNodeChild)) {
                descriptor.udn = UDN.valueOf(XMLUtil.getTextContent(deviceNodeChild));
            } else if (ELEMENT.iconList.equals(deviceNodeChild)) {
                hydrateIconList(descriptor, deviceNodeChild);
            } else if (ELEMENT.serviceList.equals(deviceNodeChild)) {
                hydrateServiceList(descriptor, deviceNodeChild);
            } else if (ELEMENT.deviceList.equals(deviceNodeChild)) {
                hydrateDeviceList(descriptor, deviceNodeChild);
            } else if (ELEMENT.X_DLNADOC.equals(deviceNodeChild) &&
                    Descriptor.Device.DLNA_PREFIX.equals(deviceNodeChild.getPrefix())) {
                String txt = XMLUtil.getTextContent(deviceNodeChild);
                try {
                    descriptor.dlnaDocs.add(DLNADoc.valueOf(txt));
                } catch (InvalidValueException ex) {
                    log.info("Invalid X_DLNADOC value, ignoring value: " + txt);
                }
            } else if (ELEMENT.X_DLNACAP.equals(deviceNodeChild) &&
                    Descriptor.Device.DLNA_PREFIX.equals(deviceNodeChild.getPrefix())) {
                descriptor.dlnaCaps = DLNACaps.valueOf(XMLUtil.getTextContent(deviceNodeChild));
            }
        }
    }

    public void hydrateIconList(MutableDevice descriptor, Node iconListNode) throws DescriptorBindingException {

        NodeList iconListNodeChildren = iconListNode.getChildNodes();
        for (int i = 0; i < iconListNodeChildren.getLength(); i++) {
            Node iconListNodeChild = iconListNodeChildren.item(i);

            if (iconListNodeChild.getNodeType() != Node.ELEMENT_NODE)
                continue;

            if (ELEMENT.icon.equals(iconListNodeChild)) {

                MutableIcon icon = new MutableIcon();

                NodeList iconChildren = iconListNodeChild.getChildNodes();

                for (int x = 0; x < iconChildren.getLength(); x++) {
                    Node iconChild = iconChildren.item(x);

                    if (iconChild.getNodeType() != Node.ELEMENT_NODE)
                        continue;

                    if (ELEMENT.width.equals(iconChild)) {
                        icon.width = (Integer.valueOf(XMLUtil.getTextContent(iconChild)));
                    } else if (ELEMENT.height.equals(iconChild)) {
                        icon.height = (Integer.valueOf(XMLUtil.getTextContent(iconChild)));
                    } else if (ELEMENT.depth.equals(iconChild)) {
                        icon.depth = (Integer.valueOf(XMLUtil.getTextContent(iconChild)));
                    } else if (ELEMENT.url.equals(iconChild)) {
                        icon.uri = parseURI(XMLUtil.getTextContent(iconChild));
                    } else if (ELEMENT.mimetype.equals(iconChild)) {
                        icon.mimeType = XMLUtil.getTextContent(iconChild);
                    }

                }

                descriptor.icons.add(icon);
            }
        }
    }

    public void hydrateServiceList(MutableDevice descriptor, Node serviceListNode) throws DescriptorBindingException {

        NodeList serviceListNodeChildren = serviceListNode.getChildNodes();
        for (int i = 0; i < serviceListNodeChildren.getLength(); i++) {
            Node serviceListNodeChild = serviceListNodeChildren.item(i);

            if (serviceListNodeChild.getNodeType() != Node.ELEMENT_NODE)
                continue;

            if (ELEMENT.service.equals(serviceListNodeChild)) {

                MutableService service = new MutableService();

                NodeList serviceChildren = serviceListNodeChild.getChildNodes();

                for (int x = 0; x < serviceChildren.getLength(); x++) {
                    Node serviceChild = serviceChildren.item(x);

                    if (serviceChild.getNodeType() != Node.ELEMENT_NODE)
                        continue;

                    if (ELEMENT.serviceType.equals(serviceChild)) {
                        service.serviceType = (ServiceType.valueOf(XMLUtil.getTextContent(serviceChild)));
                    } else if (ELEMENT.serviceId.equals(serviceChild)) {
                        service.serviceId = (ServiceId.valueOf(XMLUtil.getTextContent(serviceChild)));
                    } else if (ELEMENT.SCPDURL.equals(serviceChild)) {
                        service.descriptorURI = parseURI(XMLUtil.getTextContent(serviceChild));
                    } else if (ELEMENT.controlURL.equals(serviceChild)) {
                        service.controlURI = parseURI(XMLUtil.getTextContent(serviceChild));
                    } else if (ELEMENT.eventSubURL.equals(serviceChild)) {
                        service.eventSubscriptionURI = parseURI(XMLUtil.getTextContent(serviceChild));
                    }

                }

                descriptor.services.add(service);
            }
        }
    }

    public void hydrateDeviceList(MutableDevice descriptor, Node deviceListNode) throws DescriptorBindingException {

        NodeList deviceListNodeChildren = deviceListNode.getChildNodes();
        for (int i = 0; i < deviceListNodeChildren.getLength(); i++) {
            Node deviceListNodeChild = deviceListNodeChildren.item(i);

            if (deviceListNodeChild.getNodeType() != Node.ELEMENT_NODE)
                continue;

            if (ELEMENT.device.equals(deviceListNodeChild)) {
                MutableDevice embeddedDevice = new MutableDevice();
                embeddedDevice.parentDevice = descriptor;
                descriptor.embeddedDevices.add(embeddedDevice);
                hydrateDevice(embeddedDevice, deviceListNodeChild);
            }
        }

    }

    public String generate(Device deviceModel, ControlPointInfo info, Namespace namespace) throws DescriptorBindingException {
        try {
            log.fine("Generating XML descriptor from device model: " + deviceModel);

            return XMLUtil.documentToString(buildDOM(deviceModel, info, namespace));

        } catch (Exception ex) {
            throw new DescriptorBindingException("Could not build DOM: " + ex.getMessage(), ex);
        }
    }

    public Document buildDOM(Device deviceModel, ControlPointInfo info, Namespace namespace) throws DescriptorBindingException {

        try {
            log.fine("Generating DOM from device model: " + deviceModel);

            DocumentBuilderFactory factory = DocumentBuilderFactory.newInstance();
            factory.setNamespaceAware(true);

            Document d = factory.newDocumentBuilder().newDocument();
            generateRoot(namespace, deviceModel, d, info);

            return d;

        } catch (Exception ex) {
            throw new DescriptorBindingException("Could not generate device descriptor: " + ex.getMessage(), ex);
        }
    }

    protected void generateRoot(Namespace namespace, Device deviceModel, Document descriptor, ControlPointInfo info) {

        Element rootElement = descriptor.createElementNS(Descriptor.Device.NAMESPACE_URI, ELEMENT.root.toString());
        descriptor.appendChild(rootElement);

        generateSpecVersion(namespace, deviceModel, descriptor, rootElement);

        /* UDA 1.1 spec says: Don't use URLBase anymore
        if (deviceModel.getBaseURL() != null) {
            appendChildElementWithTextContent(descriptor, rootElement, "URLBase", deviceModel.getBaseURL());
        }
        */

        generateDevice(namespace, deviceModel, descriptor, rootElement, info);
    }

    protected void generateSpecVersion(Namespace namespace, Device deviceModel, Document descriptor, Element rootElement) {
        Element specVersionElement = appendNewElement(descriptor, rootElement, ELEMENT.specVersion);
        appendNewElementIfNotNull(descriptor, specVersionElement, ELEMENT.major, deviceModel.getVersion().getMajor());
        appendNewElementIfNotNull(descriptor, specVersionElement, ELEMENT.minor, deviceModel.getVersion().getMinor());
    }

    protected void generateDevice(Namespace namespace, Device deviceModel, Document descriptor, Element rootElement, ControlPointInfo info) {

        Element deviceElement = appendNewElement(descriptor, rootElement, ELEMENT.device);

        appendNewElementIfNotNull(descriptor, deviceElement, ELEMENT.deviceType, deviceModel.getType());
        appendNewElementIfNotNull(descriptor, deviceElement, ELEMENT.UDN, deviceModel.getIdentity().getUdn());

        DeviceDetails deviceModelDetails = deviceModel.getDetails(info);
        appendNewElementIfNotNull(
                descriptor, deviceElement, ELEMENT.friendlyName,
                deviceModelDetails.getFriendlyName()
        );
        if (deviceModelDetails.getManufacturerDetails() != null) {
            appendNewElementIfNotNull(
                    descriptor, deviceElement, ELEMENT.manufacturer,
                    deviceModelDetails.getManufacturerDetails().getManufacturer()
            );
            appendNewElementIfNotNull(
                    descriptor, deviceElement, ELEMENT.manufacturerURL,
                    deviceModelDetails.getManufacturerDetails().getManufacturerURI()
            );
        }
        if (deviceModelDetails.getModelDetails() != null) {
            appendNewElementIfNotNull(
                    descriptor, deviceElement, ELEMENT.modelDescription,
                    deviceModelDetails.getModelDetails().getModelDescription()
            );
            appendNewElementIfNotNull(
                    descriptor, deviceElement, ELEMENT.modelName,
                    deviceModelDetails.getModelDetails().getModelName()
            );
            appendNewElementIfNotNull(
                    descriptor, deviceElement, ELEMENT.modelNumber,
                    deviceModelDetails.getModelDetails().getModelNumber()
            );
            appendNewElementIfNotNull(
                    descriptor, deviceElement, ELEMENT.modelURL,
                    deviceModelDetails.getModelDetails().getModelURI()
            );
        }
        appendNewElementIfNotNull(
                descriptor, deviceElement, ELEMENT.serialNumber,
                deviceModelDetails.getSerialNumber()
        );
        appendNewElementIfNotNull(
                descriptor, deviceElement, ELEMENT.presentationURL,
                deviceModelDetails.getPresentationURI()
        );
        appendNewElementIfNotNull(
                descriptor, deviceElement, ELEMENT.UPC,
                deviceModelDetails.getUpc()
        );

        if (deviceModelDetails.getDlnaDocs() != null) {
            for (DLNADoc dlnaDoc : deviceModelDetails.getDlnaDocs()) {
                appendNewElementIfNotNull(
                        descriptor, deviceElement, Descriptor.Device.DLNA_PREFIX + ":" + ELEMENT.X_DLNADOC,
                        dlnaDoc, Descriptor.Device.DLNA_NAMESPACE_URI
                );
            }
        }
        appendNewElementIfNotNull(
                descriptor, deviceElement, Descriptor.Device.DLNA_PREFIX + ":" + ELEMENT.X_DLNACAP,
                deviceModelDetails.getDlnaCaps(), Descriptor.Device.DLNA_NAMESPACE_URI
        );

        generateIconList(namespace, deviceModel, descriptor, deviceElement);
        generateServiceList(namespace, deviceModel, descriptor, deviceElement);
        generateDeviceList(namespace, deviceModel, descriptor, deviceElement, info);
    }

    protected void generateIconList(Namespace namespace, Device deviceModel, Document descriptor, Element deviceElement) {
        if (!deviceModel.hasIcons()) return;

        Element iconListElement = appendNewElement(descriptor, deviceElement, ELEMENT.iconList);

        for (Icon icon : deviceModel.getIcons()) {
            Element iconElement = appendNewElement(descriptor, iconListElement, ELEMENT.icon);

            appendNewElementIfNotNull(descriptor, iconElement, ELEMENT.mimetype, icon.getMimeType());
            appendNewElementIfNotNull(descriptor, iconElement, ELEMENT.width, icon.getWidth());
            appendNewElementIfNotNull(descriptor, iconElement, ELEMENT.height, icon.getHeight());
            appendNewElementIfNotNull(descriptor, iconElement, ELEMENT.depth, icon.getDepth());
            appendNewElementIfNotNull(descriptor, iconElement, ELEMENT.url, icon.getUri());
        }
    }

    protected void generateServiceList(Namespace namespace, Device deviceModel, Document descriptor, Element deviceElement) {
        if (!deviceModel.hasServices()) return;

        Element serviceListElement = appendNewElement(descriptor, deviceElement, ELEMENT.serviceList);

        for (Service service : deviceModel.getServices()) {
            Element serviceElement = appendNewElement(descriptor, serviceListElement, ELEMENT.service);

            appendNewElementIfNotNull(descriptor, serviceElement, ELEMENT.serviceType, service.getServiceType());
            appendNewElementIfNotNull(descriptor, serviceElement, ELEMENT.serviceId, service.getServiceId());
            if (service instanceof RemoteService) {
                RemoteService rs = (RemoteService) service;
                appendNewElementIfNotNull(descriptor, serviceElement, ELEMENT.controlURL, rs.getControlURI());
                appendNewElementIfNotNull(descriptor, serviceElement, ELEMENT.eventSubURL, rs.getEventSubscriptionURI());
                appendNewElementIfNotNull(descriptor, serviceElement, ELEMENT.SCPDURL, rs.getDescriptorURI());
            } else if (service instanceof LocalService) {
                LocalService ls = (LocalService) service;
                appendNewElementIfNotNull(descriptor, serviceElement, ELEMENT.controlURL, namespace.getControlPath(ls));
                appendNewElementIfNotNull(descriptor, serviceElement, ELEMENT.eventSubURL, namespace.getEventSubscriptionPath(ls));
                appendNewElementIfNotNull(descriptor, serviceElement, ELEMENT.SCPDURL, namespace.getDescriptorPath(ls));
            }
        }
    }

    protected void generateDeviceList(Namespace namespace, Device deviceModel, Document descriptor, Element deviceElement, ControlPointInfo info) {
        if (!deviceModel.hasEmbeddedDevices()) return;

        Element deviceListElement = appendNewElement(descriptor, deviceElement, ELEMENT.deviceList);

        for (Device device : deviceModel.getEmbeddedDevices()) {
            generateDevice(namespace, device, descriptor, deviceListElement, info);
        }
    }

    static protected URI parseURI(String uri) {

        // TODO: UPNP VIOLATION: Netgear DG834 uses a non-URI: 'www.netgear.com'
        if (uri.startsWith("www.")) {
             uri = "http://" + uri;
        }

        try {
            return URI.create(uri);
        } catch (IllegalArgumentException ex) {
            log.fine("Illegal URI, trying with ./ prefix: " + Exceptions.unwrap(ex));
            // Ignore
        }
        try {
            // The java.net.URI class can't deal with "_urn:foobar" (yeah, great idea Intel UPnP tools guy), as
            // explained in RFC 3986:
            //
            // A path segment that contains a colon character (e.g., "this:that") cannot be used as the first segment
            // of a relative-path reference, as it would be mistaken for a scheme name. Such a segment must
            // be preceded by a dot-segment (e.g., "./this:that") to make a relative-path reference.
            //
            return URI.create("./" + uri);
        } catch (IllegalArgumentException ex) {
            log.warning("Illegal URI '" + uri + "', ignoring value: " + Exceptions.unwrap(ex));
            // Ignore
        }
        return null;
    }

}
