package org.newdawn.slick.tiled;

import java.io.ByteArrayInputStream;
import java.io.IOException;
import java.io.InputStream;
import java.util.ArrayList;
import java.util.Properties;

import javax.xml.parsers.DocumentBuilder;
import javax.xml.parsers.DocumentBuilderFactory;

import org.newdawn.slick.Image;
import org.newdawn.slick.SlickException;
import org.newdawn.slick.util.Log;
import org.newdawn.slick.util.ResourceLoader;
import org.w3c.dom.Document;
import org.w3c.dom.Element;
import org.w3c.dom.NodeList;
import org.xml.sax.EntityResolver;
import org.xml.sax.InputSource;
import org.xml.sax.SAXException;

/**
 * This class is intended to parse TilED maps. TilED is a generic tool for tile map editing and can
 * be found at:
 * 
 * http://mapeditor.org/
 * 
 * @author kevin
 */
public class TiledMap {
	
	/** The width of the map */
	protected int width;
	/** The height of the map */
	protected int height;
	/** The width of the tiles used on the map */
	protected int tileWidth;
	/** The height of the tiles used on the map */
	protected int tileHeight;
	
	/** The location prefix where we can find tileset images */
	protected String tilesLocation;
	
	/** the properties of the map */
	protected Properties props;
	
	/** The list of tilesets defined in the map */
	protected ArrayList tileSets = new ArrayList();
	/** The list of layers defined in the map */
	protected ArrayList layers = new ArrayList();

	/** True if we want to load tilesets - including their image data */
	private boolean loadTileSets = true;
	
	/**
	 * Create a new tile map based on a given TMX file
	 * 
	 * @param ref The location of the tile map to load
	 * @throws SlickException Indicates a failure to load the tilemap
	 */
	public TiledMap(String ref) throws SlickException {
		this(ref, true);
	}

	/**
	 * Create a new tile map based on a given TMX file
	 * 
	 * @param ref The location of the tile map to load
	 * @param loadTileSets True if we want to load tilesets - including their image data
	 * @throws SlickException Indicates a failure to load the tilemap
	 */
	public TiledMap(String ref, boolean loadTileSets) throws SlickException {
		this.loadTileSets = loadTileSets;
		ref = ref.replace('\\','/');
		load(ResourceLoader.getResourceAsStream(ref), ref.substring(0,ref.lastIndexOf("/")));
	}
	
	/**
	 * Create a new tile map based on a given TMX file
	 * 
	 * @param ref The location of the tile map to load
	 * @param tileSetsLocation The location where we can find the tileset images and other resources
	 * @throws SlickException Indicates a failure to load the tilemap
	 */
	public TiledMap(String ref, String tileSetsLocation) throws SlickException {
		load(ResourceLoader.getResourceAsStream(ref), tileSetsLocation);
	}
	
	/**
	 * Load a tile map from an arbitary input stream
	 * 
	 * @param in The input stream to load from
	 * @throws SlickException Indicates a failure to load the tilemap
	 */
	public TiledMap(InputStream in) throws SlickException {
		load(in, "");
	}

	/**
	 * Load a tile map from an arbitary input stream
	 * 
	 * @param in The input stream to load from
	 * @param tileSetsLocation The location at which we can find tileset images
	 * @throws SlickException Indicates a failure to load the tilemap
	 */
	public TiledMap(InputStream in, String tileSetsLocation) throws SlickException {
		load(in, tileSetsLocation);
	}
	
	/**
	 * Get the location of the tile images specified
	 * 
	 * @return The location of the tile images specified as a resource reference prefix
	 */
	public String getTilesLocation() {
		return tilesLocation;
	}
	
	/**
     * Get the index of the layer with given name
     * 
     * @param name The name of the tile to search for
     * @return The index of the layer or -1 if there is no layer with given name
     */
   public int getLayerIndex(String name) {
      int idx = 0;
      
      for (int i=0;i<layers.size();i++) {
         Layer layer = (Layer) layers.get(i);
         
         if (layer.name.equals(name)) {
            return i;
         }
      }
      
      return -1;
   }
   
