/*****************************************************************
 *   Licensed to the Apache Software Foundation (ASF) under one
 *  or more contributor license agreements.  See the NOTICE file
 *  distributed with this work for additional information
 *  regarding copyright ownership.  The ASF licenses this file
 *  to you under the Apache License, Version 2.0 (the
 *  "License"); you may not use this file except in compliance
 *  with the License.  You may obtain a copy of the License at
 *
 *    http://www.apache.org/licenses/LICENSE-2.0
 *
 *  Unless required by applicable law or agreed to in writing,
 *  software distributed under the License is distributed on an
 *  "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
 *  KIND, either express or implied.  See the License for the
 *  specific language governing permissions and limitations
 *  under the License.
 ****************************************************************/
package org.apache.cayenne.lifecycle.id;

import java.io.UnsupportedEncodingException;
import java.net.URLDecoder;
import java.net.URLEncoder;
import java.util.HashMap;
import java.util.Map;
import java.util.Map.Entry;
import java.util.SortedMap;
import java.util.StringTokenizer;
import java.util.TreeMap;

import org.apache.cayenne.CayenneRuntimeException;
import org.apache.cayenne.ObjectId;
import org.apache.cayenne.dba.TypesMapping;
import org.apache.cayenne.map.DbAttribute;
import org.apache.cayenne.map.ObjAttribute;
import org.apache.cayenne.map.ObjEntity;
import org.apache.cayenne.util.IDUtil;
import org.apache.cayenne.util.Util;

/**
 * An object to encode/decode ObjectIds for a single mapped entity.
 * 
 * @since 3.1
 */
public class EntityIdCoder {

    static final String ID_SEPARATOR = ":";
    static final String TEMP_ID_PREFIX = ".";
    private static final int TEMP_PREFIX_LENGTH = TEMP_ID_PREFIX.length();

    private String entityName;
    private SortedMap<String, Converter> converters;
    private int idSize;

    public static String getEntityName(String id) {
        int separator = id.indexOf(ID_SEPARATOR);
        if (separator <= 0 || separator == id.length() - 1) {
            throw new IllegalArgumentException("Invalid String id: " + id);
        }

        String name = id.substring(0, separator);

        if (name.startsWith(TEMP_ID_PREFIX)) {
            name = name.substring(TEMP_PREFIX_LENGTH);
        }

        return name;
    }

    public EntityIdCoder(ObjEntity entity) {

        this.entityName = entity.getName();
        this.converters = new TreeMap<String, Converter>();

        for (ObjAttribute attribute : entity.getAttributes()) {
            if (attribute.isPrimaryKey()) {
                converters.put(attribute.getDbAttributeName(),
                        create(attribute.getJavaClass()));
            }
        }

        for (DbAttribute attribute : entity.getDbEntity().getPrimaryKeys()) {
            if (!converters.containsKey(attribute.getName())) {
                String type = TypesMapping
                        .getJavaBySqlType(attribute.getType());
                try {
                    converters.put(attribute.getName(),
                            create(Util.getJavaClass(type)));
                } catch (ClassNotFoundException e) {
                    throw new CayenneRuntimeException(
                            "Can't instantiate class " + type, e);
                }
            }
        }

        if (converters.isEmpty()) {
            throw new IllegalArgumentException("Entity has no PK definied: "
                    + entity.getName());
        }

        this.idSize = (int) Math.ceil(converters.size() / 0.75d);
    }

    /**
     * Returns a consistent String representation of the ObjectId
     */
    public String toStringId(ObjectId id) {

        // deal with temp that have attached replacement ID as permanent IDs...
        // AuditableFilter, etc. all rely on the ability to find the temp object
        // after the transaction end

        // TODO: support encoding format for temp+replacement
        if (id.isTemporary() && !id.isReplacementIdAttached()) {
            return toTempIdString(id);
        } else {
            return toPermIdString(id);
        }
    }

