/*
 * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS HEADER.
 * 
 * Copyright 2008 jOpenDocument, by ILM Informatique. All rights reserved.
 * 
 * The contents of this file are subject to the terms of the GNU
 * General Public License Version 3 only ("GPL").  
 * You may not use this file except in compliance with the License. 
 * You can obtain a copy of the License at http://www.gnu.org/licenses/gpl-3.0.html
 * See the License for the specific language governing permissions and limitations under the License.
 * 
 * When distributing the software, include this License Header Notice in each file.
 * 
 */

package org.jopendocument.dom.spreadsheet;

import org.jopendocument.dom.ContentType;
import org.jopendocument.dom.ContentTypeVersioned;
import org.jopendocument.dom.ODDocument;
import org.jopendocument.dom.ODPackage;
import org.jopendocument.dom.OOUtils;
import org.jopendocument.dom.XMLVersion;
import org.jopendocument.dom.spreadsheet.SheetTableModel.MutableTableModel;

import java.awt.Point;
import java.io.File;
import java.io.FileNotFoundException;
import java.io.IOException;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.NoSuchElementException;
import java.util.regex.Matcher;
import java.util.regex.Pattern;

import javax.swing.table.TableModel;

import org.jdom.DocType;
import org.jdom.Document;
import org.jdom.Element;
import org.jdom.JDOMException;
import org.jdom.xpath.XPath;

/**
 * A calc document.
 * 
 * @author Sylvain
 */
public class SpreadSheet implements ODDocument {

    public static SpreadSheet createFromFile(File f) throws IOException {
        return create(new ODPackage(f));
    }

    public static SpreadSheet create(final ODPackage fd) {
        return new SpreadSheet(fd.getDocument("content.xml"), fd.getDocument("styles.xml"), fd);
    }

    public static SpreadSheet createEmpty(TableModel t) throws IOException {
        return createEmpty(t, XMLVersion.getOD());
    }

    public static SpreadSheet createEmpty(TableModel t, XMLVersion ns) throws IOException {
        final Document doc = new Document(new Element("document", ns.getOFFICE()));
        // OpenDocument use relaxNG
        if (ns == XMLVersion.OOo)
            doc.setDocType(new DocType("office:document", "-//OpenOffice.org//DTD OfficeDocument 1.0//EN", "office.dtd"));
        final ContentTypeVersioned ct = ContentTypeVersioned.fromType(ContentType.SPREADSHEET, ns);
        if (ct.getVersion().equals(XMLVersion.OOo)) {
            doc.getRootElement().setAttribute("class", ct.getShortName(), ns.getOFFICE());
        } else if (ct.getVersion().equals(XMLVersion.OD)) {
            doc.getRootElement().setAttribute("mimetype", ct.getMimeType(), ns.getOFFICE());
        }
        // don't forget that, otherwise OO crash
        doc.getRootElement().addContent(new Element("automatic-styles", ns.getOFFICE()));

        final Element topBody = new Element("body", ns.getOFFICE());
        final Element body;
        if (ct.getVersion().equals(XMLVersion.OD)) {
            body = new Element(ct.getShortName(), ns.getOFFICE());
            topBody.addContent(body);
        } else
            body = topBody;
        doc.getRootElement().addContent(topBody);
        final Element sheetElem = Sheet.createEmpty(ns);
        body.addContent(sheetElem);

        final SpreadSheet spreadSheet = new SpreadSheet(doc, null);
        spreadSheet.getSheet(0).merge(t, 0, 0, true);
        return spreadSheet;
    }

    /**
     * Export the passed data to file.
     * 
     * @param t the data to export.
     * @param f where to export, if the extension is missing (or wrong) the correct one will be
     *        added, eg "dir/data".
     * @param ns the version of XML.
     * @return the saved file, eg "dir/data.ods".
     * @throws IOException if the file can't be saved.
     */
    public static File export(TableModel t, File f, XMLVersion ns) throws IOException {
        return SpreadSheet.createEmpty(t, ns).saveAs(f);
    }