   /**
    * Gets the Image used to draw the tile at the given x and y coordinates.
    * 
    * @param x The x coordinate of the tile whose image should be retrieved
    * @param y The y coordinate of the tile whose image should be retrieved
    * @param layerIndex The index of the layer on which the tile whose image should be retrieve exists
    * @return The image used to draw the specified tile or null if there is no image for the
    * specified tile.
    */
   public Image getTileImage(int x, int y, int layerIndex) {
      Layer layer = (Layer) layers.get(layerIndex);
      
      int tileSetIndex = layer.data[x][y][0];
      if ((tileSetIndex >= 0) && (tileSetIndex < tileSets.size())) {
    	  TileSet tileSet = (TileSet) tileSets.get(tileSetIndex);
    	  
          int sheetX = tileSet.getTileX(layer.data[x][y][1]);
          int sheetY = tileSet.getTileY(layer.data[x][y][1]);
          
          return tileSet.tiles.getSprite(sheetX, sheetY);
      }
      
      return null;
   } 
   
	/**
	 * Get the width of the map
	 * 
	 * @return The width of the map (in tiles)
	 */
	public int getWidth() {
		return width;
	}

	/**
	 * Get the height of the map
	 * 
	 * @return The height of the map (in tiles)
	 */
	public int getHeight() {
		return height;
	}

	/**
	 * Get the height of a single tile
	 * 
	 * @return The height of a single tile (in pixels)
	 */
	public int getTileHeight() {
		return tileHeight;
	}

	/**
	 * Get the width of a single tile
	 * 
	 * @return The height of a single tile (in pixels)
	 */
	public int getTileWidth() {
		return tileWidth;
	}
	   
	/**
	 * Get the global ID of a tile at specified location in the map
	 * 
	 * @param x
	 *            The x location of the tile
	 * @param y
	 *            The y location of the tile
	 * @param layerIndex
	 *            The index of the layer to retireve the tile from
	 * @return The global ID of the tile
	 */
	public int getTileId(int x,int y,int layerIndex) {
		Layer layer = (Layer) layers.get(layerIndex);
		return layer.getTileID(x,y);
	}
	
	/**
	 * Set the global ID of a tile at specified location in the map
	 * @param x
	 *            The x location of the tile
	 * @param y
	 *            The y location of the tile
	 * @param layerIndex
	 *            The index of the layer to set the new tileid
	 * @param tileid
	 *            The tileid to be set
	 */
	public void setTileId(int x, int y, int layerIndex, int tileid) {
		Layer layer = (Layer) layers.get(layerIndex);
		layer.setTileID(x, y, tileid);
	}
	
	/**
	 * Get a property given to the map. Note that this method will
	 * not perform well and should not be used as part of the default code
	 * path in the game loop.
	 * 
	 * @param propertyName The name of the property of the map to retrieve
	 * @param def The default value to return
	 * @return The value assigned to the property on the map (or the default value if none is supplied)
	 */
	public String getMapProperty(String propertyName, String def) {
		if (props == null)
			return def;
		return props.getProperty(propertyName, def);
	}
	
