/*
 * $Id: TaxAuthorityServices.java,v 1.1 2008/02/01 02:58:06 linkconsulting Exp $
 *
 *  Copyright (c) 2001-2005 The Open For Business Project - www.ofbiz.org
 *
 *  Permission is hereby granted, free of charge, to any person obtaining a
 *  copy of this software and associated documentation files (the "Software"),
 *  to deal in the Software without restriction, including without limitation
 *  the rights to use, copy, modify, merge, publish, distribute, sublicense,
 *  and/or sell copies of the Software, and to permit persons to whom the
 *  Software is furnished to do so, subject to the following conditions:
 *
 *  The above copyright notice and this permission notice shall be included
 *  in all copies or substantial portions of the Software.
 *
 *  THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS
 *  OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
 *  MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.
 *  IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY
 *  CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT
 *  OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR
 *  THE USE OR OTHER DEALINGS IN THE SOFTWARE.
 */
package org.ofbiz.accounting.tax;

import java.math.BigDecimal;
import java.sql.Timestamp;
import java.util.ArrayList;
import java.util.Iterator;
import java.util.List;
import java.util.Map;
import java.util.Set;

import javolution.util.FastList;
import javolution.util.FastSet;

import org.ofbiz.base.util.Debug;
import org.ofbiz.base.util.UtilDateTime;
import org.ofbiz.base.util.UtilMisc;
import org.ofbiz.base.util.UtilValidate;
import org.ofbiz.common.geo.GeoWorker;
import org.ofbiz.entity.GenericDelegator;
import org.ofbiz.entity.GenericEntityException;
import org.ofbiz.entity.GenericValue;
import org.ofbiz.entity.condition.EntityCondition;
import org.ofbiz.entity.condition.EntityConditionList;
import org.ofbiz.entity.condition.EntityExpr;
import org.ofbiz.entity.condition.EntityOperator;
import org.ofbiz.entity.util.EntityUtil;
import org.ofbiz.service.DispatchContext;
import org.ofbiz.service.ServiceUtil;

/**
 * Tax Authority tax calculation and other misc services
 *
 * @author     <a href="mailto:jonesde@ofbiz.org">David E. Jones</a>
 * @version    $Rev: 6213 $
 * @since      2.0
 */

public class TaxAuthorityServices {

    public static final String module = TaxAuthorityServices.class.getName();
    public static final BigDecimal ZERO_BASE = new BigDecimal("0.000"); 
    public static final BigDecimal ONE_BASE = new BigDecimal("1.000"); 
    public static final BigDecimal PERCENT_SCALE = new BigDecimal("100.000"); 

