<?php
/**
 * Class represents records from table products
 * {autogenerated}
 * @property int $product_id 
 * @property string $title 
 * @property string $description 
 * @property string $trial_group 
 * @property string $start_date 
 * @property string $currency 
 * @property int $tax_group 
 * @property string $sort_order 
 * @property string $renewal_group 
 * @property date $start_date_fixed 
 * @property string $require_other 
 * @property string $prevent_if_other
 * @property string $require_other_group
 * @property string $prevent_if_other_group
 * @property string $paysys_id 
 * @property string $comment 
 * @property int $default_billing_plan_id 
 * @property int $is_tangible  
 * @property bool $is_disabled 
 * @see Am_Table
 * @package Am_Invoice
 */

class Product extends Am_Record_WithData implements IProduct 
{
    /** calculate start date by payment date */
    const SD_PAYMENT = 'payment';
    /** calculate start date by product payments */
    const SD_PRODUCT = 'product';
    /** calculate start date by renewal groups payments */
    const SD_GROUP = 'group';
    /** set fixed start date */
    const SD_FIXED = 'fixed';

    
    /** @var BillingPlan */
    protected $_plan;
    /** @var array */
    protected $_options = array();
    
    public function init()
    {
        parent::init();
        if (empty($this->currency))
            $this->currency = Am_Currency::getDefault ();
    }

    /**
     * @return bool
     */
    function isRecurring(){
        return (bool)$this->getBillingPlan()->rebill_times;
    }
    /**
     * Run htmlspecialchars(strip_tags()) for the string
     * It is useful for strings that may contain html entities but we would
     * not want to see it here, for example: product title or description
     * @param string string to escape
     * @return string escaped string
     */
    
    static function stripEscape($string){
        return htmlspecialchars(strip_tags($string), null, 'UTF-8', false);
    }
    
    /**
     * Return title of product
     */
    function getTitle($escaped=true){
        $title = ___($this->title);
        return $escaped ? $this->stripEscape($title) : str_ireplace('<script>', '', $title);
    }
    /**
     * Return description of product
     */
    function getDescription($escaped=true){
        $title = ___($this->description);
        return $escaped ? $this->stripEscape($title) : str_ireplace('<script>', '', $title);
    }

    /**
     * @return Am_Currency
     */
    function getCurrency($value = null)
    {
        $c = new Am_Currency($this->currency);
        if ($value) $c->setValue($value);
        return $c;
    }

    function getProductId() { return $this->product_id; }
    /**
     * Return short type of the item, ex. for Product returns "product"
     */
    function getType() { return 'product'; }
    //function getTitle()
    //function getDescription();
    function getFirstPrice() {
        return $this->getBillingPlan()->first_price;
    }
    function getFirstPeriod() {
        return $this->getBillingPlan()->first_period;
    }
    /**
     * Rebilling mode
     * @return int 0:"No Rebilling", 1:"Charge Second Price Once", a number:"Rebill x Times: ", 99999:"Unlimited Recurring Billing"
     */
    function getRebillTimes(){
        return $this->getBillingPlan()->rebill_times;
    }
    function getSecondPrice(){
        return $this->getBillingPlan()->second_price;
    }
    function getSecondPeriod(){
        return $this->getBillingPlan()->second_period;
    }
    function getCurrencyCode(){
        return $this->currency;
    }
    function getTaxGroup()
    {
        return $this->tax_group;
    }
    /**
     * Can the item be shipped? Should we calculate shipping
     * charges for it?
     */
    function getIsTangible(){
        return false;
    }
    /**
     * Can qty of the item in the Invoice be not equal to 1?
     * For subscriptions this must be almost always "false"
     * For deliverable goods like cups this must be "true"
     */
    function getIsCountable(){
        $bp = $this->getBillingPlan();
        return $bp->get('qty') != 1 || $bp->get('variable_qty');
    }
    
    public function getQty()
    {
        return $this->getBillingPlan()->get('qty', 1);
    }
    
    public function getIsVariableQty()
    {
        return $this->getBillingPlan()->get('variable_qty', 0);
    }
    