	/**
	 * Get a property given to a particular layer. Note that this method will
	 * not perform well and should not be used as part of the default code
	 * path in the game loop.
	 * 
	 * @param layerIndex The index of the layer to retrieve
	 * @param propertyName The name of the property of this layer to retrieve
	 * @param def The default value to return
	 * @return The value assigned to the property on the layer (or the default value if none is supplied)
	 */
	public String getLayerProperty(int layerIndex, String propertyName, String def) {
		Layer layer = (Layer) layers.get(layerIndex);
		if (layer == null || layer.props == null)
			return def;
		return layer.props.getProperty(propertyName, def);
	}
	
	
	/**
	 * Get a propety given to a particular tile. Note that this method will
	 * not perform well and should not be used as part of the default code
	 * path in the game loop.
	 * 
	 * @param tileID The global ID of the tile to retrieve
	 * @param propertyName The name of the property to retireve
	 * @param def The default value to return
	 * @return The value assigned to the property on the tile (or the default value if none is supplied)
	 */
	public String getTileProperty(int tileID, String propertyName, String def) {
		if (tileID == 0) {
			return def;
		}
		
		TileSet set = findTileSet(tileID);
		
		Properties props = set.getProperties(tileID);
		if (props == null) {
			return def;
		}
		return props.getProperty(propertyName, def);
	}
	
	/**
	 * Render the whole tile map at a given location
	 * 
	 * @param x The x location to render at 
	 * @param y The y location to render at
	 */
	public void render(int x,int y) {
		render(x,y,0,0,width,height,false);
	}

	/**
	 * Render a section of the tile map
	 * 
	 * @param x The x location to render at
	 * @param y The y location to render at
	 * @param sx The x tile location to start rendering
	 * @param sy The y tile location to start rendering
	 * @param width The width of the section to render (in tiles)
	 * @param height The height of the secton to render (in tiles)
	 */
	public void render(int x,int y,int sx,int sy,int width,int height) {
		render(x,y,sx,sy,width,height,false);
	}

	/**
	 * Render a section of the tile map
	 * 
	 * @param x The x location to render at
	 * @param y The y location to render at
	 * @param sx The x tile location to start rendering
	 * @param sy The y tile location to start rendering
	 * @param width The width of the section to render (in tiles)
	 * @param height The height of the secton to render (in tiles)
	 * @param l The index of the layer to render
	 * @param lineByLine True if we should render line by line, i.e. giving us a chance
	 * to render something else between lines (@see {@link #renderedLine(int, int, int)}
	 */
	public void render(int x,int y,int sx,int sy,int width,int height,int l,boolean lineByLine) {
		Layer layer = (Layer) layers.get(l);
		for (int ty=0;ty<height;ty++) {
			layer.render(x,y,sx,sy,width,ty,lineByLine, tileWidth, tileHeight);
		}
	}
	
	/**
	 * Render a section of the tile map
	 * 
	 * @param x The x location to render at
	 * @param y The y location to render at
	 * @param sx The x tile location to start rendering
	 * @param sy The y tile location to start rendering
	 * @param width The width of the section to render (in tiles)
	 * @param height The height of the secton to render (in tiles)
	 * @param lineByLine True if we should render line by line, i.e. giving us a chance
	 * to render something else between lines (@see {@link #renderedLine(int, int, int)}
	 */
	public void render(int x,int y,int sx,int sy,int width,int height, boolean lineByLine) {
		for (int ty=0;ty<height;ty++) {
			for (int i=0;i<layers.size();i++) {
				Layer layer = (Layer) layers.get(i);
				layer.render(x,y,sx,sy,width, ty,lineByLine, tileWidth, tileHeight);
			}
		}
	}
	
	/**
	 * Retrieve a count of the number of layers available
	 * 
	 * @return The number of layers available in this map
	 */
	public int getLayerCount() {
		return layers.size();
	}
	