    private final ODPackage originalFile;
    private final Map<Element, Sheet> sheets;

    public SpreadSheet(Document doc, Document styles) {
        this(doc, styles, null);
    }

    private SpreadSheet(final Document doc, final Document styles, final ODPackage orig) {
        if (orig != null) {
            // ATTN OK because this is our private instance (see createFromFile())
            this.originalFile = orig;
        } else {
            this.originalFile = new ODPackage();
        }
        this.originalFile.putFile("content.xml", doc);
        if (styles != null)
            this.originalFile.putFile("styles.xml", styles);

        // map Sheet by XML elements so has not to depend on ordering or name
        this.sheets = new HashMap<Element, Sheet>();
    }

    final Document getContent() {
        return this.getPackage().getContent().getDocument();
    }

    @Override
    public final XMLVersion getVersion() {
        return this.getPackage().getVersion();
    }

    private Element getBody() {
        final Element body = this.getContent().getRootElement().getChild("body", this.getVersion().getOFFICE());
        if (this.getVersion().equals(XMLVersion.OOo))
            return body;
        else
            return body.getChild("spreadsheet", this.getVersion().getOFFICE());
    }

    // ** from 8.3.1 Referencing Table Cells (just double the backslash for . and escape the $)
    private static final String minCell = "\\$?([A-Z]+)\\$?([0-9]+)";
    // added parens to capture cell address
    // \1 is sheet name, \4 cell address
    static final Pattern cellPattern = Pattern.compile("(\\$?([^\\. ']+|'([^']|'')+'))?\\.(" + minCell + ")");
    static final Pattern minCellPattern = Pattern.compile(minCell);
    // added parens to capture cell addresses
    // \1 is sheet name, \4 cell address, \6 second sheet name, \9 second cell address
    private static final Pattern cellRangePattern = java.util.regex.Pattern
            .compile("(\\$?([^\\. ']+|'([^']|'')+'))?\\.(\\$?[A-Z]+\\$?[0-9]+)(:(\\$?([^\\. ']+|'([^']|'')+'))?\\.(\\$?[A-Z]+\\$?[0-9]+))?");

    protected final String parseSheetName(final String n) {
        if (n == null)
            return null;

        // ToDo handle '' (but OpenOffice doesn't)
        return n.charAt(0) == '$' ? n.substring(1) : n;
    }

    /**
     * Return a view of the passed range.
     * 
     * @param name a named range.
     * @return the matching TableModel, <code>null</code> if it doesn't exist.
     */
    public final MutableTableModel<SpreadSheet> getTableModel(String name) {
        final Element range;
        try {
            final XPath path = this.getXPath("./table:named-expressions/table:named-range[@table:name='" + name + "']");
            range = (Element) path.selectSingleNode(this.getBody());
        } catch (JDOMException e) {
            throw new IllegalStateException(e);
        }
        if (range == null)
            return null;

        // OpenOffice only supports absolute addresses, so need to use base-cell-address
        final String baseCell = range.getAttributeValue("cell-range-address", getVersion().getTABLE());
        final Matcher m = cellRangePattern.matcher(baseCell);
        if (!m.matches())
            throw new IllegalStateException(baseCell + " is not a valid range address");
        final String sheet1 = parseSheetName(m.group(1));
        final String sheet2 = parseSheetName(m.group(6));
        if (sheet2 != null && !sheet2.equals(sheet1))
            throw new UnsupportedOperationException("different sheet names: " + sheet1 + " != " + sheet2);

        final Sheet sheet = this.getSheet(sheet1, true);
        final Point start = Table.resolve(m.group(4));
        final Point end = Table.resolve(m.group(9));

        return sheet.getMutableTableModel(start, end);
    }