    /** param array $categories - array of id# */
    function setCategories(array $categories)
    {
        $this->getAdapter()->query("DELETE FROM ?_product_product_category WHERE product_id=?d {AND product_category_id NOT IN (?a)}",
                $this->product_id, $categories ? $categories : DBSIMPLE_SKIP);
        if (!$categories) return;
        $vals = array();
        foreach ($categories as $id)
            $vals[] = sprintf("(%d,%d)", $this->product_id, $id);
        $this->getAdapter()->query("INSERT IGNORE INTO ?_product_product_category
            (product_id, product_category_id)
            VALUES " . implode(", ", $vals));
    }
    /** @return array of id# */
    function getCategories()
    {
        if (empty($this->product_id)) return array();
        return $this->getAdapter()->selectCol("SELECT product_category_id FROM ?_product_product_category WHERE product_id=?d", $this->product_id);
    }
    /** @return array of BillingPlan */
    function getBillingPlans($onlyEnabled = false)
    {
        if (empty($this->product_id)) return array();
        $ret = $this->getDi()->billingPlanTable->getForProduct($this->product_id, null, $onlyEnabled);
        foreach ($ret as $r) $r->_setProduct($this);
        return $ret;
    }
    /** @return BillingPlan */
    function getBillingPlan($throwException = true)
    {
        if (!$this->_plan)
            if ($this->default_billing_plan_id)
                $this->_plan = $this->getDi()->billingPlanTable->load($this->default_billing_plan_id);
            else
                $this->_plan = current($this->getDi()->billingPlanTable->getForProduct($this->product_id, 1));
        if (!$this->_plan && $throwException)
            throw new Am_Exception_Configuration("No billing plan defined for product# " . $this->pk());
        return $this->_plan;
    }
    function getBillingOptions()
    {
        $ret = array();
        foreach ($this->getBillingPlans(true) as $plan)
            $ret[ $plan->plan_id ] = $plan->title . ' - ' . $plan->getTerms();
        return $ret;
    }
    /** @return BillingPlan and set it as "current" for this product object */
    function createBillingPlan()
    {
        if (!$this->pk()) 
            throw new Am_Exception_InternalError("Could not run [createBillingPlan] on not-saved product");
        $p = $this->getDi()->billingPlanRecord;
        $p->product_id = $this->pk();
        $this->setBillingPlan($p);
        return $p;
    }
    /**
     * @param BillingPlan|int $plan
     * @return Product provides fluent interface
     */
    function setBillingPlan($plan)
    {
        if ($plan instanceof BillingPlan)
            $this->_plan = $plan;
        else
        {
            $p = $this->getDi()->billingPlanTable->load(intval($plan), true);
            if ($p->product_id != $this->product_id)
                throw new Am_Exception_InternalError("Billing plan from another product cannot be used");
            $this->_plan = $p;
        }
        return $this;
    }
    public function getBillingPlanId()
    {
        return $this->getBillingPlan()->plan_id;
    }
    public function getBillingPlanData()
    {
        return $this->getBillingPlan()->data()->getAll();
    }
    public function getOptions()
    {
        return $this->_options;
    }
    public function setOptions(array $options)
    {
        $this->_options = (array)$options;
    }
    public function delete()
    {
        $ret = parent::delete();
        $this
            ->deleteFromRelatedTable('?_billing_plan')
            ->deleteFromRelatedTable('?_access')
            ->deleteFromRelatedTable('?_user_status')
            ->deleteFromRelatedTable('?_product_product_category');
        $this->getTable()->getAdapter()->query("DELETE FROM ?_resource_access WHERE fn='product_id' AND id=?d", $this->pk());
        return $ret;
    }
    
    static function serializeList($ids)
    {
        if (!is_array($ids))
            return preg_replace('[^a-z-A-Z0-9_,-:]', '', $ids);
        return implode(',', $ids);
    }
    static function unserializeList($ids)
    {
        if ($ids=='') return array();
        return explode(',', $ids);
    }
    /** @return array start date calculation rules */
    public function getStartDate()
    {
        $setting = $this->unserializeList(empty($this->start_date) ? '' : $this->start_date);
        if (!$setting)
            $setting = array(
                self::SD_PRODUCT,
                self::SD_PAYMENT,
            );
        return $setting;
    }
    public function setStartDate(array $setting)
    {
        return $this->start_date = $this->serializeList($setting);
    }
    public function calculateStartDate($paymentDate, Invoice $invoice)
    {
        if ($paymentDate instanceof DateTime)
            $paymentDate = $paymentDate->format('Y-m-d');
        
        $ret = array();
        $setting = $this->getStartDate();
        $callArgs = array();
        if (in_array(self::SD_PRODUCT, $setting))
            $callArgs['product_id'] = $this->product_id;
        if (in_array(self::SD_GROUP, $setting))
            $callArgs['renewal_group'] = $this->renewal_group;
        if (in_array(self::SD_PAYMENT, $setting))
            $ret[] = $paymentDate;
        if (in_array(self::SD_FIXED, $setting) && $this->start_date_fixed)
            $ret[] = $this->start_date_fixed;
        if (count($callArgs))
        {
            $callArgs['user_id'] = $invoice->getUserId();
            $ret[] = $this->getDi()->accessTable->getLastExpire($callArgs);
        }
        $ret = array_filter($ret);
        if (!$ret) $ret[] = $this->getDi()->sqlDate;
        return max($ret);
    }
    
    function defaultRender(BillingPlan $plan = null, $short = false)
    {
        return sprintf('&nbsp;<b>%s</b> %s<br/><span class="small">%s</span><br />',
            $this->getTitle(false),
            $plan ? ___($plan->getTerms()) : "",
            $this->getDescription(false)
            );
    }

    public function addQty($requestedQty, $itemQty)
    {
        if (!$this->getIsCountable()) return 1;
        if ($this->getBillingPlan()->variable_qty)
            return $itemQty + $requestedQty;
        else
            return $this->getBillingPlan()->qty;
    }

    public function findItem(array $existingInvoiceItems)
    {
        foreach ($existingInvoiceItems as $item)
            if ($item->item_id == $this->getProductId())
                return $item;
    }

     /**
     *
     * @param string $req
     * @return array
     */
    protected function parceRequirementsGroup($req)
    {
        $catProducts = $this->getDi()->productCategoryTable->getCategoryProducts();
        $res = array_filter(explode(',',$req));
        foreach ($res as $k => $g) {
            if (preg_match('/CATEGORY-(\w*)-(\d*)/', $g, $match)) {
                unset($res[$k]);
                $status = $match[1];
                $catId = $match[2];
                foreach ($catProducts[$catId] as $prId) {
                    $res[] = sprintf('%s-%d', $status, $prId);
                }
            }
        }
        return $res;
    }

    function getRequireOther()
    {
        return array_unique(array_filter($this->parceRequirementsGroup($this->require_other)));
    }

    function getPreventIfOther()
    {
        return array_unique(array_filter($this->parceRequirementsGroup($this->prevent_if_other)));
    }
}

/**
 * Products table
 * @package Am_Invoice 
 */
class ProductTable extends Am_Table_WithData 
{
    protected $_key = 'product_id';
    protected $_table = '?_product';
    protected $_recordClass = 'Product';

