package gizmoball.xml;

import java.io.File;
import java.io.IOException;
import java.io.InputStream;
import java.util.HashMap;
import java.util.Map;
import javax.xml.parsers.*;

import gizmoball.NotImplementedException;
import gizmoball.game.*;
import org.apache.log4j.Logger;
import org.w3c.dom.*;
import org.xml.sax.SAXException;
import physics.Angle;
import physics.Vect;

/**
 * A reader for the Gizmoball XML format.  This acts as a factory
 * method for {@link GameBoard GameBoards} from serialized game
 * boards in XML.
 *
 * <p>This uses reflection to find the gizmo classes as they are
 * needed.  Note that this means gizmo classes must follow certain
 * conventions to be automatically supported by the reader.
 * <ul>
 * <li>The class name must be "gizmoball.game.NameGizmo" where
 * Name is the tag name in the XML file with the first letter
 * capitalized.
 * <li>The class must have an appropriate subset of the properties:
 * Name (String), Position (Vect), Velocity (Vect), Orientation
 * (Integer), and Size (Integer)
 * <li>The class must provide a default constructor of no arguments
 * </ul>
 * If a gizmo class does not conform to these standards, a special
 * case will have to be added.
 *
 * @see GizmoballWriter
 * @author Austin Clements
 * @version 1.0
 */
public class GizmoballReader {
    private static final String JAXP_SCHEMA_LANGUAGE =
        "http://java.sun.com/xml/jaxp/properties/schemaLanguage";
    private static final String W3C_XML_SCHEMA =
        "http://www.w3.org/2001/XMLSchema";
    private static final String JAXP_SCHEMA_SOURCE =
        "http://java.sun.com/xml/jaxp/properties/schemaSource";

    private static final String SCHEMA = "gb_level.xsd";

    private static Logger logger = Logger.getLogger(GizmoballReader.class);

    /**
     * This object is purely static, so this ctor is not callable.
     */
    private GizmoballReader() {
        throw new NotImplementedException();
    }

    private static Map/*<String, AbstractGizmo>*/ nameMap;

    /**
     * Deserialize a game board from the specified XML file (which
     * must have been written by GizmoballWriter).
     *
     * @param file the XML file to load from
     * @return the deserialized game board
     * @throws IOException if there is an I/O problem reading from
     * file or if file is not a Gizmoball game board
     */
    public static GameBoard load(File file)
        throws IOException {
        Document doc = openDocument(file);
        GameBoard gb = new GameBoard();
        nameMap = new HashMap();

        loadBoard(gb, doc.getDocumentElement());

        return gb;
    }

    /**
     * Load an entire board, given the board element
     */
    private static void loadBoard(GameBoard gb, Element board)
        throws IOException {
        // FIXME Load gravity and friction

        NodeList boardNodes = board.getChildNodes();
        for (int i = 0, c = boardNodes.getLength(); i < c; ++i) {
            Node n = boardNodes.item(i);

            if (n.getNodeType() == Node.ELEMENT_NODE) {
                Element e = (Element)n;
                String nodeName = n.getNodeName();

                if (nodeName.equals("ball")) {
                    loadGizmo(gb, e);
                } else if (nodeName.equals("gizmos")) {
                    loadGizmos(gb, e.getChildNodes());
                } else if (nodeName.equals("connections")) {
                    loadConnections(gb, e.getChildNodes());
                } else {
                    throw new RuntimeException
                        ("Expected ball/gizmos/connections");
                }
            } else if (n.getNodeType() == Node.TEXT_NODE) {
                // Ignore it
            } else {
                System.out.println("Got " + n.getNodeType());
                throw new RuntimeException("Expected element node");
            }
        }
    }

    /**
     * Load the gizmos section of the board
     */
    private static void loadGizmos(GameBoard gb, NodeList gizmos) {
        for (int i = 0, c = gizmos.getLength(); i < c; ++i) {
            Node n = gizmos.item(i);

            if (n.getNodeType() == Node.ELEMENT_NODE) {
                Element e = (Element)n;

                loadGizmo(gb, e);
            } else if (n.getNodeType() == Node.TEXT_NODE) {
                // Ignore it
            } else {
                throw new RuntimeException("Expected element node");
            }
        }
    }