    public static Map rateProductTaxCalcForDisplay(DispatchContext dctx, Map context) {
        GenericDelegator delegator = dctx.getDelegator();
        String productStoreId = (String) context.get("productStoreId");
        String billToPartyId = (String) context.get("billToPartyId");
        String productId = (String) context.get("productId");
        BigDecimal quantity = (BigDecimal) context.get("quantity");
        BigDecimal basePrice = (BigDecimal) context.get("basePrice");
        BigDecimal shippingPrice = (BigDecimal) context.get("shippingPrice");

        if (quantity == null) quantity = ONE_BASE;
        BigDecimal amount = basePrice.multiply(quantity);
        
        BigDecimal taxTotal = ZERO_BASE;
        BigDecimal taxPercentage = ZERO_BASE;
        BigDecimal priceWithTax = basePrice;
        if (shippingPrice != null) priceWithTax = priceWithTax.add(shippingPrice); 

        try {
            GenericValue product = delegator.findByPrimaryKeyCache("Product", UtilMisc.toMap("productId", productId));
            GenericValue productStore = delegator.findByPrimaryKeyCache("ProductStore", UtilMisc.toMap("productStoreId", productStoreId));
            if (productStore == null) {
                throw new IllegalArgumentException("Could not find ProductStore with ID [" + productStoreId + "] for tax calculation");
            }
            
            if ("Y".equals(productStore.getString("showPricesWithVatTax"))) {
                Set taxAuthoritySet = FastSet.newInstance();
                if (productStore.get("vatTaxAuthPartyId") == null) {
                    List taxAuthorityRawList = delegator.findByConditionCache("TaxAuthority", new EntityExpr("taxAuthGeoId", EntityOperator.EQUALS, productStore.get("vatTaxAuthGeoId")), null, null);
                    taxAuthoritySet.addAll(taxAuthorityRawList);
                } else {
                    GenericValue taxAuthority = delegator.findByPrimaryKeyCache("TaxAuthority", UtilMisc.toMap("taxAuthGeoId", productStore.get("vatTaxAuthGeoId"), "taxAuthPartyId", productStore.get("vatTaxAuthPartyId")));
                    taxAuthoritySet.add(taxAuthority);
                }
                
                if (taxAuthoritySet.size() == 0) {
                    throw new IllegalArgumentException("Could not find any Tax Authories for store with ID [" + productStoreId + "] for tax calculation; the store settings may need to be corrected.");
                }
                
                List taxAdustmentList = getTaxAdjustments(delegator, product, productStore, billToPartyId, taxAuthoritySet, basePrice, amount, shippingPrice);
                if (taxAdustmentList.size() == 0) {
                    // this is something that happens every so often for different products and such, so don't blow up on it...
                    Debug.logWarning("Could not find any Tax Authories Rate Rules for store with ID [" + productStoreId + "], productId [" + productId + "], basePrice [" + basePrice + "], amount [" + amount + "], for tax calculation; the store settings may need to be corrected.", module);
                }

                // add up amounts from adjustments (amount OR exemptAmount, sourcePercentage)
                Iterator taxAdustmentIter = taxAdustmentList.iterator();
                while (taxAdustmentIter.hasNext()) {
                    GenericValue taxAdjustment = (GenericValue) taxAdustmentIter.next();
                    taxPercentage = taxPercentage.add(taxAdjustment.getBigDecimal("sourcePercentage"));
                    BigDecimal adjAmount = taxAdjustment.getBigDecimal("amount");
                    taxTotal = taxTotal.add(adjAmount);
                    priceWithTax = priceWithTax.add(adjAmount);
                    Debug.logInfo("For productId [" + productId + "] added [" + adjAmount + "] of tax to price for geoId [" + taxAdjustment.getString("taxAuthGeoId") + "], new price is [" + priceWithTax + "]", module);
                }
            }
        } catch (GenericEntityException e) {
            String errMsg = "Data error getting tax settings: " + e.toString();
            Debug.logError(e, errMsg, module);
            return ServiceUtil.returnError(errMsg);
        }
        
        // round to 2 decimal places for display/etc
        taxTotal.setScale(2, BigDecimal.ROUND_CEILING);
        priceWithTax.setScale(2, BigDecimal.ROUND_CEILING);
        
        Map result = ServiceUtil.returnSuccess();
        result.put("taxTotal", taxTotal);
        result.put("taxPercentage", taxPercentage);
        result.put("priceWithTax", priceWithTax);
        return result;
    }
    
