/*
 * Copyright (C) 2016.
 *
 * This program is free software; you can redistribute it and/or modify
 * it under the terms of the GNU General Public License version 3 or
 * version 2 as published by the Free Software Foundation.
 *
 * 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
 * General Public License for more details.
 */
package uk.me.parabola.mkgmap.combiners;

import java.io.File;
import java.io.IOException;
import java.io.Writer;
import java.nio.ByteBuffer;
import java.nio.channels.ByteChannel;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.nio.file.StandardCopyOption;
import java.util.HashMap;
import java.util.Map;
import java.util.Objects;

import javax.xml.stream.XMLOutputFactory;
import javax.xml.stream.XMLStreamException;
import javax.xml.stream.XMLStreamWriter;

import uk.me.parabola.imgfmt.ExitException;
import uk.me.parabola.imgfmt.fs.DirectoryEntry;
import uk.me.parabola.imgfmt.fs.FileSystem;
import uk.me.parabola.imgfmt.fs.ImgChannel;
import uk.me.parabola.imgfmt.sys.ImgFS;
import uk.me.parabola.mkgmap.CommandArgs;

import static java.nio.file.StandardOpenOption.*;

/**
 * Create a map in the gmapi format.
 *
 * This is directory tree containing an XML file describing the contents, and exploded versions of
 * each .img file.
 */
public class GmapiBuilder implements Combiner {
	private final static String NS = "http://www.garmin.com/xmlschemas/MapProduct/v1";

	private final Map<String, Combiner> combinerMap;

	private Path gmapDir;
	private final Map<Integer, ProductInfo> productMap = new HashMap<>();

	private String familyName;
	private int familyId;
	private short productVersion;

	private String typFile;

	public GmapiBuilder(Map<String, Combiner> combinerMap) {
		this.combinerMap = combinerMap;
	}

	/**
	 * Initialise with the command line arguments.  This is called after all
	 * the command line arguments have been processed, but before any calls to
	 * the {@link #onMapEnd} methods.
	 *
	 * @param args The command line arguments.
	 */
	public void init(CommandArgs args) {
		familyName = args.get("family-name", "OSM map");
		familyId = args.get("family-id", CommandArgs.DEFAULT_FAMILYID);
		productVersion = (short) args.get("product-version", 100);

		gmapDir = Paths.get(args.getOutputDir(), String.format("%s.gmap", familyName));
	}

	/**
	 * This is called when an individual map is complete.
	 *
	 * @param info An interface to read the map.
	 */
	public void onMapEnd(FileInfo info) {

		String fn = info.getFilename();
		String mapname = info.getMapname();

		int productId = info.getProductId();
		if (!productMap.containsKey(productId))
			productMap.put(productId, new ProductInfo(productId, info.getSeriesName(), info.getOverviewName()));

		// Unzip the image into the product tile directory.
		try {
			if (info.isImg())
				unzipImg(fn, mapname, productId);
			else if (info.getKind() == FileKind.TYP_KIND)
				typFile = info.getFilename();

		} catch (IOException e) {
			e.printStackTrace();
		}
	}

	/**
	 * The complete map set has been processed.  Finish off anything that needs
	 * doing.
	 */
	public void onFinish() {
		try {
			if (combinerMap.containsKey("mdx")) {
				File file = new File(getFilenameFor("mdx"));
				Files.copy(file.toPath(), gmapDir.resolve(file.getName()), StandardCopyOption.REPLACE_EXISTING);
			}
			if (combinerMap.containsKey("mdr")) {
				File file = new File(getFilenameFor("mdr"));
				unzipImg(file.getCanonicalPath(), gmapDir.resolve(nameWithoutExtension(file)));
			}

			if (typFile != null) {
				File file = new File(typFile);
				Files.copy(file.toPath(), gmapDir.resolve(file.getName()), StandardCopyOption.REPLACE_EXISTING);
			}

			for (ProductInfo info : productMap.values()) {
				finishTdbFile(info);
				unzipImg(getFilenameFor("img"), info.overviewName, info.id);
			}

			writeXmlFile(gmapDir);

		} catch (IOException e) {
			e.printStackTrace();
		}
	}

	private static String nameWithoutExtension(File file) {
		String name = file.getName();
		int len = name.length();
		if (len < 4)
			return name;
		return name.substring(0, len-4);
	}