    /**
     * Return the cell at the passed address.
     * 
     * @param ref the full address, eg "$sheet.A12".
     * @return the cell at the passed address.
     */
    public final Cell<SpreadSheet> getCellAt(String ref) {
        final Matcher m = cellPattern.matcher(ref);
        if (!m.matches())
            throw new IllegalArgumentException(ref + " is not a valid cell address: " + m.pattern().pattern());
        final String sheetName = parseSheetName(m.group(1));
        if (sheetName == null)
            throw new IllegalArgumentException("no sheet specified: " + ref);
        return this.getSheet(sheetName, true).getCellAt(Sheet.resolve(m.group(5), m.group(6)));
    }

    public XPath getXPath(String p) throws JDOMException {
        return OOUtils.getXPath(p, this.getVersion());
    }

    // query directly the DOM, that way don't need to listen to it (eg for name, size or order
    // change)
    @SuppressWarnings("unchecked")
    private final List<Element> getTables() {
        return this.getBody().getChildren("table", this.getVersion().getTABLE());
    }

    public int getSheetCount() {
        return this.getTables().size();
    }

    public Sheet getSheet(int i) {
        return this.getSheet(getTables().get(i));
    }

    public Sheet getSheet(String name) {
        return this.getSheet(name, false);
    }

    /**
     * Return the first sheet with the passed name.
     * 
     * @param name the name of a sheet.
     * @param mustExist what to do when no match is found : <code>true</code> to throw an exception,
     *        <code>false</code> to return null.
     * @return the first matching sheet, <code>null</code> if <code>mustExist</code> is
     *         <code>false</code> and no match is found.
     * @throws NoSuchElementException if <code>mustExist</code> is <code>true</code> and no match is
     *         found.
     */
    public Sheet getSheet(String name, final boolean mustExist) throws NoSuchElementException {
        for (final Element table : getTables()) {
            if (name.equals(Table.getName(table)))
                return getSheet(table);
        }
        if (mustExist)
            throw new NoSuchElementException("no such sheet: " + name);
        else
            return null;
    }

    private final Sheet getSheet(Element table) {
        Sheet res = this.sheets.get(table);
        if (res == null) {
            res = new Sheet(this, table);
            this.sheets.put(table, res);
        }
        return res;
    }

    void invalidate(Element element) {
        this.sheets.remove(element);
    }

    /**
     * Adds an empty sheet.
     * 
     * @param index where to add the new sheet.
     * @param name the name of the new sheet.
     * @return the newly created sheet.
     */
    public final Sheet addSheet(final int index, String name) {
        if (name == null)
            throw new NullPointerException("null name");
        final Element newElem = Table.createEmpty(getVersion());
        return this.addSheet(index, newElem, name);
    }

    final Sheet addSheet(final int index, final Element newElem, final String name) {
        if (index < 0)
            throw new IndexOutOfBoundsException("Negative index: " + index);
        if (index > getSheetCount())
            throw new IndexOutOfBoundsException("index (" + index + ") > count (" + getSheetCount() + ")");
        // the following statement fails when adding after the last table:table :
        // this.getTables().add(index, newElem);
        // it add at the end of its parent element (e.g. after table:named-expressions).
        // so use the fact that there's always at least one sheet and all sheets are grouped :
        final Element sheet1 = this.getSheet(0).getElement();
        final int firstIndex = sheet1.getParentElement().indexOf(sheet1);
        sheet1.getParentElement().addContent(firstIndex + index, newElem);

        final Sheet res = this.getSheet(newElem);
        if (name != null)
            res.setName(name);
        assert res.getName() != null;
        return res;
    }

    public final Sheet addSheet(String name) {
        return this.addSheet(getSheetCount(), name);
    }

    void move(Sheet sheet, int toIndex) {
        final Element parentElement = sheet.getElement().getParentElement();
        sheet.getElement().detach();
        parentElement.addContent(toIndex, sheet.getElement());
        // no need to update this.sheets since it doesn't depend on order
    }

    // *** Files

    public File saveAs(File file) throws FileNotFoundException, IOException {
        this.getPackage().setFile(file);
        return this.getPackage().save();
    }

    @Override
    public final ODPackage getPackage() {
        return this.originalFile;
    }

}