    public static Map rateProductTaxCalc(DispatchContext dctx, Map context) {
        GenericDelegator delegator = dctx.getDelegator();
        String productStoreId = (String) context.get("productStoreId");
        String billToPartyId = (String) context.get("billToPartyId");
        List itemProductList = (List) context.get("itemProductList");
        List itemAmountList = (List) context.get("itemAmountList");
        List itemPriceList = (List) context.get("itemPriceList");
        List itemShippingList = (List) context.get("itemShippingList");
        BigDecimal orderShippingAmount = (BigDecimal) context.get("orderShippingAmount");
        GenericValue shippingAddress = (GenericValue) context.get("shippingAddress");

        // without knowing the TaxAuthority parties, just find all TaxAuthories for the set of IDs...
        Set taxAuthoritySet = FastSet.newInstance();
        GenericValue productStore = null;
        try {
            Set geoIdSet = FastSet.newInstance();
            if (shippingAddress != null) {
                if (shippingAddress.getString("countryGeoId") != null) {
                    geoIdSet.add(shippingAddress.getString("countryGeoId"));
                }
                if (shippingAddress.getString("stateProvinceGeoId") != null) {
                    geoIdSet.add(shippingAddress.getString("stateProvinceGeoId"));
                }
            }
            
            if (geoIdSet.size() == 0) {
                return ServiceUtil.returnError("The address(es) used for tax calculation did not have State/Province or Country values set, so we cannot determine the taxes to charge.");
            }
            
            // get the most granular, or all available, geoIds and then find parents by GeoAssoc with geoAssocTypeId="REGIONS" and geoIdTo=<granular geoId> and find the GeoAssoc.geoId
            geoIdSet = GeoWorker.expandGeoRegionDeep(geoIdSet, delegator);

            List taxAuthorityRawList = delegator.findByConditionCache("TaxAuthority", new EntityExpr("taxAuthGeoId", EntityOperator.IN, geoIdSet), null, null);
            taxAuthoritySet.addAll(taxAuthorityRawList);

            productStore = delegator.findByPrimaryKey("ProductStore", UtilMisc.toMap("productStoreId", productStoreId));
        } catch (GenericEntityException e) {
            String errMsg = "Data error getting tax settings: " + e.toString();
            Debug.logError(e, errMsg, module);
            return ServiceUtil.returnError(errMsg);
        }
        
        if (productStore == null) {
            throw new IllegalArgumentException("Could not find ProductStore with ID [" + productStoreId + "] for tax calculation");
        }
        
        // Setup the return lists.
        List orderAdjustments = FastList.newInstance();
        List itemAdjustments = FastList.newInstance();

        // Loop through the products; get the taxCategory; and lookup each in the cache.
        for (int i = 0; i < itemProductList.size(); i++) {
            GenericValue product = (GenericValue) itemProductList.get(i);
            BigDecimal itemAmount = (BigDecimal) itemAmountList.get(i);
            BigDecimal itemPrice = (BigDecimal) itemPriceList.get(i);
            BigDecimal shippingAmount = (BigDecimal) itemShippingList.get(i);
            List taxList = null;
            if (shippingAddress != null) {
                taxList = getTaxAdjustments(delegator, product, productStore, billToPartyId, taxAuthoritySet, itemPrice, itemAmount, shippingAmount);
            }
            // this is an add and not an addAll because we want a List of Lists of GenericValues, one List of Adjustments per item
            itemAdjustments.add(taxList);
        }
        if (orderShippingAmount.doubleValue() > 0) {
            List taxList = getTaxAdjustments(delegator, null, productStore, billToPartyId, taxAuthoritySet, ZERO_BASE, ZERO_BASE, orderShippingAmount);
            orderAdjustments.addAll(taxList);
        }

        Map result = ServiceUtil.returnSuccess();
        result.put("orderAdjustments", orderAdjustments);
        result.put("itemAdjustments", itemAdjustments);

        return result;
    }