	/**
	 * Load a TilED map
	 * 
	 * @param in The input stream from which to load the map
	 * @param tileSetsLocation The location from which we can retrieve tileset images
	 * @throws SlickException Indicates a failure to parse the map or find a tileset
	 */
	private void load(InputStream in, String tileSetsLocation) throws SlickException {
		tilesLocation = tileSetsLocation;
		
		try {
			DocumentBuilderFactory factory = DocumentBuilderFactory.newInstance();
			factory.setValidating(false);
			DocumentBuilder builder = factory.newDocumentBuilder();
			builder.setEntityResolver(new EntityResolver() {
				public InputSource resolveEntity(String publicId,
						String systemId) throws SAXException, IOException {					
					return new InputSource(new ByteArrayInputStream(new byte[0]));
				}
			});
			
			Document doc = builder.parse(in);
			Element docElement = doc.getDocumentElement();
			
			String orient = docElement.getAttribute("orientation");
			if (!orient.equals("orthogonal")) {
				throw new SlickException("Only orthogonal maps supported, found: "+orient);
			}
			
			width = Integer.parseInt(docElement.getAttribute("width"));
			height = Integer.parseInt(docElement.getAttribute("height"));
			tileWidth = Integer.parseInt(docElement.getAttribute("tilewidth"));
			tileHeight = Integer.parseInt(docElement.getAttribute("tileheight"));
			
			// now read the map properties
			Element propsElement = (Element) docElement.getElementsByTagName("properties").item(0);
			if (propsElement != null) {
				NodeList properties = propsElement.getElementsByTagName("property");
				if (properties != null) {
					props = new Properties();
					for (int p = 0; p < properties.getLength();p++) {
						Element propElement = (Element) properties.item(p);
						
						String name = propElement.getAttribute("name");
						String value = propElement.getAttribute("value");		
						props.setProperty(name, value);
					}
				}
			}
			
			if (loadTileSets) {
				TileSet tileSet = null;
				TileSet lastSet = null;
				
				NodeList setNodes = docElement.getElementsByTagName("tileset");
				for (int i=0;i<setNodes.getLength();i++) {
					Element current = (Element) setNodes.item(i);
					
					tileSet = new TileSet(this, current);
					tileSet.index = i;
					
					if (lastSet != null) {
						lastSet.setLimit(tileSet.firstGID-1);
					}
					lastSet = tileSet;
					
					tileSets.add(tileSet);
				}
			}
			
			NodeList layerNodes = docElement.getElementsByTagName("layer");
			for (int i=0;i<layerNodes.getLength();i++) {
				Element current = (Element) layerNodes.item(i);
				Layer layer = new Layer(this, current);
				layer.index = i;
				
				layers.add(layer);
			}
		} catch (Exception e) {
			Log.error(e);
			throw new SlickException("Failed to parse tilemap", e);
		}
	}
	
	/**
	 * Retrieve the number of tilesets available in this map
	 * 
	 * @return The number of tilesets available in this map
	 */
	public int getTileSetCount() {
		return tileSets.size();
	}
	
	/**
	 * Get a tileset at a particular index in the list of sets for this map
	 * 
	 * @param index The index of the tileset. 
	 * @return The TileSet requested
	 */
	public TileSet getTileSet(int index) {
		return (TileSet) tileSets.get(index);
	}
	
	/**
	 * Get a tileset by a given global ID
	 * 
	 * @param gid The global ID of the tileset to retrieve
	 * @return The tileset requested or null if no tileset matches
	 */
	public TileSet getTileSetByGID(int gid) {
		for (int i=0;i<tileSets.size();i++) {
			TileSet set = (TileSet) tileSets.get(i);
			
			if (set.contains(gid)) {
				return set;
			}
		}
		
		return null;
	}
	
	/**
	 * Find a tile for a given global tile id
	 * 
	 * @param gid The global tile id we're looking for
	 * @return The tileset in which that tile lives or null if the gid is not defined
	 */
	public TileSet findTileSet(int gid) {
		for (int i=0;i<tileSets.size();i++) {
			TileSet set = (TileSet) tileSets.get(i);
			
			if (set.contains(gid)) {
				return set;
			}
		}
		
		return null;
	}
	
	/**
	 * Overrideable to allow other sprites to be rendered between lines of the
	 * map
	 * 
	 * @param visualY The visual Y coordinate, i.e. 0->height
	 * @param mapY The map Y coordinate, i.e. y->y+height
	 * @param layer The layer being rendered
	 */
	protected void renderedLine(int visualY, int mapY,int layer) {
	}
}