    protected $useCache = true;
    
    /*
     * Return array of products in the form
     * product_id => title
     * Suitable for usage in <SELECT>
     * @return array
     */
    function getOptions($onlyEnabled = false)
    {
        return array_map(array("Am_Controller", "escape"), $this->_db->selectCol("SELECT product_id as ARRAY_KEY, CONCAT('(', product_id, ') ', title)
            FROM ?_product 
            {WHERE is_disabled = ?}
            ORDER BY 0+sort_order, title", 
            $onlyEnabled ? 0 : DBSIMPLE_SKIP));
    }


    /**
     * Return products that are visible in passed context
     * @param array $productCategories or null to do not limit
     * @param array|null $activeProductIds active products of the current user or null to skip checks
     * @param array|null $expiredProductIds active products of the current user or null to skip checks
     * @return array of Products
     */
    function getVisible($productCategories = null,
        $activeProductIds = null, $expiredProductIds = null)
    {
        if ($activeProductIds === null) {
            // skip check for product requirements
        }
        if ($productCategories)
            $productCategories = array_filter(array_map('intval', $productCategories));
            
        return $this->selectObjects("SELECT p.* 
                                    FROM  ?_product p 
                                    {RIGHT JOIN ?_product_product_category ppc 
                                    ON p.product_id=ppc.product_id AND ppc.product_category_id in (?a)}
                                    WHERE   p.product_id > 0 AND p.is_disabled = 0 
                                    GROUP BY p.product_id
                                    ORDER BY 0+p.sort_order, p.title
            ",  $productCategories ? $productCategories : DBSIMPLE_SKIP);
    }

    /**
     * @param array of #ids
     * @return array of strings
     */
    function getProductTitles(array $ids) {
        return array_map(array('Product', 'stripEscape'),
                $this->_db->selectCol("SELECT product_id as ARRAY_KEY,title FROM ?_product WHERE product_id IN (?a)",
                array_filter(array_map('intval', $ids))));
    }

    /**
     * Filter products that should not be available on signup/renew pages depends on 
     * require_other and prevent_if_other product settings
     * @param array $products Product objects that are purchasing now
     * @param array $haveActiveIds int product# user has active subscriptions to
     * @param array $haveExpiredIds int product# user has expired subscriptions to
     * @param bool $select_multiple - is user able to select multiple products on signup form.
     * @return array Product objects.
     */
    function filterProducts(array $products, array $haveActiveIds = array(), array $haveExpiredIds=array(), $select_multiple=false){
        $have = array_unique(array_merge(
                array_map(create_function('$id', 'return "ACTIVE-$id";'), $haveActiveIds),
                array_map(create_function('$id', 'return "EXPIRED-$id";'), $haveExpiredIds)
        ));
        do{
            $changes =0; $all_available = array();
            foreach($products as $pr){
                $all_available[] = 'ACTIVE-'.$pr->product_id;
            }
            foreach($products as $k=>$v){
                $po = $v->getPreventIfOther();
                $ro = $v->getRequireOther();
                if($po && array_intersect($po, $have))
                { 
                    unset($products[$k]); 
                    $changes++;
                }
                
                if($ro){
                    if(is_array($have) && $have){
                        if(!array_intersect($ro, ($select_multiple ? array_merge($have, $all_available) : $have))){
                            unset($products[$k]);
                            $changes++;
                        }
                    }else{
                        if(!array_intersect($ro, ($select_multiple  ? $all_available : array()))){
                            unset($products[$k]);
                            $changes++;
                        }
                    }
                }
            }
        }while($changes);
        return $products;
    }
    
    /**
     * Check if require_other prevent_if_other product settings are statisfied
     * for current purchase
     * @param array $products Product objects that are purchasing now
     * @param array $haveActiveIds int product# user has active subscriptions to
     * @param array $haveExpiredIds int product# user has expired subscriptions to
     * @return array empty array of OK, or an array full of error messages
     */
    function checkRequirements(array $products, array $haveActiveIds = array(), array $haveExpiredIds = array()){
        $error = array();
        $have = array_unique(array_merge(
                array_map(create_function('$id', 'return "ACTIVE-$id";'), $haveActiveIds),
                array_map(create_function('$id', 'return "EXPIRED-$id";'), $haveExpiredIds)
        ));
        $will_have = array_unique(array_merge(
                $have,
                array_map(create_function('Product $p', 'return "ACTIVE-".$p->product_id;'), $products)
        ));

        $willHaveActiveId = array_unique(array_merge($haveActiveIds,
            array_map(create_function('Product $p', 'return $p->product_id;'), $products)
            ));

        foreach ($products as $pr){
            if ($ro = $pr->getRequireOther()){
                if ($ro && !array_intersect($ro, $will_have)) {
                    $ids = array();
                    foreach ($ro as $s)
                        if (preg_match('/^ACTIVE-(\d+)$/', $s, $args)) $ids[] = $args[1];
                    if ($ids){
                        $error[] = sprintf(___('"%s" can be ordered along with these products/subscripton(s) only: %s'), $pr->getTitle(true),
                            implode(',', $this->getProductTitles($ids)));
                        continue;
                    }
                    $ids = array();
                    foreach ($ro as $s)
                        if (preg_match('/^EXPIRED-(\d+)$/', $s, $args)) $ids[] = $args[1];
                    if ($ids){
                        $error[] = sprintf(___('"%s" can only be ordered if you have expired subscription(s) for these products: %s'), $pr->getTitle(true),
                            implode(',',$this->getProductTitles($ids)));
                        continue;
                    }
                }
            }
            if ($ro = $pr->getPreventIfOther()){
                if ($ro && array_intersect($ro, $have)) {
                    $ids = array();
                    foreach ($ro as $s)
                        if (preg_match('/^ACTIVE-(\d+)$/', $s, $args)) $ids[] = $args[1];

                    $ids = array_intersect($ids, $willHaveActiveId);
                    if ($ids)
                    {
                        $error[] = sprintf(___('"%s" cannot be ordered because you have active subscription(s) to: %s'), $pr->getTitle(true),
                            implode(',',$this->getProductTitles($ids)));
                        continue;
                    }
                    $ids = array();
                    foreach ($ro as $s)
                        if (preg_match('/^EXPIRED-(\d+)$/', $s, $args)) $ids[] = $args[1];

                    $ids = array_intersect($ids, $haveExpiredIds);
                    if ($ids)
                    {
                        $error[] = sprintf(___('"%s" cannot be ordered because you have expired subscription(s) to: %s'), $pr->getTitle(true),
                            implode(',',$this->getProductTitles($ids)));
                        continue;
                    }
                }
            }
        }
        return $error;
    }

    /**
     * return query object with category filter applied if specified
     * if parameters === 0, it selects products not assigned to any categories
     * if parameter === null, it selects products regardless of categories
     * @param int $product_category_id
     * @param bool|array $include_hidden  Include products from hidden categories,
     * in case of array include hidden categories only with given in array codes.
     * @return Am_Query
     */
    function createQuery($product_category_id = null, $include_hidden=true)
    {
        $q = new Am_Query($this, 'p');
        $q->addOrderRaw('0+p.sort_order')->addOrder('title');
        $q->addWhere('p.is_disabled=0');
        if ($product_category_id > 0)
            $q->innerJoin('?_product_product_category', 'ppc', 'ppc.product_id = p.product_id AND ppc.product_category_id='.intval($product_category_id));
        elseif ((string)$product_category_id === '0')
            $q->leftJoin('?_product_product_category', 'ppc', 'ppc.product_id = p.product_id')->addHaving('count(ppc.product_category_id)=0');
        elseif (is_array($include_hidden)) {
            $q->leftJoin('?_product_product_category', 'ppc', 'ppc.product_id = p.product_id')
              ->leftJoin('?_product_category', 'pc', 'pc.product_category_id = ppc.product_category_id')->addHaving('sum(if(pc.code>"" AND pc.code NOT IN (?a), 1, 0)) =0', $include_hidden);
        } elseif(!$include_hidden){
            $q->leftJoin('?_product_product_category', 'ppc', 'ppc.product_id = p.product_id')
              ->leftJoin('?_product_category', 'pc', 'pc.product_category_id = ppc.product_category_id')->addHaving('sum(if(pc.code>"", 1, 0)) =0');
        }

        return $q;
    }
    
    function getRenewalGroups()
    {
        return $this->_db->selectCol("SELECT DISTINCT renewal_group, renewal_group AS ?
            FROM ?_product
            WHERE renewal_group <> '' 
            ORDER BY renewal_group", DBSIMPLE_ARRAY_KEY);
    }
}