    /**
     * A mapping from gizmo names (as they appear in the XML tags) to
     * the classes representing those gizmos.  This is used to cache
     * this information.
     */
    private static Map gizmoNameToClassMap = new HashMap();

    /**
     * Get the class representing a gizmo from the name of a gizmo
     * tag.  This caches the value ones it's been figured out the
     * first time.
     */
    private static Class getGizmoClass(String tagName)
        throws IllegalArgumentException {
        Class gizmoClass = (Class)gizmoNameToClassMap.get(tagName);

        if (gizmoClass == null) {
            // Haven't seen this gizmo before, so find its class
            String className = "gizmoball.game." +
                tagName.substring(0,1).toUpperCase() +
                tagName.substring(1) + "Gizmo";

            try {
                logger.debug("Finding gizmo class " + className);
                gizmoClass = Class.forName(className);
            } catch (ClassNotFoundException e) {
                throw new IllegalArgumentException("No such gizmo class");
            }

            gizmoNameToClassMap.put(tagName, gizmoClass);
        }

        return gizmoClass;
    }

    /**
     * Create a gizmo based on the gizmo's tag and add it to the game
     * board.
     */
    private static void loadGizmo(GameBoard gb, Element gizmoElem) {
        String gizmoType = gizmoElem.getNodeName();
        Class gizmoClass;

        // Deal with flippers (special case)
        if (gizmoType.equals("rightFlipper")) {
            gizmoType = "flipper";
            gizmoElem.setAttribute("handedness", "right");
        } else if (gizmoType.equals("leftFlipper")) {
            gizmoType = "flipper";
            gizmoElem.setAttribute("handedness", "left");
        }

        // Get the gizmo class
        try {
            gizmoClass = getGizmoClass(gizmoType);
        } catch (IllegalArgumentException e) {
            throw new RuntimeException("File specified unknown gizmo type",
                                       e.getCause());
        }

        // Create the gizmo
        AbstractGizmo gizmo;
        try {
            gizmo = (AbstractGizmo)gizmoClass.newInstance();
        } catch (IllegalAccessException e) {
            throw new RuntimeException("Error instantiating new gizmo", e);
        } catch (InstantiationException e) {
            throw new RuntimeException("Error instantiating new gizmo", e);
        }
        String name = gizmoElem.getAttribute("name");
        gizmo.setName(name);
        nameMap.put(name, gizmo);

        // Set the appropriate properties
        if (gizmoElem.hasAttribute("x") && gizmoElem.hasAttribute("y")) {
            double x = getDoubleAttribute(gizmoElem, "x");
            double y = getDoubleAttribute(gizmoElem, "y");
            Vect position = new Vect(x, y);
            try {
                gizmo.setProperty("Position", position);
            } catch (IllegalArgumentException e) {
                throw new RuntimeException("Expected Position property");
            }
        }
        if (gizmoElem.hasAttribute("xVelocity") &&
            gizmoElem.hasAttribute("yVelocity")) {
            double xVel = getDoubleAttribute(gizmoElem, "xVelocity");
            double yVel = getDoubleAttribute(gizmoElem, "yVelocity");
            Vect velocity = new Vect(xVel, yVel);
            try {
                gizmo.setProperty("Velocity", velocity);
            } catch (IllegalArgumentException e) {
                throw new RuntimeException("Expected Velocity property");
            }
        }
        if (gizmoElem.hasAttribute("orientation")) {
            int orientation = getIntAttribute(gizmoElem, "orientation");
            try {
                gizmo.setProperty("Orientation",
                                  new Angle(Math.toRadians(orientation)));
            } catch (IllegalArgumentException e) {
                throw new RuntimeException("Expected Orientation property");
            }
        }
        if (gizmoElem.hasAttribute("width") &&
            gizmoElem.hasAttribute("height")) {
            double width = getDoubleAttribute(gizmoElem, "width");
            double height = getDoubleAttribute(gizmoElem, "height");
            Vect size = new Vect(width, height);
            try {
                gizmo.setProperty("Size", size);
            } catch (IllegalArgumentException e) {
                throw new RuntimeException("Expected Size property");
            }
        }
        if (gizmoElem.hasAttribute("handedness")) {
            String handedness = gizmoElem.getAttribute("handedness");
            boolean handednessB;
            if (handedness.equals("right")) {
                handednessB = false;
            } else if (handedness.equals("left")) {
                handednessB = true;
            } else {
                throw new RuntimeException("Invalid handedness");
            }
            
            try {
                gizmo.setProperty("Handedness", new Boolean(handednessB));
            } catch (IllegalArgumentException e) {
                throw new RuntimeException("Expected Handedness property");
            }
        }

        // Stick it in the board
        logger.debug("Adding " + gizmo + " to the board");
        gb.addGizmo(gizmo);
    }