    private String toTempIdString(ObjectId id) {
        StringBuilder buffer = new StringBuilder();

        buffer.append(TEMP_ID_PREFIX);

        buffer.append(id.getEntityName());

        buffer.append(ID_SEPARATOR);

        for (byte b : id.getKey()) {
            IDUtil.appendFormattedByte(buffer, b);
        }
        return buffer.toString();
    }

    private String toPermIdString(ObjectId id) {
        StringBuilder buffer = new StringBuilder();
        buffer.append(id.getEntityName());
        Map<String, Object> idValues = id.getIdSnapshot();

        for (Entry<String, Converter> entry : converters.entrySet()) {
            Object value = idValues.get(entry.getKey());
            buffer.append(ID_SEPARATOR).append(entry.getValue().toUuid(value));
        }

        return buffer.toString();
    }

    public ObjectId toObjectId(String stringId) {

        if (stringId.startsWith(TEMP_ID_PREFIX)) {
            String idValues = stringId.substring(entityName.length() + 1
                    + TEMP_PREFIX_LENGTH);
            return new ObjectId(entityName, decodeTemp(idValues));
        }

        String idValues = stringId.substring(entityName.length() + 1);

        if (converters.size() == 1) {
            Entry<String, Converter> entry = converters.entrySet().iterator()
                    .next();

            String decoded;
            try {
                decoded = URLDecoder.decode(idValues, "UTF-8");
            } catch (UnsupportedEncodingException e) {
                // unexpected
                throw new CayenneRuntimeException("Unsupported encoding", e);
            }
            return new ObjectId(entityName, entry.getKey(), entry.getValue()
                    .fromStringId(decoded));
        }

        Map<String, Object> idMap = new HashMap<String, Object>(idSize);
        StringTokenizer toks = new StringTokenizer(idValues, ID_SEPARATOR);

        if (toks.countTokens() != converters.size()) {
            throw new IllegalArgumentException("Invalid String ID for entity "
                    + entityName + ": " + idValues);
        }

        for (Entry<String, Converter> entry : converters.entrySet()) {
            String value = toks.nextToken();

            String decoded;
            try {
                decoded = URLDecoder.decode(value, "UTF-8");
            } catch (UnsupportedEncodingException e) {
                // unexpected
                throw new CayenneRuntimeException("Unsupported encoding", e);
            }

            idMap.put(entry.getKey(), entry.getValue().fromStringId(decoded));
        }

        return new ObjectId(entityName, idMap);
    }

    private byte[] decodeTemp(String byteString) {

        byte[] bytes = new byte[byteString.length() / 2];
        for (int i = 0; i < bytes.length; i++) {
            int index = i * 2;

            // this is better than Byte.parseByte which can't parse values >=
            // 128 as negative bytes
            int c1 = byteString.charAt(index);
            int c2 = byteString.charAt(index + 1);
            bytes[i] = (byte) ((Character.digit(c1, 16) << 4) + Character
                    .digit(c2, 16));
        }

        return bytes;
    }

    private Converter create(Class<?> type) {

        if (type == null) {
            throw new NullPointerException("Null type");
        }

        if (Long.class.isAssignableFrom(type)) {
            return new Converter() {

                @Override
                Object fromStringId(String stringId) {
                    return Long.valueOf(stringId);
                }
            };
        } else if (Integer.class.isAssignableFrom(type)) {
            return new Converter() {

                @Override
                Object fromStringId(String stringId) {
                    return Integer.valueOf(stringId);
                }
            };
        } else if (String.class.isAssignableFrom(type)) {
            return new Converter() {

                @Override
                Object fromStringId(String stringId) {
                    return stringId;
                }
            };
        }

        throw new IllegalArgumentException("Unsupported ID type: "
                + type.getName());
    }

    abstract class Converter {

        String toUuid(Object value) {
            try {
                return URLEncoder.encode(String.valueOf(value), "UTF-8");
            } catch (UnsupportedEncodingException e) {
                // unexpected
                throw new CayenneRuntimeException("Unsupported encoding", e);
            }
        }

        abstract Object fromStringId(String stringId);
    }
}