    private static List getTaxAdjustments(GenericDelegator delegator, GenericValue product, GenericValue productStore, String billToPartyId, Set taxAuthoritySet, BigDecimal itemPrice, BigDecimal itemAmount, BigDecimal shippingAmount) {
        Timestamp nowTimestamp = UtilDateTime.nowTimestamp();
        List adjustments = FastList.newInstance();

        String payToPartyId = productStore.getString("payToPartyId");

        // store expr
        EntityCondition storeCond = new EntityExpr("productStoreId", EntityOperator.EQUALS, productStore.get("productStoreId"));

        // build the TaxAuthority expressions (taxAuthGeoId, taxAuthPartyId)
        List taxAuthCondOrList = FastList.newInstance();
        // start with the _NA_ TaxAuthority...
        taxAuthCondOrList.add(new EntityExpr(
                new EntityExpr("taxAuthPartyId", EntityOperator.EQUALS, "_NA_"), 
                EntityOperator.AND, 
                new EntityExpr("taxAuthGeoId", EntityOperator.EQUALS, "_NA_")));
        
        Iterator taxAuthorityIter = taxAuthoritySet.iterator();
        while (taxAuthorityIter.hasNext()) {
            GenericValue taxAuthority = (GenericValue) taxAuthorityIter.next();
            EntityCondition taxAuthCond = new EntityExpr(
                    new EntityExpr("taxAuthPartyId", EntityOperator.EQUALS, taxAuthority.getString("taxAuthPartyId")), 
                    EntityOperator.AND, 
                    new EntityExpr("taxAuthGeoId", EntityOperator.EQUALS, taxAuthority.getString("taxAuthGeoId")));
            taxAuthCondOrList.add(taxAuthCond);
        }
        EntityCondition taxAuthoritiesCond = new EntityConditionList(taxAuthCondOrList, EntityOperator.OR);

        try {
            EntityCondition productCategoryCond = null;
            if (product != null) {
                // find the tax categories associated with the product and filter by those, with an IN clause or some such
                // question: get all categories, or just a special type? for now let's do all categories...
                Set productCategoryIdSet = FastSet.newInstance();
                List pcmList = delegator.findByAndCache("ProductCategoryMember", UtilMisc.toMap("productId", product.get("productId")));
                pcmList = EntityUtil.filterByDate(pcmList, true);
                Iterator pcmIter = pcmList.iterator();
                while (pcmIter.hasNext()) {
                    GenericValue pcm = (GenericValue) pcmIter.next();
                    productCategoryIdSet.add(pcm.get("productCategoryId"));
                }
                
                if (productCategoryIdSet.size() == 0) {
                    productCategoryCond = new EntityExpr("productCategoryId", EntityOperator.EQUALS, null);
                } else {
                    productCategoryCond = new EntityExpr(
                            new EntityExpr("productCategoryId", EntityOperator.EQUALS, null),
                            EntityOperator.OR,
                            new EntityExpr("productCategoryId", EntityOperator.IN, productCategoryIdSet));
                }
            } else {
                productCategoryCond = new EntityExpr("productCategoryId", EntityOperator.EQUALS, null);
            }

            // build the main condition clause
            List mainExprs = UtilMisc.toList(storeCond, taxAuthoritiesCond, productCategoryCond);
            mainExprs.add(new EntityExpr(new EntityExpr("minItemPrice", EntityOperator.EQUALS, null), EntityOperator.OR, new EntityExpr("minItemPrice", EntityOperator.LESS_THAN_EQUAL_TO, itemPrice)));
            mainExprs.add(new EntityExpr(new EntityExpr("minPurchase", EntityOperator.EQUALS, null), EntityOperator.OR, new EntityExpr("minPurchase", EntityOperator.LESS_THAN_EQUAL_TO, itemAmount)));
            EntityCondition mainCondition = new EntityConditionList(mainExprs, EntityOperator.AND);

            // create the orderby clause
            List orderList = UtilMisc.toList("minItemPrice", "minPurchase", "fromDate");

            // finally ready... do the rate query
            List lookupList = delegator.findByCondition("TaxAuthorityRateProduct", mainCondition, null, orderList);
            List filteredList = EntityUtil.filterByDate(lookupList, true);

            if (filteredList.size() == 0) {
                Debug.logWarning("In TaxAuthority Product Rate no records were found for condition:" + mainCondition.toString(), module);
                return adjustments;
            }

            // find the right entry(s) based on purchase amount
            Iterator flIt = filteredList.iterator();
            while (flIt.hasNext()) {
                GenericValue taxAuthorityRateProduct = (GenericValue) flIt.next();

                BigDecimal taxRate = taxAuthorityRateProduct.get("taxPercentage") != null ? taxAuthorityRateProduct.getBigDecimal("taxPercentage") : ZERO_BASE;
                BigDecimal taxable = ZERO_BASE;

                if (product != null && (product.get("taxable") == null || (product.get("taxable") != null && product.getBoolean("taxable").booleanValue()))) {
                    taxable = taxable.add(itemAmount);
                }
                if (taxAuthorityRateProduct != null && (taxAuthorityRateProduct.get("taxShipping") == null || (taxAuthorityRateProduct.get("taxShipping") != null && taxAuthorityRateProduct.getBoolean("taxShipping").booleanValue()))) {
                    taxable = taxable.add(shippingAmount);
                }
                
                if (taxable.doubleValue() == 0) {
                    // this should make it less confusing if the taxable flag on the product is not Y/true, and there is no shipping and such
                    continue;
                }

                // taxRate is in percentage, so needs to be divided by 100
                BigDecimal taxAmount = (taxable.multiply(taxRate)).divide(PERCENT_SCALE, 3, BigDecimal.ROUND_CEILING);

                String taxAuthGeoId = taxAuthorityRateProduct.getString("taxAuthGeoId");
                String taxAuthPartyId = taxAuthorityRateProduct.getString("taxAuthPartyId");

                // get glAccountId from TaxAuthorityGlAccount entity using the ProductStore.payToPartyId as the organizationPartyId
                GenericValue taxAuthorityGlAccount = delegator.findByPrimaryKey("TaxAuthorityGlAccount", UtilMisc.toMap("taxAuthPartyId", taxAuthPartyId, "taxAuthGeoId", taxAuthGeoId, "organizationPartyId", payToPartyId));
                String taxAuthGlAccountId = null;
                if (taxAuthorityGlAccount != null) {
                    taxAuthGlAccountId = taxAuthorityGlAccount.getString("glAccountId");
                } else {
                    // TODO: what to do if no TaxAuthorityGlAccount found? Use some default, or is that done elsewhere later on?
                }

                GenericValue adjValue = delegator.makeValue("OrderAdjustment", null);
                adjValue.set("amount", taxAmount);
                adjValue.set("sourcePercentage", taxRate);
                adjValue.set("orderAdjustmentTypeId", "SALES_TAX");
                // the primary Geo should be the main jurisdiction that the tax is for, and the secondary would just be to define a parent or wrapping jurisdiction of the primary
                adjValue.set("primaryGeoId", taxAuthGeoId);
                adjValue.set("comments", taxAuthorityRateProduct.getString("description"));
                if (taxAuthPartyId != null) adjValue.set("taxAuthPartyId", taxAuthPartyId);
                if (taxAuthGlAccountId != null) adjValue.set("overrideGlAccountId", taxAuthGlAccountId);
                if (taxAuthGeoId != null) adjValue.set("taxAuthGeoId", taxAuthGeoId);

                // check to see if this party has a tax ID for this, and if the party is tax exempt in the primary (most-local) jurisdiction
                if (UtilValidate.isNotEmpty(billToPartyId) && taxAuthGeoId != null) {
                    // see if partyId is a member of any groups , if so honor their tax exemptions
                    // look for PartyRelationship with partyRelationshipTypeId=GROUP_ROLLUP, the partyIdTo is the group member, so the partyIdFrom is the groupPartyId
                    Set billToPartyIdSet = FastSet.newInstance();
                    billToPartyIdSet.add(billToPartyId);
                    List partyRelationshipList = EntityUtil.filterByDate(delegator.findByAndCache("PartyRelationship", UtilMisc.toMap("partyIdTo", billToPartyId, "partyRelationshipTypeId", "GROUP_ROLLUP")), true);
                    Iterator partyRelationshipIter = partyRelationshipList.iterator();
                    while (partyRelationshipIter.hasNext()) {
                        GenericValue partyRelationship = (GenericValue) partyRelationshipIter.next();
                        billToPartyIdSet.add(partyRelationship.get("partyIdFrom"));
                    }

                    List ptiConditionList = UtilMisc.toList(
                            new EntityExpr("partyId", EntityOperator.IN, billToPartyIdSet),
                            new EntityExpr("taxAuthGeoId", EntityOperator.EQUALS, taxAuthGeoId),
                            new EntityExpr("taxAuthPartyId", EntityOperator.EQUALS, taxAuthPartyId));
                    ptiConditionList.add(new EntityExpr("fromDate", EntityOperator.LESS_THAN_EQUAL_TO, nowTimestamp));
                    ptiConditionList.add(new EntityExpr(new EntityExpr("thruDate", EntityOperator.EQUALS, null), EntityOperator.OR, new EntityExpr("thruDate", EntityOperator.GREATER_THAN, nowTimestamp)));
                    EntityCondition ptiCondition = new EntityConditionList(ptiConditionList, EntityOperator.AND);
                    // sort by -fromDate to get the newest (largest) first, just in case there is more than one, we only want the most recent valid one, should only be one per jurisdiction...
                    List partyTaxInfos = delegator.findByCondition("PartyTaxAuthInfo", ptiCondition, null, UtilMisc.toList("-fromDate"));
                    if (partyTaxInfos.size() > 0) {
                        GenericValue partyTaxInfo = (GenericValue) partyTaxInfos.get(0);
                        adjValue.set("customerReferenceId", partyTaxInfo.get("partyTaxId"));
                        if ("Y".equals(partyTaxInfo.getString("isExempt"))) {
                            adjValue.set("amount", new Double(0));
                            adjValue.set("exemptAmount", taxAmount);
                        }
                    }
                } else {
                    Debug.logInfo("NOTE: A tax calculation was done without a billToPartyId or taxAuthGeoId, so no tax exemptions or tax IDs considered; billToPartyId=[" + billToPartyId + "] taxAuthGeoId=[" + taxAuthGeoId + "]", module);
                }

                adjustments.add(adjValue);
            }
        } catch (GenericEntityException e) {
            Debug.logError(e, "Problems looking up tax rates", module);
            return new ArrayList();
        }

        return adjustments;
    }
}