    /**
     * Load the connections section of the board
     */
    private static void loadConnections(GameBoard gb, NodeList connections)
        throws IOException {
        for (int i = 0, c = connections.getLength(); i < c; ++i) {
            Node n = connections.item(i);

            if (n.getNodeType() == Node.ELEMENT_NODE) {
                Element e = (Element)n;
                String nodeName = n.getNodeName();

                if (nodeName.equals("connect")) {
                    AbstractGizmo source =
                        (AbstractGizmo)nameMap.get
                        (e.getAttribute("sourceGizmo"));
                    AbstractGizmo target =
                        (AbstractGizmo)nameMap.get
                        (e.getAttribute("targetGizmo"));

                    if (source == null || target == null) {
                        throw new IOException
                            ("Invalid syntax: Unknown gizmo name in connect");
                    }

                    AbstractTrigger trigger =
                        new CollisionTrigger(source, target);

                    gb.addTrigger(trigger);
                } else if (nodeName.equals("keyConnect")) {
                    int key = getIntAttribute(e, "key");
                    String keyDirection = e.getAttribute("keyDirection");
                    AbstractGizmo target =
                        (AbstractGizmo)nameMap.get
                        (e.getAttribute("targetGizmo"));

                    if (target == null) {
                        throw new IOException
                            ("Invalid syntax: "+
                             "Unknown gizmo name in keyConnect");
                    }
                    if (keyDirection.equals("up")) {
                        // FIXME Deal with keyDirection
                        continue;
                    } else if (keyDirection.equals("down")) {
                        // FIXME
                    } else {
                        throw new RuntimeException("Bad keyDirection");
                    }

                    AbstractTrigger trigger =
                        new KeypressTrigger(key, target);

                    gb.addTrigger(trigger);
                } else {
                    throw new RuntimeException
                        ("Expected connect/keyConnect");
                }
            } else if (n.getNodeType() == Node.TEXT_NODE) {
                // Ignore it
            } else {
                throw new RuntimeException("Expected element node");
            }
        }
    }

    /**
     * Get an attribute value as a double
     */
    private static double getDoubleAttribute(Element elem, String attr) {
        return Double.parseDouble(elem.getAttribute(attr));
    }

    /**
     * Get an attribute value as an int
     */
    private static int getIntAttribute(Element elem, String attr) {
        return Integer.parseInt(elem.getAttribute(attr));
    }

    /**
     * Get an attribute value as a boolean
     */
    private static boolean getBooleanAttribute(Element elem, String attr) {
        System.out.println(elem.getAttribute(attr));
        
        return Boolean.valueOf(elem.getAttribute(attr)).booleanValue();
    }

    /**
     * Parse the given file using DOM
     */
    private static Document openDocument(File file)
        throws IOException {
        // Set up document builder options
        DocumentBuilderFactory dbf = DocumentBuilderFactory.newInstance();

        dbf.setNamespaceAware(true);
        dbf.setValidating(true);
        dbf.setIgnoringElementContentWhitespace(true);
        dbf.setIgnoringComments(true);

        // Set up document schema
        InputStream schema = ClassLoader.getSystemResourceAsStream(SCHEMA);
        if (schema == null) {
            logger.error("Failed to find schema resource");
        } else {
            dbf.setAttribute(JAXP_SCHEMA_LANGUAGE, W3C_XML_SCHEMA);
            dbf.setAttribute(JAXP_SCHEMA_SOURCE, schema);
        }

        // Get the document builder
        DocumentBuilder db;
        try {
            db = dbf.newDocumentBuilder();
        } catch (ParserConfigurationException e) {
            throw new IOException
                ("XML parser does not support necessary options");
        }

        // Do the XML dance
        try {
            return db.parse(file);
        } catch (SAXException e) {
            throw new IOException("Failed to parse XML data");
        }
    }
}