	private void finishTdbFile(ProductInfo info) throws IOException {
		Path tdbPath = Paths.get(getFilenameFor("tdb"));

		Files.copy(tdbPath, gmapDir
				.resolve(String.format("Product%d", info.id))
				.resolve(String.format("%s.tdb", info.overviewName)), StandardCopyOption.REPLACE_EXISTING);
	}

	private void unzipImg(String srcImgName, String mapname, int productId) throws IOException {
		Path destDir = Paths.get(gmapDir.toString(), "Product" + productId, mapname);

		unzipImg(srcImgName, destDir);
	}

	private static void unzipImg(String srcImgName, Path destDir) throws IOException {
		FileSystem fs = ImgFS.openFs(srcImgName);
		for (DirectoryEntry ent : fs.list()) {
			String fullname = ent.getFullName();

			try (ImgChannel f = fs.open(fullname, "r")) {
				String name = displayName(fullname);
				if (Objects.equals(name, "."))
					continue;

				Files.createDirectories(destDir);
				copyToFile(f, destDir.resolve(name));
			}
		}
	}

	private static void copyToFile(ImgChannel f, Path dest) {
		ByteBuffer buf = ByteBuffer.allocate(8 * 1024);
		try (ByteChannel outchan = Files.newByteChannel(dest, CREATE, WRITE, TRUNCATE_EXISTING)) {
			while (f.read(buf) > 0) {
				buf.flip();
				outchan.write(buf);
				buf.compact();
			}
		} catch (IOException e) {
			throw new ExitException("Cannot write file " + e);
		}
	}

	private String getFilenameFor(String kind) {
		return combinerMap.get(kind).getFilename();
	}

	private static String displayName(String fullname) {
		return fullname.trim().replace("\000", "");
	}

	/**
	 * An xml file contains similar information that is contained in the windows registry.
	 *
	 * @param gDir The directory where the Info.xml file will be created.
	 */
	private void writeXmlFile(Path gDir) {
		Path infoFile = gDir.resolve("Info.xml");

		XMLOutputFactory factory = XMLOutputFactory.newFactory();

		try (Writer stream = Files.newBufferedWriter(infoFile)) {

			XMLStreamWriter writer = factory.createXMLStreamWriter(stream);

			writer.writeStartDocument("UTF-8", "1.0");
			writer.setDefaultNamespace(NS);
			writer.writeCharacters("\n");

			writer.writeStartElement(NS,"MapProduct");
			writer.writeDefaultNamespace(NS);
			writer.writeCharacters("\n");

			xmlElement(writer, "Name", familyName);
			xmlElement(writer, "DataVersion", String.valueOf(productVersion));
			xmlElement(writer, "DataFormat", "Original");
			xmlElement(writer, "ID", String.valueOf(familyId));

			if (combinerMap.containsKey("mdx")) {
				String mdxFile = getFilenameFor("mdx");

				File file = new File(mdxFile);
				xmlElement(writer, "IDX", file.getName());
			}

			if (combinerMap.containsKey("mdr")) {
				String mdrName = getFilenameFor("mdr");
				File file = new File(mdrName);
				xmlElement(writer, "MDR", nameWithoutExtension(file));
			}

			if (typFile != null) {
				File file = new File(typFile);
				xmlElement(writer, "TYP", file.getName());
			}

			for (ProductInfo prod : productMap.values()) {
				writer.writeStartElement(NS, "SubProduct");
				writer.writeCharacters("\n");

				xmlElement(writer, "Name", prod.seriesName);
				xmlElement(writer, "ID", String.valueOf(prod.id));
				xmlElement(writer, "BaseMap", prod.overviewName);
				xmlElement(writer, "TDB", String.format("%s.tdb", prod.overviewName));
				xmlElement(writer, "Directory", String.format("Product%s", prod.id));
				writer.writeEndElement();
				writer.writeCharacters("\n");
			}

			writer.writeEndElement();
			writer.writeEndDocument();
			writer.flush();
		} catch (XMLStreamException | IOException e) {
			throw new ExitException("Could not create file " + infoFile + "; " + e);
		}
	}

	private static void xmlElement(XMLStreamWriter writer, String name, String value) throws XMLStreamException {
		writer.writeCharacters(" ");
		writer.writeStartElement(NS, name);
		writer.writeCharacters(value);
		writer.writeEndElement();
		writer.writeCharacters("\n");
	}

	private static class ProductInfo {

		private final String seriesName;
		private final String overviewName;
		private final int id;

		public ProductInfo(int id, String seriesName, String overviewName) {
			this.id = id;
			this.seriesName = seriesName;
			this.overviewName = overviewName;
		}
	}
}
