<?php
if (!defined("ROOT_PATH"))
{
	header("HTTP/1.1 403 Forbidden");
	exit;
}
class pjAdminProducts extends pjAdmin
{
	public function pjActionOpenDigital()
	{
		$this->checkLogin();
	    if (!pjAuth::factory()->hasAccess())
	    {
	        $this->sendForbidden();
	        return;
	    }

		if ($this->_get->check('id') && $this->_get->toInt('id') > 0)
		{
			$arr = pjProductModel::factory()->find($this->_get->toInt('id'))->getData();
			if (empty($arr))
			{
				exit;
			}
			if ((int) $arr['is_digital'] !== 1)
			{
				exit;
			}
			if (empty($arr['digital_file']) || !is_file($arr['digital_file']))
			{
				exit;
			}
			$zip = new pjZipStream();
			
			$handle = @fopen($arr['digital_file'], "rb");
			if ($handle)
			{
				$zip->addLargeFile($handle, $arr['digital_name']);
				fclose($handle);
			}
			$zip->finalize();
			$zip->sendZip(sprintf("%s.zip", $arr['digital_name']));
			exit;
			
		} else {
			exit;
		}
	}

	public function pjActionAttrGroupDelete()
	{
		$this->setAjax(true);
		
		if ($this->isXHR() && $this->isLoged())
		{
			if ($this->_post->check('id') && (int) $this->_post->toInt('id') > 0)
			{
				$pjAttributeModel = pjAttributeModel::factory();
				$ids = $pjAttributeModel->where('t1.parent_id', $this->_post->toInt('id'))->findAll()->getDataPair(null, 'id');
				if ($pjAttributeModel->reset()->set('id', $this->_post->toInt('id'))->erase()->getAffectedRows() == 1)
				{
					$pjMultiLangModel = pjMultiLangModel::factory();
					$pjMultiLangModel->where('model', 'pjAttribute')->where('foreign_id', $this->_post->toInt('id'))->eraseAll();
					
					if (!empty($ids))
					{
						$pjAttributeModel->reset()->whereIn('id', $ids)->eraseAll();
						$pjMultiLangModel->reset()->where('model', 'pjAttribute')->whereIn('foreign_id', $ids)->eraseAll();
					}
					
					pjAppController::jsonResponse(array('status' => 'OK', 'code' => 200, 'text' => ''));
				}
			}
			pjAppController::jsonResponse(array('status' => 'ERR', 'code' => 100, 'text' => ''));
		}
		exit;
	}
	
	public function pjActionAttrDelete()
	{
		$this->setAjax(true);
		
		if ($this->isXHR() && $this->isLoged())
		{
			if ($this->_post->check('id') && (int) $this->_post->toInt('id') > 0)
			{
				if (pjAttributeModel::factory()->set('id', $this->_post->toInt('id'))->erase()->getAffectedRows() == 1)
				{
					pjMultiLangModel::factory()->where('model', 'pjAttribute')->where('foreign_id', $this->_post->toInt('id'))->eraseAll();
					pjAppController::jsonResponse(array('status' => 'OK', 'code' => 200, 'text' => ''));
				}
			}
			pjAppController::jsonResponse(array('status' => 'ERR', 'code' => 100, 'text' => ''));
		}
		exit;
	}
	
	public function pjActionAttrCopy()
	{
		$this->setAjax(true);
		
		if ($this->isXHR() && $this->isLoged())
		{
			if ($this->_post->check('from_product_id') && $this->_post->toInt('from_product_id') > 0)
			{
				if ($this->_post->check('product_id') && $this->_post->toInt('product_id') > 0)
				{
					$ak = 'product_id';
					$av = $this->_post->toInt('product_id');
				} elseif ($this->_post->check('hash') && $this->_post->toString('hash') != '') {
					$ak = 'hash';
					$av = $this->_post->toString('hash');
				}
			
				$pjAttributeModel = pjAttributeModel::factory();
				$pjMultiLangModel = pjMultiLangModel::factory();
				
				$attr = $pjAttributeModel->where('t1.product_id', $this->_post->toInt('from_product_id'))->orderBy('t1.`order_group`, `order_item` ASC')->findAll()->getDataPair('id', 'parent_id');
				$arr = array();
				foreach ($attr as $id => $parent_id)
				{
					if (empty($parent_id))
					{
						$arr[$id] = array();
					} else {
						$arr[$parent_id][] = $id;
					}
				}
				
				$multi = $pjMultiLangModel
					->where('t1.model', 'pjAttribute')
					->whereIn('t1.foreign_id', array_keys($attr))
					->where("t1.field='name'")
					->findAll()
					->getData();

				$stack = array();
				foreach ($multi as $item)
				{
					if (!isset($stack[$item['foreign_id']]))
					{
						$stack[$item['foreign_id']] = array();
					}
					$stack[$item['foreign_id']][] = $item;
				}
				
				$last_order = $pjAttributeModel->getLastOrder($this->_post->toInt('product_id'));
				
				foreach ($arr as $parent_id => $items)
				{
					$insert_id = $pjAttributeModel->reset()->setAttributes(array($ak => $av, 'order_group' => $last_order))->insert()->getInsertId();
					if ($insert_id !== false && (int) $insert_id > 0)
					{
						if (isset($stack[$parent_id]))
						{
							foreach ($stack[$parent_id] as $locale)
							{
								$pjMultiLangModel->reset()->setAttributes(array(
									'model' => $locale['model'],
									'foreign_id' => $insert_id,
									'field' => $locale['field'],
									'locale' => $locale['locale'],
									'content' => $locale['content'],
									'source' => 'data'
								))->insert();
							}
						}
					
						$item_order = 0;
						foreach ($items as $id)
						{
							$attr_id = $pjAttributeModel->reset()->setAttributes(array($ak => $av, 'parent_id' => $insert_id, 'order_group' => $last_order, 'order_item'=>$item_order))->insert()->getInsertId();
							if ($attr_id !== false && (int) $attr_id > 0)
							{
								if (isset($stack[$id]))
								{
									foreach ($stack[$id] as $locale)
									{
										$pjMultiLangModel->reset()->setAttributes(array(
											'model' => $locale['model'],
											'foreign_id' => $attr_id,
											'field' => $locale['field'],
											'locale' => $locale['locale'],
											'content' => $locale['content'],
											'source' => 'data'
										))->insert();
									}
								}
								$item_order++;
							}
						}
					}
					$last_order++;
				}
				pjAppController::jsonResponse(array('status' => 'OK', 'code' => 200, 'text' => ''));
			}
		}
		exit;
	}
	
	private function pjActionAttrHandle($product_id)
	{
		if ($attr_arr = $this->_post->toArray('attr'))
		{
			$pjAttributeModel = pjAttributeModel::factory();
			$pjMultiLangModel = pjMultiLangModel::factory();
			
			$i18n_arr = $this->_post->toArray('i18n');
			$keys = array_keys($i18n_arr);
			$fkey = $keys[0];
			
			$group_arr = $this->_post->check('orderAttributes') && $this->_post->toString('orderAttributes') != '' ? explode("|", $this->_post->toString('orderAttributes')) : array();
			$order_group_arr = array();
			foreach($group_arr as $k => $v)
			{
				$order_group_arr[$v] = $k;
			}
			foreach ($attr_arr as $group_id => $whatever)
			{
				if (strpos($group_id, 'x_') === 0)
				{
					// insert new group attr
					$attr_group_id = $pjAttributeModel->reset()
						->setAttributes(array(
								'product_id'=> $product_id,
								'order_group'=> $order_group_arr['attrBox_' . $group_id]
							))
						->insert()->getInsertId();
					if ($attr_group_id !== false && (int) $attr_group_id > 0)
					{
						$tmp = $this->pjActionTurnI18n($i18n_arr, 'attr_group', $group_id, NULL, 'name');
						$pjMultiLangModel->saveMultiLang($tmp, $attr_group_id, 'pjAttribute');
						
						$item_arr = $this->_post->check('orderItems_' . $group_id) &&  $this->_post->toString('orderItems_' . $group_id) != '' ? explode("|", $this->_post->toString('orderItems_' . $group_id)) : array();
						$order_item_arr = array();
						foreach($item_arr as $k => $v)
						{
							$order_item_arr[$v] = $k;
						}
						if(isset($i18n_arr[$fkey]['attr_item'][$group_id]) && count(($i18n_arr[$fkey]['attr_item'][$group_id])) > 0)
						{
							foreach ($i18n_arr[$fkey]['attr_item'][$group_id] as $index => $value)
							{
								$attr_item_id = $pjAttributeModel->reset()->setAttributes(array(
									'product_id' => $product_id,
									'parent_id' => $attr_group_id,
									'order_group'=> $order_group_arr['attrBox_' . $group_id],
									'order_item'=> $order_item_arr['attrBoxRowItems_' . $index]
								))->insert()->getInsertId();
								if ($attr_item_id !== false && (int) $attr_item_id > 0)
								{
									$tmp = $this->pjActionTurnI18n($i18n_arr, 'attr_item', $group_id, $index, 'name');
									$pjMultiLangModel->saveMultiLang($tmp, $attr_item_id, 'pjAttribute');
								}
							}
						}
					}
				} else {
					// update group attr
					$tmp = $this->pjActionTurnI18n($i18n_arr, 'attr_group', $group_id, NULL, 'name');
					$pjMultiLangModel->updateMultiLang($tmp, $group_id, 'pjAttribute');

					$pjAttributeModel->reset()->set('id', $group_id)->modify(array(
							'order_group' => $order_group_arr['attrBox_' . $group_id]
					));
					$item_arr = $this->_post->check('orderItems_' . $group_id) && $this->_post->toString('orderItems_' . $group_id) != '' ? explode("|", $this->_post->toString('orderItems_' . $group_id)) : array();
					$order_item_arr = array();
					foreach($item_arr as $k => $v)
					{
						$order_item_arr[$v] = $k;
					}
					if(isset($i18n_arr[$fkey]['attr_item'][$group_id]) && is_array($i18n_arr[$fkey]['attr_item'][$group_id]))
					{
						foreach ($i18n_arr[$fkey]['attr_item'][$group_id] as $index => $value)
						{
							if (strpos($index, 'y_') === 0)
							{
								# Add items
								$attr_item_id = $pjAttributeModel->reset()->setAttributes(array(
									'product_id' => $product_id,
									'parent_id' => $group_id,
									'order_group'=> $order_group_arr['attrBox_' . $group_id],
									'order_item'=> $order_item_arr['attrBoxRowItems_' . $index]
								))->insert()->getInsertId();
								if ($attr_item_id !== false && (int) $attr_item_id > 0)
								{
									$tmp = $this->pjActionTurnI18n($i18n_arr, 'attr_item', $group_id, $index, 'name');
									$pjMultiLangModel->saveMultiLang($tmp, $attr_item_id, 'pjAttribute');
								}
							} else {
								# Update items
								$tmp = $this->pjActionTurnI18n($i18n_arr, 'attr_item', $group_id, $index, 'name');
								$pjAttributeModel->reset()->set('id', $index)->modify(array(
										'order_group'=> $order_group_arr['attrBox_' . $group_id],
										'order_item' => $order_item_arr['attrBoxRowItems_' . $index]
								));
								$pjMultiLangModel->updateMultiLang($tmp, $index, 'pjAttribute');
							}
						}
					}
				}
			}
			foreach ($i18n_arr as $locale_id => $whatever)
			{
				if (isset($i18n_arr[$locale_id]['attr_group']))
				{
					unset($i18n_arr[$locale_id]['attr_group']);
				}
				if (isset($i18n_arr[$locale_id]['attr_item']))
				{
					unset($i18n_arr[$locale_id]['attr_item']);
				}
			}
			return $i18n_arr;
		}
	}
	
	public function pjActionCheckSku()
	{
		$this->setAjax(true);
	
		if ($this->isXHR())
		{
			if (!$this->_get->check('sku') || $this->_get->toString('sku') == '')
			{
				echo 'false';
				exit;
			}
			$pjProductModel = pjProductModel::factory()->where('t1.sku', $this->_get->toString('sku'));
			if ($this->_get->check('id') && $this->_get->toInt('id') > 0)
			{
				$pjProductModel->where('t1.id !=', $this->_get->toInt('id'));
			}
			echo $pjProductModel->findCount()->getData() == 0 ? 'true' : 'false';
		}
		exit;
	}
	
	public function pjActionCreate()
	{
		$this->checkLogin();
	    if (!pjAuth::factory()->hasAccess())
	    {
	        $this->sendForbidden();
	        return;
	    }

		if (self::isPost() && $this->_post->toInt('product_create'))
		{
			$pjProductModel = pjProductModel::factory();
	        
	        $data = array();
	        $data['is_featured'] = $this->_post->check('is_featured') ? 1 : 0;
	        $id = $pjProductModel->setAttributes(array_merge($this->_post->raw(), $data))->insert()->getInsertId();
	        if ($id !== false && (int) $id > 0)
	        {
	        	$err = 'AP01';
	        	if ($i18n_arr = $this->_post->toArray('i18n')) {
	        		pjMultiLangModel::factory()->saveMultiLang($i18n_arr, $id, 'pjProduct', 'data');
	        	}
	        	$pjProductCategoryModel = pjProductCategoryModel::factory();
	            if ($category_id_arr = $this->_post->toArray('category_id'))
	            {
	                $pjProductCategoryModel->begin();
	                foreach ($category_id_arr as $category_id)
	                {
	                    $pjProductCategoryModel
	                    ->reset()
	                    ->set('product_id', $id)
	                    ->set('category_id', $category_id)
	                    ->insert();
	                }
	                $pjProductCategoryModel->commit();
	            }
	        	pjUtil::redirect(sprintf("%s?controller=pjAdminProducts&action=pjActionUpdate&id=%u&err=%s", $_SERVER['PHP_SELF'], $id, $err));
	        } else {
	        	$err = 'AP02';
	        }
	        pjUtil::redirect(sprintf("%s?controller=pjAdminProducts&action=pjActionIndex&err=%s", $_SERVER['PHP_SELF'], $err));
		}
		if (self::isGet()) {
			$this->setLocalesData();
			
			$this->set('category_arr', pjCategoryModel::factory()->getNode($this->getLocaleId(), 1));
			
			$this->appendJs('jquery.multilang.js', PJ_FRAMEWORK_LIBS_PATH . 'pj/js/');				
			$this->appendJs('tinymce.min.js', PJ_THIRD_PARTY_PATH . 'tinymce/');
			$this->appendCss('css/select2.min.css', PJ_THIRD_PARTY_PATH . 'select2/');
			$this->appendJs('js/select2.full.min.js', PJ_THIRD_PARTY_PATH . 'select2/');
			$this->appendJs('pjAdminProducts.js');
		}
	}
	
	private function pjActionDeleteProductAttr($product_id)
	{
		if (empty($product_id))
		{
			return false;
		}
		
		$pjAttributeModel = pjAttributeModel::factory();
		if (is_array($product_id))
		{
			$pjAttributeModel->whereIn('product_id', $product_id);
		} else {
			$pjAttributeModel->where('product_id', $product_id);
		}
		
		$attr_ids = $pjAttributeModel->findAll()->getDataPair(null, 'id');
		if (!empty($attr_ids))
		{
			$pjAttributeModel->eraseAll();
			$pjMultiLangModel = pjMultiLangModel::factory();
			$pjMultiLangModel->reset()->where('model', 'pjAttribute')->whereIn('foreign_id', $attr_ids)->eraseAll();
		}
	}
	
	private function pjActionDeleteStockAttr($product_id)
	{
		if (empty($product_id))
		{
			return false;
		}
		
		$pjStockAttributeModel = pjStockAttributeModel::factory();
		if (is_array($product_id))
		{
			$pjStockAttributeModel->whereIn('product_id', $product_id);
		} else {
			$pjStockAttributeModel->where('product_id', $product_id);
		}
		$pjStockAttributeModel->eraseAll();
	}
	
	public function pjActionDeactivate()
	{
		$this->setAjax(true);
	
		if ($this->isXHR() && $this->_get->check('id') && $this->_get->toInt('id') > 0)
		{
			pjProductModel::factory()->where('id', $this->_get->toInt('id'))->limit(1)->modifyAll(array('status' => 2));
			
			pjAppController::jsonResponse(array('status' => 'OK', 'code' => 200, 'text' => ''));
		}
		exit;
	}
	
	/**
	 * Number of distinct orders that reference any of the given product id(s).
	 * Used to warn before deleting a product that has order history.
	 */
	private function pjCountProductOrders($ids)
	{
		$ids = array_filter(array_map('intval', (array) $ids));
		if (empty($ids)) { return 0; }
		$row = pjOrderStockModel::factory()
			->select('COUNT(DISTINCT t1.order_id) AS cnt')
			->whereIn('t1.product_id', $ids)
			->findAll()
			->getDataIndex(0);
		return isset($row['cnt']) ? (int) $row['cnt'] : 0;
	}

	public function pjActionDeleteProduct()
	{
		$this->setAjax(true);
	    
	    if (!$this->isXHR())
	    {
	        self::jsonResponse(array('status' => 'ERR', 'code' => 100, 'text' => 'Missing headers.'));
	    }
	    if (!pjAuth::factory()->hasAccess())
	    {
	        self::jsonResponse(array('status' => 'ERR', 'code' => 102, 'text' => 'Access denied.'));
	    }
	    if (!($this->_get->toInt('id')))
	    {
	        self::jsonResponse(array('status' => 'ERR', 'code' => 103, 'text' => 'Missing, empty or invalid parameters.'));
	    }
	    $id = $this->_get->toInt('id');
	    $pjProductModel = pjProductModel::factory();
	    $arr = $pjProductModel->find($id)->getData();
	    if (!$arr)
	    {
	        self::jsonResponse(array('status' => 'ERR', 'code' => 103, 'text' => 'Product not found.'));
	    }
	    // Warn before deleting a product that already has orders. The orders are
	    // kept either way, but those order lines lose the live product name/image.
	    if (!$this->_post->check('confirmed'))
	    {
	        $orderCount = $this->pjCountProductOrders($id);
	        if ($orderCount > 0)
	        {
	            $tplMsg = __('product_delete_has_orders', true);
	            if (empty($tplMsg)) { $tplMsg = 'This product is linked to %d order(s). The orders are kept, but those order lines will no longer show this product\'s name or image. Delete anyway?'; }
	            self::jsonResponse(array('status' => 'CONFIRM', 'code' => 'HAS_ORDERS', 'count' => $orderCount, 'text' => sprintf($tplMsg, $orderCount)));
	        }
	    }
	    if ($pjProductModel->reset()->set('id', $id)->erase()->getAffectedRows() == 1)
	    {
	    	$pjExtraModel = pjExtraModel::factory();
			$pjMultiLangModel = pjMultiLangModel::factory();
			
			$pjMultiLangModel->where('model', 'pjProduct')->where('foreign_id', $id)->eraseAll();
			
			pjStockModel::factory()->where('product_id', $id)->eraseAll();
			pjCartModel::factory()->where('product_id', $id)->eraseAll();
			$this->pjActionDeleteStockAttr($id);
			pjProductSimilarModel::factory()->where('product_id', $id)->orWhere('similar_id', $id)->eraseAll();
			
			$extra_arr = $pjExtraModel->where('product_id', $id)->findAll()->getDataPair(null, 'id');
			if (!empty($extra_arr))
			{
				$pjExtraItemModel = pjExtraItemModel::factory();
				$pjExtraModel->eraseAll();
				$pjMultiLangModel->reset()->where('model', 'pjExtra')->whereIn('foreign_id', $extra_arr)->eraseAll();
				$extra_item_arr = $pjExtraItemModel->whereIn('extra_id', $extra_arr)->findAll()->getDataPair(NULL, 'id');
				if (!empty($extra_item_arr))
				{
					$pjExtraItemModel->reset()->whereIn('extra_id', $extra_arr)->eraseAll();
					$pjMultiLangModel->reset()->where('model', 'pjExtraItem')->whereIn('foreign_id', $extra_item_arr)->eraseAll();
				}
			}
			$this->pjActionDeleteProductAttr($id);
			pjProductCategoryModel::factory()->where('product_id', $id)->eraseAll();
			
			$pjGalleryModel = pjGalleryModel::factory();
			$image_arr = $pjGalleryModel->where('foreign_id', $id)->findAll()->getData();
			if (!empty($image_arr))
			{
				$pjGalleryModel->eraseAll();
				foreach ($image_arr as $image)
				{
					@clearstatcache();
					if (!empty($image['small_path']) && is_file($image['small_path']))
					{
						@unlink($image['small_path']);
					}
					@clearstatcache();
					if (!empty($image['medium_path']) && is_file($image['medium_path']))
					{
						@unlink($image['medium_path']);
					}
					@clearstatcache();
					if (!empty($image['large_path']) && is_file($image['large_path']))
					{
						@unlink($image['large_path']);
					}
					@clearstatcache();
					if (!empty($image['source_path']) && is_file($image['source_path']))
					{
						@unlink($image['source_path']);
					}
				}
			}

			// Remove the digital file from disk too, so deleting a product never
			// leaves an orphaned upload behind.
			@clearstatcache();
			if (!empty($arr['digital_file']) && is_file($arr['digital_file']))
			{
				@unlink($arr['digital_file']);
			}

			self::jsonResponse(array('status' => 'OK', 'code' => 200, 'text' => 'Product has been deleted'));
	    }else{
	        self::jsonResponse(array('status' => 'ERR', 'code' => 105, 'text' => 'Product has not been deleted.'));
	    }
		exit;
	}
	
	public function pjActionDeleteProductBulk()
	{
		$this->setAjax(true);
	
		if (!pjAuth::factory()->hasAccess())
		{
		    self::jsonResponse(array('status' => 'ERR', 'code' => 103, 'text' => 'Access denied.'));
		}
		
		if (!$this->isXHR())
		{
			self::jsonResponse(array('status' => 'ERR', 'code' => 100, 'text' => 'Missing headers.'));
		}
		if (!self::isPost())
		{
			self::jsonResponse(array('status' => 'ERR', 'code' => 101, 'text' => 'HTTP method not allowed.'));
		}
		
		if (!$this->_post->has('record') || !($record = $this->_post->toArray('record')))
		{
			self::jsonResponse(array('status' => 'ERR', 'code' => 103, 'text' => 'Missing, empty or invalid data.'));
		}

		// Warn before deleting products that already have orders.
		if (!$this->_post->check('confirmed'))
		{
			$orderCount = $this->pjCountProductOrders($record);
			if ($orderCount > 0)
			{
				$tplMsg = __('product_delete_has_orders_bulk', true);
				if (empty($tplMsg)) { $tplMsg = 'The selected products are linked to %d order(s). The orders are kept, but those order lines will no longer show the product name or image. Delete anyway?'; }
				self::jsonResponse(array('status' => 'CONFIRM', 'code' => 'HAS_ORDERS', 'count' => $orderCount, 'text' => sprintf($tplMsg, $orderCount)));
			}
		}

		$pjExtraModel = pjExtraModel::factory();
		$pjMultiLangModel = pjMultiLangModel::factory();

		// Capture digital file paths before the rows are erased so the physical
		// files can be removed afterwards (no orphans left on disk).
		$digital_files = pjProductModel::factory()
			->select('t1.digital_file')
			->whereIn('t1.id', $record)
			->findAll()
			->getDataPair(null, 'digital_file');

		pjProductModel::factory()->whereIn('id', $record)->eraseAll();
		$pjMultiLangModel->where('model', 'pjProduct')->whereIn('foreign_id', $record)->eraseAll();
		
		pjStockModel::factory()->whereIn('product_id', $record)->eraseAll();
		pjCartModel::factory()->whereIn('product_id', $record)->eraseAll();
		$this->pjActionDeleteStockAttr($record);
		pjProductSimilarModel::factory()->whereIn('product_id', $record)->orWhereIn('similar_id', $record)->eraseAll();
		
		$extra_arr = $pjExtraModel->whereIn('product_id', $record)->findAll()->getDataPair(null, 'id');
		if (!empty($extra_arr))
		{
			$pjExtraItemModel = pjExtraItemModel::factory();
			$pjExtraModel->eraseAll();
			$pjMultiLangModel->reset()->where('model', 'pjExtra')->whereIn('foreign_id', $extra_arr)->eraseAll();
			$extra_item_arr = $pjExtraItemModel->whereIn('extra_id', $extra_arr)->findAll()->getDataPair(NULL, 'id');
			if (!empty($extra_item_arr))
			{
				$pjExtraItemModel->reset()->whereIn('extra_id', $extra_arr)->eraseAll();
				$pjMultiLangModel->reset()->where('model', 'pjExtraItem')->whereIn('foreign_id', $extra_item_arr)->eraseAll();
			}
		}

		$this->pjActionDeleteProductAttr($record);
		pjProductCategoryModel::factory()->whereIn('product_id', $record)->eraseAll();
		
		$pjGalleryModel = pjGalleryModel::factory();
		$image_arr = $pjGalleryModel->whereIn('foreign_id', $record)->findAll()->getData();
		if (!empty($image_arr))
		{
			$pjGalleryModel->eraseAll();
			foreach ($image_arr as $image)
			{
				@clearstatcache();
				if (!empty($image['small_path']) && is_file($image['small_path']))
				{
					@unlink($image['small_path']);
				}
				@clearstatcache();
				if (!empty($image['medium_path']) && is_file($image['medium_path']))
				{
					@unlink($image['medium_path']);
				}
				@clearstatcache();
				if (!empty($image['large_path']) && is_file($image['large_path']))
				{
					@unlink($image['large_path']);
				}
				@clearstatcache();
				if (!empty($image['source_path']) && is_file($image['source_path']))
				{
					@unlink($image['source_path']);
				}
			}
		}

		// Remove digital files from disk too (no orphaned uploads left behind).
		if (!empty($digital_files))
		{
			foreach ($digital_files as $digital_file)
			{
				@clearstatcache();
				if (!empty($digital_file) && is_file($digital_file))
				{
					@unlink($digital_file);
				}
			}
		}

		self::jsonResponse(array('status' => 'OK'));
	}

	public function pjActionDeleteSimilar()
	{
		$this->setAjax(true);
	    
	    if (!$this->isXHR())
	    {
	        self::jsonResponse(array('status' => 'ERR', 'code' => 100, 'text' => 'Missing headers.'));
	    }
	    if (!pjAuth::factory()->hasAccess())
	    {
	        self::jsonResponse(array('status' => 'ERR', 'code' => 102, 'text' => 'Access denied.'));
	    }
	    if (!($this->_get->toInt('id')))
	    {
	        self::jsonResponse(array('status' => 'ERR', 'code' => 103, 'text' => 'Missing, empty or invalid parameters.'));
	    }
	    $id = $this->_get->toInt('id');
	    if (pjProductSimilarModel::factory()->set('id', $id)->erase()->getAffectedRows() == 1)
	    {
	        self::jsonResponse(array('status' => 'OK', 'code' => 200, 'text' => 'Similar product has been deleted.'));
	    }else{
	        self::jsonResponse(array('status' => 'ERR', 'code' => 105, 'text' => 'Similar product has not been deleted.'));
	    }
		exit;
	}
	
	public function pjActionDeleteSimilarBulk()
	{
		$this->setAjax(true);
	
		if (!pjAuth::factory()->hasAccess())
		{
		    self::jsonResponse(array('status' => 'ERR', 'code' => 103, 'text' => 'Access denied.'));
		}
		
		if (!$this->isXHR())
		{
			self::jsonResponse(array('status' => 'ERR', 'code' => 100, 'text' => 'Missing headers.'));
		}
		if (!self::isPost())
		{
			self::jsonResponse(array('status' => 'ERR', 'code' => 101, 'text' => 'HTTP method not allowed.'));
		}
		
		if (!$this->_post->has('record') || !($record = $this->_post->toArray('record')))
		{
			self::jsonResponse(array('status' => 'ERR', 'code' => 103, 'text' => 'Missing, empty or invalid data.'));
		}
		
		if (pjProductSimilarModel::factory()->whereIn('id', $record)->eraseAll()->getAffectedRows() > 0)
		{
			pjAppController::jsonResponse(array('status' => 'OK', 'code' => 200, 'text' => 'Similar products has been deleted.'));
		}
		
		self::jsonResponse(array('status' => 'OK'));
	}
	
	public function pjActionGetProduct()
	{
		$this->setAjax(true);
	
		if ($this->isXHR())
		{
			$pjProductModel = pjProductModel::factory()
				->join('pjMultiLang', "t2.model='pjProduct' AND t2.foreign_id=t1.id AND t2.locale='".$this->getLocaleId()."' AND t2.field='name'", 'left outer')
				->join('pjMultiLang', "t3.model='pjProduct' AND t3.foreign_id=t1.id AND t3.locale='".$this->getLocaleId()."' AND t3.field='short_desc'", 'left outer')
				->join('pjMultiLang', "t4.model='pjProduct' AND t4.foreign_id=t1.id AND t4.locale='".$this->getLocaleId()."' AND t4.field='full_desc'", 'left outer');
			
			if ($q = $this->_get->toString('q'))
			{
				$q = str_replace(array('%', '_'), array('\%', '\_'), trim($q));
				$pjProductModel->where('(t2.content LIKE "%'.$q.'%" OR t3.content LIKE "%'.$q.'%" OR t4.content LIKE "%'.$q.'%" OR t1.sku LIKE "%'.$q.'%")');
			}
			
			if ($this->_get->check('status') && $this->_get->toString('status') != '' && in_array($this->_get->toString('status'), array(1,2)))
			{
				if ((int) $this->_get->toString('status') === 1)
				{
					// Active products only.
					$pjProductModel->where('t1.status', 1);
				}
				else
				{
					// Hidden = anything that is not active (covers legacy 0 and 2).
					$pjProductModel->where('t1.status <>', 1);
				}
			}

			if ($this->_get->check('name') && $this->_get->toString('name') != '')
			{
				$q = str_replace(array('%', '_'), array('\%', '\_'), $this->_get->toString('name'));
				$pjProductModel->where('t2.content LIKE', "%$q%");
			}
			
			if ($this->_get->check('sku') && $this->_get->toString('sku') != '')
			{
				$q = str_replace(array('%', '_'), array('\%', '\_'), $this->_get->toString('sku'));
				$pjProductModel->where('t1.sku LIKE', "%$q%");
			}
			
			if ($this->_get->check('category_id') && $this->_get->toInt('category_id') > 0)
			{
				$pjProductModel->where(sprintf("t1.id IN (SELECT `product_id` FROM `%s` WHERE `category_id` = '%u')",
					pjProductCategoryModel::factory()->getTable(), $this->_get->toInt('category_id')));
			}
			
			if ($this->_get->check('is_digital'))
			{
				$pjProductModel->where('t1.is_digital', 1);
			}
			
			if ($this->_get->check('is_featured'))
			{
				$pjProductModel->where('t1.is_featured', 1);
			}
			if ($this->_get->check('is_out') && $this->_get->toString('is_out') != '')
			{
				$pjProductModel->where("(t1.id NOT IN(SELECT TS.product_id FROM `".pjStockModel::factory()->getTable()."` AS TS GROUP BY TS.product_id HAVING SUM(TS.qty) > 0))");
			}
			if ($this->_get->check('is_active_out') && $this->_get->toString('is_active_out') != '')
			{
				$pjProductModel->where("(t1.status = 1 AND t1.id NOT IN(SELECT TS.product_id FROM `".pjStockModel::factory()->getTable()."` AS TS GROUP BY TS.product_id HAVING SUM(TS.qty) > 0))");
			}
			
			$column = 'name';
			$direction = 'ASC';
			$allowed_columns = array('id', 'name', 'sku', 'status', 'created', 'price', 'is_featured', 'cnt_orders', 'total_stock');
			if ($this->_get->check('direction') && $this->_get->check('column') && in_array(strtoupper($this->_get->toString('direction')), array('ASC', 'DESC')) && in_array($this->_get->toString('column'), $allowed_columns))
			{
				$column = $this->_get->toString('column');
				$direction = strtoupper($this->_get->toString('direction'));
			}

			$total = $pjProductModel->findCount()->getData();
			$rowCount = $this->_get->check('rowCount') && $this->_get->toInt('rowCount') > 0 ? $this->_get->toInt('rowCount') : 10;
			$pages = ceil($total / $rowCount);
			$page = $this->_get->check('page') && $this->_get->toInt('page') > 0 ? $this->_get->toInt('page') : 1;
			$offset = ((int) $page - 1) * $rowCount;
			if ($page > $pages)
			{
				$page = $pages;
			}

			$data = $pjProductModel
				->select(sprintf("t1.*, t2.content AS name,
					(SELECT `small_path` FROM `%1\$s`
						WHERE `foreign_id` = `t1`.`id`
						ORDER BY `sort` ASC
						LIMIT 1) AS `pic`,
					(SELECT COALESCE(SUM(`qty`), 0) FROM `%2\$s` WHERE `product_id` = `t1`.`id` LIMIT 1) AS `total_stock`,
					(SELECT MIN(`price`) FROM `%2\$s` WHERE `product_id` = `t1`.`id` LIMIT 1) AS `min_price`,
					(SELECT COUNT(`id`) FROM `%2\$s` WHERE `product_id` = `t1`.`id` LIMIT 1) AS `cnt_stock`,
					(SELECT COUNT(DISTINCT `order_id`) FROM `%3\$s` WHERE `product_id` = `t1`.`id` LIMIT 1) AS `cnt_orders`
				", pjGalleryModel::factory()->getTable(), pjStockModel::factory()->getTable(), pjOrderStockModel::factory()->getTable()))
				->orderBy("$column $direction")->limit($rowCount, $offset)->findAll()->getData();
				
			foreach ($data as $k => $v)
			{
				$data[$k]['name'] = pjSanitize::clean($v['name']);
				$data[$k]['min_price_format'] = pjCurrency::formatPrice($v['min_price']);
				if ($v['cnt_stock'] > 1)
				{
					$data[$k]['min_price_format'] = __('front_price_from', true) . " " . $data[$k]['min_price_format'];
				}
				// Normalize status for display: anything that is not active (1) is
				// treated as hidden (2) so the grid toggle always shows a label,
				// even for legacy rows stored with status 0.
				$data[$k]['status'] = ((int) $v['status'] === 1) ? 1 : 2;
			}
			
			pjAppController::jsonResponse(compact('data', 'total', 'pages', 'page', 'rowCount', 'column', 'direction'));
		}
		exit;
	}
	
	public function pjActionGetStock()
	{
		$this->setAjax(true);
	
		if ($this->isXHR())
		{
			$pjStockModel = pjStockModel::factory()
				->join('pjMultiLang', "t2.model='pjProduct' AND t2.foreign_id=t1.product_id AND t2.locale='".$this->getLocaleId()."' AND t2.field='name'", 'left outer')
				->join('pjProduct', 't3.id=t1.product_id', 'left outer')
			;
			
			if ($this->_get->check('q') && $this->_get->toString('q') != '')
			{
				$q = trim($this->_get->toString('q'));
				$q = str_replace(array('%', '_'), array('\%', '\_'), $q);
				$pjStockModel->where('t2.content LIKE', "%$q%");
				$pjStockModel->orWhere('t3.sku LIKE', "%$q%");
			}
			
			$column = 'name';
			$direction = 'ASC';
			$allowed_columns = array('id', 'name', 'sku', 'status', 'created', 'price', 'is_featured', 'cnt_orders', 'total_stock');
			if ($this->_get->check('direction') && $this->_get->check('column') && in_array(strtoupper($this->_get->toString('direction')), array('ASC', 'DESC')) && in_array($this->_get->toString('column'), $allowed_columns))
			{
				$column = $this->_get->toString('column');
				$direction = strtoupper($this->_get->toString('direction'));
			}

			$total = $pjStockModel->findCount()->getData();
			$rowCount = $this->_get->check('rowCount') && $this->_get->toInt('rowCount') > 0 ? $this->_get->toInt('rowCount') : 10;
			$pages = ceil($total / $rowCount);
			$page = $this->_get->check('page') && $this->_get->toInt('page') > 0 ? $this->_get->toInt('page') : 1;
			$offset = ((int) $page - 1) * $rowCount;
			if ($page > $pages)
			{
				$page = $pages;
			}

			$data = $pjStockModel
				->select(sprintf("t1.*, t2.content AS name,
					(SELECT `small_path` FROM `%1\$s`
						WHERE `id` = `t1`.`image_id`
						LIMIT 1) AS `pic`,
					(SELECT GROUP_CONCAT(CONCAT_WS('~:~', `tm2`.`content`, `tm1`.`content`) SEPARATOR '~|~')
						FROM `%2\$s` AS `tsa`
						LEFT JOIN `%3\$s` AS `tm1` ON `tm1`.`model` = 'pjAttribute' AND `tm1`.`foreign_id` = `tsa`.`attribute_id` AND `tm1`.`field` = 'name' AND `tm1`.`locale` = '%4\$u'
						LEFT JOIN `%3\$s` AS `tm2` ON `tm2`.`model` = 'pjAttribute' AND `tm2`.`foreign_id` = `tsa`.`attribute_parent_id` AND `tm2`.`field` = 'name' AND `tm2`.`locale` = '%4\$u'
						WHERE `tsa`.`product_id` = `t1`.`product_id`
						AND `tsa`.`stock_id` = `t1`.`id`
						LIMIT 1) AS `stock_attr`
				", pjGalleryModel::factory()->getTable(), pjStockAttributeModel::factory()->getTable(), pjMultiLangModel::factory()->getTable(), $this->getLocaleId()))
				->orderBy("$column $direction")
				->limit($rowCount, $offset)
				->findAll()
				->toArray('stock_attr', '~|~')
				->getData();
			
			foreach ($data as $k => $v)
			{
				$data[$k]['price_formated'] = pjCurrency::formatPrice($v['price']);
			}
				
			pjAppController::jsonResponse(compact('data', 'total', 'pages', 'page', 'rowCount', 'column', 'direction'));
		}
		exit;
	}

	public function pjActionDeleteExtra()
	{
		$this->setAjax(true);
	
		if ($this->isXHR())
		{
			$resp = array('status' => 'ERR');
			if (pjExtraModel::factory()->set('id', $this->_post->toInt('id'))->erase()->getAffectedRows() == 1)
			{
				$pjMultiLangModel = pjMultiLangModel::factory();
				$pjExtraItemModel = pjExtraItemModel::factory();
				
				$pjMultiLangModel->reset()
					->where('model', 'pjExtra')
					->where('foreign_id', $this->_post->toInt('id'))
					->eraseAll();
					
				$extra_item = $pjExtraItemModel->where('extra_id', $this->_post->toInt('id'))->findAll()->getDataPair(NULL, 'id');
				if (!empty($extra_item))
				{
					$pjMultiLangModel->reset()
						->where('model', 'pjExtraItem')
						->whereIn('foreign_id', $extra_item)
						->eraseAll();
				
					$pjExtraItemModel->reset()->where('extra_id', $this->_post->toInt('id'))->eraseAll();
				}
				$resp['status'] = 'OK';
			}
			pjAppController::jsonResponse($resp);
		}
		exit;
	}
	
	public function pjActionDeleteStock()
	{
		$this->setAjax(true);
	
		if ($this->isXHR())
		{
			$resp = array('status' => 'ERR');
			if (pjStockModel::factory()->set('id', $this->_post->toInt('id'))->erase()->getAffectedRows() == 1)
			{
				pjStockAttributeModel::factory()->where('stock_id', $this->_post->toInt('id'))->eraseAll();
				$resp['status'] = 'OK';
			}
			pjAppController::jsonResponse($resp);
		}
	}
	
	public function pjActionDeleteDigital()
	{
		$this->setAjax(true);
	
		if ($this->isXHR())
		{
			$pjProductModel = pjProductModel::factory();
			
			$arr = $pjProductModel->find($this->_post->toInt('id'))->getData();
			if (count($arr) > 0)
			{
				if ($pjProductModel
					->reset()
					->set('id', $arr['id'])
					->modify(array(
						'digital_file' => ':NULL',
						'digital_name' => ':NULL'
					))->getAffectedRows() == 1)
				{
					@unlink($arr['digital_file']);
				}
				$this->set('arr', $arr);
			}
		}
	}
	
	public function pjActionExportProduct()
	{
		$this->checkLogin();
		
		if ($record = $this->_post->toArray('record'))
		{
			$arr = pjProductModel::factory()
				->join('pjMultiLang', "t2.model='pjProduct' AND t2.foreign_id=t1.id AND t2.locale='".$this->getLocaleId()."' AND t2.field='name'", 'left outer')
				->join('pjMultiLang', "t3.model='pjProduct' AND t3.foreign_id=t1.id AND t3.locale='".$this->getLocaleId()."' AND t3.field='short_desc'", 'left outer')
				->join('pjMultiLang', "t4.model='pjProduct' AND t4.foreign_id=t1.id AND t4.locale='".$this->getLocaleId()."' AND t4.field='full_desc'", 'left outer')
				->select(sprintf("t1.id,
					t2.content AS product_name,
					t1.sku,
					(CASE WHEN t1.status = 1 THEN 'Available' ELSE 'Hidden' END) AS status,
					(SELECT GROUP_CONCAT(tc.content SEPARATOR ' | ') FROM `%1\$s` AS pc LEFT JOIN `%2\$s` AS tc ON tc.model='pjCategory' AND tc.foreign_id = pc.category_id AND tc.field='name' AND tc.locale='%3\$s' WHERE pc.product_id = t1.id) AS categories,
					(CASE WHEN t1.is_featured = 1 THEN 'Yes' ELSE 'No' END) AS is_featured,
					(CASE WHEN t1.is_digital = 1 THEN 'Yes' ELSE 'No' END) AS is_digital,
					t3.content AS short_description,
					t4.content AS full_description,
					t1.digital_file,
					t1.digital_name,
					t1.digital_expire,
					(SELECT MIN(`price`) FROM `%4\$s` WHERE `product_id` = `t1`.`id` LIMIT 1) AS `price`,
					(SELECT COALESCE(SUM(`qty`), 0) FROM `%4\$s` WHERE `product_id` = `t1`.`id` LIMIT 1) AS `in_stock`",
					pjProductCategoryModel::factory()->getTable(),
					pjMultiLangModel::factory()->getTable(),
					$this->getLocaleId(),
					pjStockModel::factory()->getTable()))
				->whereIn('t1.id', $record)
				->findAll()->getData();

			// Attach product attributes as a single "attributes" column shaped like the
			// extras column:  "Size: S, M, L | Color: Red, Blue". Groups are separated by
			// " | ", each group is "Name: value, value, ...". This is self-describing in
			// the CSV and round-trips with the import format.
			$attr_rows = pjAttributeModel::factory()
				->select("t1.id, t1.product_id, t1.parent_id, t2.content AS name")
				->join('pjMultiLang', "t2.model='pjAttribute' AND t2.foreign_id=t1.id AND t2.field='name' AND t2.locale='".$this->getLocaleId()."'", 'left outer')
				->whereIn('t1.product_id', $record)
				->orderBy("t1.`order_group` ASC, t1.`order_item` ASC")
				->findAll()->getData();

			$group_name = array();      // group id  => group name
			$group_order = array();     // product id => ordered list of group ids
			$values_by_group = array(); // group id  => array of value names
			foreach ($attr_rows as $row)
			{
				if ((int) $row['parent_id'] === 0)
				{
					$group_name[$row['id']] = (string) $row['name'];
					$group_order[$row['product_id']][] = $row['id'];
				}
			}
			foreach ($attr_rows as $row)
			{
				if ((int) $row['parent_id'] !== 0 && isset($group_name[$row['parent_id']]))
				{
					$values_by_group[$row['parent_id']][] = $row['name'];
				}
			}

			$attributes_by_product = array(); // product id => "Group: v1, v2 | Group2: vA, vB"
			foreach ($group_order as $pid => $gids)
			{
				$parts = array();
				foreach ($gids as $gid)
				{
					$gname = trim($group_name[$gid]);
					if ($gname === '') { continue; }
					$vals = isset($values_by_group[$gid]) ? implode(', ', $values_by_group[$gid]) : '';
					$parts[] = $gname . ': ' . $vals;
				}
				$attributes_by_product[$pid] = implode(' | ', $parts);
			}

			// Per-stock data so each stock combination becomes its own CSV row.
			// attrNameById maps every attribute id (group or value) to its name.
			$attrNameById = array();
			foreach ($attr_rows as $row) { $attrNameById[(int) $row['id']] = (string) $row['name']; }

			$stock_rows = pjStockModel::factory()
				->select("t1.id, t1.product_id, t1.qty, t1.price")
				->whereIn('t1.product_id', $record)
				->orderBy("t1.id ASC")
				->findAll()->getData();
			$stocks_by_product = array();
			foreach ($stock_rows as $st) { $stocks_by_product[$st['product_id']][] = $st; }

			$sa_rows = pjStockAttributeModel::factory()
				->select("t1.stock_id, t1.attribute_id, t1.attribute_parent_id")
				->whereIn('t1.product_id', $record)
				->findAll()->getData();
			$combo_by_stock = array(); // stock id => array(group_id => value name)
			foreach ($sa_rows as $sa)
			{
				$vid = (int) $sa['attribute_id'];
				$combo_by_stock[(int) $sa['stock_id']][(int) $sa['attribute_parent_id']] =
					isset($attrNameById[$vid]) ? $attrNameById[$vid] : '';
			}

			// Attach product extras (add-ons). Single extras read as "Name (price)";
			// multi extras read as "Title: item1 (p1), item2 (p2)". Multiple extras
			// for a product are joined with " | " into a single "extras" column.
			$mlTable = pjMultiLangModel::factory()->getTable();
			$locale = $this->getLocaleId();
			$extra_rows = pjExtraModel::factory()
				->select(sprintf("t1.id, t1.product_id, t1.type, t1.price,
					(SELECT content FROM `%1\$s` WHERE model='pjExtra' AND foreign_id=t1.id AND field='extra_name' AND locale='%2\$s' LIMIT 1) AS name,
					(SELECT content FROM `%1\$s` WHERE model='pjExtra' AND foreign_id=t1.id AND field='extra_title' AND locale='%2\$s' LIMIT 1) AS title",
					$mlTable, $locale))
				->whereIn('t1.product_id', $record)
				->orderBy('t1.id ASC')
				->findAll()->getData();

			$extra_ids = array();
			foreach ($extra_rows as $row) { $extra_ids[] = (int) $row['id']; }

			$item_rows = array();
			if (!empty($extra_ids))
			{
				$item_rows = pjExtraItemModel::factory()
					->select("t1.id, t1.extra_id, t1.price, t2.content AS name")
					->join('pjMultiLang', "t2.model='pjExtraItem' AND t2.foreign_id=t1.id AND t2.field='extra_name' AND t2.locale='".$locale."'", 'left outer')
					->whereIn('t1.extra_id', $extra_ids)
					->orderBy('t1.price ASC')
					->findAll()->getData();
			}
			$items_by_extra = array();
			foreach ($item_rows as $it) { $items_by_extra[$it['extra_id']][] = $it; }

			$extras_by_product = array();
			foreach ($extra_rows as $row)
			{
				$label = '';
				if ($row['type'] === 'multi')
				{
					$parts = array();
					if (isset($items_by_extra[$row['id']]))
					{
						foreach ($items_by_extra[$row['id']] as $it)
						{
							$parts[] = trim($it['name'] . ' (' . pjCurrency::formatPrice($it['price']) . ')');
						}
					}
					$label = trim((string) $row['title']) . ': ' . implode(', ', $parts);
				}
				else
				{
					$label = trim((string) $row['name'] . ' (' . pjCurrency::formatPrice($row['price']) . ')');
				}
				$extras_by_product[$row['product_id']][] = $label;
			}

			// Start each export from a clean export-assets folder so copies never
			// accumulate (see pjPurgeExportFolder).
			$this->pjPurgeExportFolder();

			// Photos: full-size image URLs for each product (ordered).
			$img_rows = pjGalleryModel::factory()
				->select("t1.foreign_id, t1.large_path, t1.small_path")
				->whereIn('t1.foreign_id', $record)
				->orderBy('t1.sort ASC')
				->findAll()->getData();
			$photos_by_product = array();
			foreach ($img_rows as $im)
			{
				$path = !empty($im['large_path']) ? $im['large_path'] : $im['small_path'];
				if (empty($path)) { continue; }
				// Copy the image into a persistent export folder so the URL still
				// works after the product (and its own gallery files) is deleted.
				$persist = $this->pjExportPersistImage($path);
				$photos_by_product[$im['foreign_id']][] = PJ_INSTALL_URL . ($persist !== '' ? $persist : $path);
			}

			// Similar products (names).
			$sim_rows = pjProductSimilarModel::factory()
				->join('pjMultiLang', "t2.model='pjProduct' AND t2.foreign_id=t1.similar_id AND t2.field='name' AND t2.locale='".$locale."'", 'left outer')
				->select("t1.product_id, t2.content AS name")
				->whereIn('t1.product_id', $record)
				->findAll()->getData();
			$similar_by_product = array();
			foreach ($sim_rows as $sr)
			{
				if (!empty($sr['name'])) { $similar_by_product[$sr['product_id']][] = $sr['name']; }
			}

			foreach ($arr as $k => $v)
			{
				// The digital_file column holds an absolute server path internally; export a
				// usable admin download link instead (empty when the product has no file).
				$arr[$k]['digital_file'] = !empty($v['digital_file'])
					? PJ_INSTALL_URL . 'index.php?controller=pjAdminProducts&action=pjActionOpenDigital&id=' . (int) $v['id']
					: '';

				// Flatten rich-text descriptions to clean plain text (no HTML markup).
				$arr[$k]['short_description'] = trim(preg_replace('/\s+/', ' ',
					html_entity_decode(strip_tags((string) $v['short_description']), ENT_QUOTES, 'UTF-8')));
				$arr[$k]['full_description'] = trim(preg_replace('/\s+/', ' ',
					html_entity_decode(strip_tags((string) $v['full_description']), ENT_QUOTES, 'UTF-8')));

				$arr[$k]['attributes'] = isset($attributes_by_product[$v['id']])
					? $attributes_by_product[$v['id']]
					: '';

				$arr[$k]['extras'] = isset($extras_by_product[$v['id']])
					? implode(' | ', $extras_by_product[$v['id']])
					: '';

				$arr[$k]['photos'] = isset($photos_by_product[$v['id']])
					? implode(' | ', $photos_by_product[$v['id']])
					: '';

				$arr[$k]['similar_products'] = isset($similar_by_product[$v['id']])
					? implode(', ', $similar_by_product[$v['id']])
					: '';
			}

			// Expand each product into one row per stock combination. Product-level
			// columns are repeated on every row; attributes/price/in_stock reflect
			// that specific stock so the file round-trips the exact combinations.
			$out = array();
			foreach ($arr as $v)
			{
				$pid = (int) $v['id'];
				$stocks = isset($stocks_by_product[$pid]) ? $stocks_by_product[$pid] : array();

				if (empty($stocks))
				{
					$v['attributes'] = '';
					$out[] = $v;
					continue;
				}

				foreach ($stocks as $st)
				{
					$rowc = $v;
					$comboStr = '';
					if (isset($group_order[$pid]) && isset($combo_by_stock[(int) $st['id']]))
					{
						$parts = array();
						foreach ($group_order[$pid] as $gid)
						{
							if (isset($combo_by_stock[(int) $st['id']][$gid]))
							{
								$gname = trim($group_name[$gid]);
								if ($gname === '') { continue; }
								$parts[] = $gname . ': ' . $combo_by_stock[(int) $st['id']][$gid];
							}
						}
						$comboStr = implode(' | ', $parts);
					}
					$rowc['attributes'] = $comboStr;
					$rowc['price'] = number_format((float) $st['price'], 2, '.', '');
					$rowc['in_stock'] = (int) $st['qty'];
					$out[] = $rowc;
				}
			}

			$csv = new pjCSV();
			$csv
				->setHeader(true)
				->setName("Products-".time().".csv")
				->process($out)
				->download();
		}
		exit;
	}

	/**
	 * Copy a product gallery image into a persistent export folder
	 * (app/web/upload/export/) and return its relative path. Unlike the
	 * per-product gallery folders (source/small/medium/large), this folder is
	 * never touched when a product is deleted, so an exported CSV can still
	 * restore the images after the original products are gone. Returns '' on
	 * failure, in which case the caller falls back to the original path.
	 */
	private function pjExportPersistImage($path)
	{
		$src = trim((string) $path);
		if ($src === '') { return ''; }
		if (!is_file($src))
		{
			$src = PJ_INSTALL_PATH . ltrim($src, '/');
			if (!is_file($src)) { return ''; }
		}
		$dir = PJ_UPLOAD_PATH . 'export/';
		if (!is_dir($dir)) { @mkdir($dir, 0755, true); }
		$dst = $dir . basename($src);
		if (!is_file($dst) && !@copy($src, $dst)) { return ''; }
		return $dst;
	}

	/**
	 * Empty the export-assets folder (app/web/upload/export/). These are only
	 * transient copies created during export so an exported CSV can restore
	 * images on re-import. We clear them at the start of every export and again
	 * as soon as an import finishes, so the images never linger on the server.
	 */
	private function pjPurgeExportFolder()
	{
		$dir = PJ_UPLOAD_PATH . 'export/';
		if (!is_dir($dir)) { return; }
		foreach (glob($dir . '*') as $f) { if (is_file($f)) { @unlink($f); } }
	}

	/**
	 * Download a small, ready-to-import sample CSV that demonstrates every
	 * column and format (variants, extras, a digital product). Photos use this
	 * install's demo images so the sample imports cleanly on any setup.
	 */
	public function pjActionSampleCsv()
	{
		$this->checkLogin();
		if (!pjAuth::factory()->hasAccess())
		{
			$this->sendForbidden();
			return;
		}

		$demo = PJ_INSTALL_URL . 'app/web/img/demo/';
		// Variant product the recommended way: one row per stock combination,
		// the same SKU repeated, each row carrying its own qty and price. The
		// product-level columns (name, categories, extras, photos, ...) are the
		// same on every row; the importer reads them from the first row.
		$teeBase = array(
			'product_name'      => 'Sample T-Shirt',
			'sku'               => 'SAMPLE-TEE',
			'status'            => 'Available',
			'categories'        => 'Clothing | T-Shirts',
			'is_featured'       => 'Yes',
			'is_digital'        => 'No',
			'short_description' => 'A comfortable cotton t-shirt.',
			'full_description'  => 'Soft 100% cotton t-shirt, great for everyday wear.',
			'digital_file'      => '',
			'digital_name'      => '',
			'digital_expire'    => '',
			'price'             => '19.99',
			'in_stock'          => '0',
			'attributes'        => '',
			'extras'            => 'Gift Wrap (5.00) | Engraving: Initials (3.00), Full Name (6.00)',
			'photos'            => $demo . 'cloth-tshirt-blue.jpg | ' . $demo . 'cloth-tshirt-gray.jpg',
			'similar_products'  => 'Sample Coffee Mug',
		);
		// attributes | price | qty  -- four chosen combinations (not every
		// Size x Color pairing), to show you control the exact variants.
		$teeCombos = array(
			array('Size: S | Color: Black', '19.99', '30'),
			array('Size: S | Color: White', '19.99', '25'),
			array('Size: M | Color: Black', '21.99', '20'),
			array('Size: L | Color: Black', '23.99', '15'),
		);
		$rows = array();
		foreach ($teeCombos as $c)
		{
			$r = $teeBase;
			$r['attributes'] = $c[0];
			$r['price']      = $c[1];
			$r['in_stock']   = $c[2];
			$rows[] = $r;
		}

		// The remaining samples stay one row each. Mug and Tote use the
		// single-row shortcut (a group with several values expands to every
		// combination); E-Book is a digital product; Tote is hidden.
		$rows = array_merge($rows, array(
			// Single-row variant shortcut with a single extra and a similar link.
			array(
				'product_name'      => 'Sample Coffee Mug',
				'sku'               => 'SAMPLE-MUG',
				'status'            => 'Available',
				'categories'        => 'Home & Living',
				'is_featured'       => 'No',
				'is_digital'        => 'No',
				'short_description' => '11oz ceramic mug, dishwasher safe.',
				'full_description'  => 'A sturdy ceramic mug for everyday use.',
				'digital_file'      => '',
				'digital_name'      => '',
				'digital_expire'    => '',
				'price'             => '8.50',
				'in_stock'          => '50',
				'attributes'        => 'Size: 11oz, 15oz | Color: White, Black',
				'extras'            => 'Gift Wrap (5.00)',
				'photos'            => $demo . 'cloth-mug-white.jpg',
				'similar_products'  => 'Sample T-Shirt',
			),
			// Digital product: is_digital = Yes with a digital name + expiry,
			// no physical variants.
			array(
				'product_name'      => 'Sample E-Book',
				'sku'               => 'SAMPLE-EBOOK',
				'status'            => 'Available',
				'categories'        => 'Digital Goods',
				'is_featured'       => 'No',
				'is_digital'        => 'Yes',
				'short_description' => 'Instant-download e-book.',
				'full_description'  => 'A downloadable e-book delivered right after checkout.',
				'digital_file'      => '',
				'digital_name'      => 'sample-ebook.zip',
				'digital_expire'    => '12:00:00',
				'price'             => '12.00',
				'in_stock'          => '999',
				'attributes'        => '',
				'extras'            => '',
				'photos'            => $demo . 'ebook.jpg',
				'similar_products'  => '',
			),
			// Hidden product example (status Hidden), minimal but valid.
			array(
				'product_name'      => 'Sample Tote Bag',
				'sku'               => 'SAMPLE-TOTE',
				'status'            => 'Hidden',
				'categories'        => 'Accessories',
				'is_featured'       => 'No',
				'is_digital'        => 'No',
				'short_description' => 'Reusable canvas tote bag.',
				'full_description'  => 'A roomy canvas tote for shopping or events.',
				'digital_file'      => '',
				'digital_name'      => '',
				'digital_expire'    => '',
				'price'             => '12.50',
				'in_stock'          => '75',
				'attributes'        => 'Color: Natural, Black',
				'extras'            => '',
				'photos'            => $demo . 'cloth-tote-natural.jpg',
				'similar_products'  => '',
			),
		));

		$csv = new pjCSV();
		$csv->setHeader(true)->setName('products-sample.csv')->process($rows)->download();
		exit;
	}

	/**
	 * Import products from a CSV file (matching the export format).
	 * Validates each row, then upserts by SKU. Categories are auto-created.
	 */
	public function pjActionImportProduct()
	{
		$this->checkLogin();
		if (!pjAuth::factory()->hasAccess())
		{
			$this->sendForbidden();
			return;
		}

		// Load the same datagrid component the product list uses, so the
		// import results render in that grid (paged/sorted server-side).
		$this->appendJs('jquery.datagrid.js', PJ_FRAMEWORK_LIBS_PATH . 'pj/js/');

		$results = array();
		$summary = array('total' => 0, 'created' => 0, 'updated' => 0, 'failed' => 0);
		$import_error = '';

		if (self::isPost() && $this->_post->toInt('product_import'))
		{
			if (!isset($_FILES['csv_file']) || $_FILES['csv_file']['error'] !== UPLOAD_ERR_OK)
			{
				$import_error = __('import_err_file', true);
			}
			else
			{
				$ext = strtolower(pathinfo($_FILES['csv_file']['name'], PATHINFO_EXTENSION));
				if ($ext !== 'csv')
				{
					$import_error = __('import_err_ext', true);
				}
				else
				{
					$delimiter = $this->_post->check('csv_delimiter') ? $this->_post->toString('csv_delimiter') : 'auto';
					if (!in_array($delimiter, array('auto', ',', ';'), true)) { $delimiter = 'auto'; }
					$rows = $this->pjImportParseCsv($_FILES['csv_file']['tmp_name'], $delimiter);
					if ($rows === false)
					{
						$import_error = __('import_err_read', true);
					}
					elseif (empty($rows))
					{
						$import_error = __('import_err_empty', true);
					}
					elseif (($headerError = $this->pjImportCheckHeaders($rows, $delimiter, $_FILES['csv_file']['tmp_name'])) !== '')
					{
						$import_error = $headerError;
					}
					else
					{
						$locale_ids = $this->pjImportLocaleIds();
						$reserved = array('id', 'product_name', 'sku', 'status', 'categories',
							'is_featured', 'is_digital', 'short_description', 'full_description',
							'digital_file', 'digital_name', 'digital_expire', 'price', 'in_stock',
							'attributes', 'extras', 'photos', 'similar_products');

						$_SESSION['pj_import_seen_skus'] = array(); // for in-file SKU uniqueness
						$_SESSION['pj_import_sku_pid'] = array(); // sku => product id processed in this import (for variant append)
						$line = 1;
						foreach ($rows as $row)
						{
							$line++;
							$res = $this->pjImportProcessRow($row, $line, $locale_ids, $reserved);
							$summary['total']++;
							if ($res['state'] === 'created') { $summary['created']++; }
							elseif ($res['state'] === 'updated') { $summary['updated']++; }
							else { $summary['failed']++; }
							$results[] = $res;
						}
					}
				}
			}
		}

		if (!empty($results)) { $this->pjPurgeExportFolder(); } // import done -> drop transient export images

		$this->set('results', $results);
		$this->set('summary', $summary);
		$this->set('import_error', $import_error);
	}

	/**
	 * Batched CSV import (AJAX). The browser uploads the file once (offset 0),
	 * we parse it into the session, then process a small slice of rows per
	 * request so large files never hit the PHP time/memory limits. Each
	 * response reports progress so the UI can draw a live progress bar.
	 * Row-processing logic is shared with pjActionImportProduct via
	 * pjImportProcessRow() — only the batching wrapper is new.
	 */
	public function pjActionImportProductBatch()
	{
		$this->setAjax(true);

		if (!$this->isXHR())
		{
			pjAppController::jsonResponse(array('status' => 'ERR', 'code' => 100, 'text' => __('import_err_request', true)));
		}

		$this->checkLogin();
		if (!pjAuth::factory()->hasAccess())
		{
			pjAppController::jsonResponse(array('status' => 'ERR', 'code' => 102, 'text' => __('import_err_access', true)));
		}

		$limit = 5;
		$offset = (int) $this->_post->toInt('offset');

		// ---- first request: validate + parse the uploaded CSV into the session ----
		if ($offset === 0)
		{
			if (!isset($_FILES['csv_file']) || $_FILES['csv_file']['error'] !== UPLOAD_ERR_OK)
			{
				pjAppController::jsonResponse(array('status' => 'ERR', 'code' => 100, 'text' => __('import_err_file', true)));
			}
			$ext = strtolower(pathinfo($_FILES['csv_file']['name'], PATHINFO_EXTENSION));
			if ($ext !== 'csv')
			{
				pjAppController::jsonResponse(array('status' => 'ERR', 'code' => 100, 'text' => __('import_err_ext', true)));
			}
			$delimiter = $this->_post->check('csv_delimiter') ? $this->_post->toString('csv_delimiter') : 'auto';
			if (!in_array($delimiter, array('auto', ',', ';'), true)) { $delimiter = 'auto'; }
			$rows = $this->pjImportParseCsv($_FILES['csv_file']['tmp_name'], $delimiter);
			if ($rows === false)
			{
				pjAppController::jsonResponse(array('status' => 'ERR', 'code' => 100, 'text' => __('import_err_read', true)));
			}
			if (empty($rows))
			{
				pjAppController::jsonResponse(array('status' => 'ERR', 'code' => 100, 'text' => __('import_err_empty', true)));
			}
			$headerError = $this->pjImportCheckHeaders($rows, $delimiter, $_FILES['csv_file']['tmp_name']);
			if ($headerError !== '')
			{
				pjAppController::jsonResponse(array('status' => 'ERR', 'code' => 100, 'text' => $headerError));
			}
			$_SESSION['pj_import_rows'] = $rows;
			$_SESSION['pj_import_summary'] = array('total' => 0, 'created' => 0, 'updated' => 0, 'failed' => 0);
			$_SESSION['pj_import_results'] = array(); // kept after import so the datagrid can page through it
			$_SESSION['pj_import_seen_skus'] = array(); // for in-file SKU uniqueness
			$_SESSION['pj_import_sku_pid'] = array(); // sku => product id processed in this import (for variant append)
		}

		if (empty($_SESSION['pj_import_rows']))
		{
			pjAppController::jsonResponse(array('status' => 'ERR', 'code' => 100, 'text' => __('import_err_expired', true)));
		}

		$rows = $_SESSION['pj_import_rows'];
		$total = count($rows);

		$locale_ids = $this->pjImportLocaleIds();
		$reserved = array('id', 'product_name', 'sku', 'status', 'categories',
			'is_featured', 'is_digital', 'short_description', 'full_description',
			'digital_file', 'digital_name', 'digital_expire', 'price', 'in_stock',
			'attributes', 'extras', 'photos', 'similar_products');

		// ---- process this slice ----
		$slice = array_slice($rows, $offset, $limit);
		$batch_results = array();
		foreach ($slice as $idx => $row)
		{
			$line = $offset + $idx + 2; // +1 for the header row, +1 for 1-based numbering
			$res = $this->pjImportProcessRow($row, $line, $locale_ids, $reserved);
			$_SESSION['pj_import_summary']['total']++;
			if ($res['state'] === 'created') { $_SESSION['pj_import_summary']['created']++; }
			elseif ($res['state'] === 'updated') { $_SESSION['pj_import_summary']['updated']++; }
			else { $_SESSION['pj_import_summary']['failed']++; }
			$batch_results[] = $res;
			$_SESSION['pj_import_results'][] = $res;
		}

		$next = $offset + $limit;
		$processed = min($next, $total);
		$summary = $_SESSION['pj_import_summary'];

		// ---- more rows remain ----
		if ($next < $total)
		{
			pjAppController::jsonResponse(array(
				'status'    => 'OK',
				'code'      => 200,
				'offset'    => $next,
				'total'     => $total,
				'processed' => $processed,
				'batch'     => count($batch_results),
				'rows'      => $batch_results,
				'summary'   => $summary,
			));
		}

		// ---- finished: clear the session buffers + transient export images ----
		unset($_SESSION['pj_import_rows'], $_SESSION['pj_import_summary']);
		$this->pjPurgeExportFolder();

		pjAppController::jsonResponse(array(
			'status'    => 'DONE',
			'code'      => 200,
			'offset'    => $total,
			'total'     => $total,
			'processed' => $total,
			'batch'     => count($batch_results),
			'rows'      => $batch_results,
			'summary'   => $summary,
		));
	}

	/**
	 * Datagrid data source for the import results. Serves the rows stored in
	 * the session by the last import, honouring the same page / rowCount /
	 * column / direction parameters the product-list grid uses, and returns
	 * the identical JSON shape so the same datagrid component can render it.
	 */
	public function pjActionGetImportResult()
	{
		$this->setAjax(true);

		if ($this->isXHR())
		{
			$this->checkLogin();

			$results = (isset($_SESSION['pj_import_results']) && is_array($_SESSION['pj_import_results']))
				? $_SESSION['pj_import_results'] : array();

			// sorting
			$column = 'line';
			$direction = 'ASC';
			$allowed = array('line', 'sku', 'name', 'state', 'message');
			if ($this->_get->check('column') && $this->_get->check('direction')
				&& in_array($this->_get->toString('column'), $allowed, true)
				&& in_array(strtoupper($this->_get->toString('direction')), array('ASC', 'DESC'), true))
			{
				$column = $this->_get->toString('column');
				$direction = strtoupper($this->_get->toString('direction'));
			}
			$dir = $direction;
			usort($results, function ($a, $b) use ($column, $dir) {
				$va = isset($a[$column]) ? $a[$column] : '';
				$vb = isset($b[$column]) ? $b[$column] : '';
				$cmp = ($column === 'line') ? ((int) $va - (int) $vb) : strcasecmp((string) $va, (string) $vb);
				return $dir === 'DESC' ? -$cmp : $cmp;
			});

			// paging (default 10 to match the grid's "Show" selector)
			$total = count($results);
			$rowCount = $this->_get->check('rowCount') && $this->_get->toInt('rowCount') > 0 ? $this->_get->toInt('rowCount') : 10;
			$pages = $rowCount > 0 ? (int) ceil($total / $rowCount) : 1;
			$page = $this->_get->check('page') && $this->_get->toInt('page') > 0 ? $this->_get->toInt('page') : 1;
			if ($pages > 0 && $page > $pages) { $page = $pages; }
			$offset = ((int) $page - 1) * $rowCount;
			if ($offset < 0) { $offset = 0; }

			$data = array_slice($results, $offset, $rowCount);
			foreach ($data as $k => $v)
			{
				$data[$k]['line']    = (int) (isset($v['line']) ? $v['line'] : 0);
				$data[$k]['sku']     = pjSanitize::clean(isset($v['sku']) ? $v['sku'] : '');
				$data[$k]['name']    = pjSanitize::clean(isset($v['name']) ? $v['name'] : '');
				$data[$k]['state']   = isset($v['state']) ? $v['state'] : '';
				$data[$k]['message'] = pjSanitize::clean(isset($v['message']) ? $v['message'] : '');
			}

			pjAppController::jsonResponse(compact('data', 'total', 'pages', 'page', 'rowCount', 'column', 'direction'));
		}
		exit;
	}

	private function pjImportProcessRow($row, $line, $locale_ids, $reserved)
	{
			$row = array_map('trim', $row);

			$name = isset($row['product_name']) ? $row['product_name'] : '';
			$sku  = isset($row['sku']) ? $row['sku'] : '';

			// A SKU that already appeared earlier in THIS import is an additional
			// stock combination (variant) for the same product, not a duplicate.
			if (!isset($_SESSION['pj_import_sku_pid']) || !is_array($_SESSION['pj_import_sku_pid'])) { $_SESSION['pj_import_sku_pid'] = array(); }
			$firstForSku = ($sku === '' || !isset($_SESSION['pj_import_sku_pid'][$sku]));

			// ---------- validation ----------
			$errors = array();
			if ($sku === '')  { $errors[] = __('import_err_sku', true); }
			if ($firstForSku && $name === '') { $errors[] = __('import_err_name', true); }

			$status = 1;
			$statusRaw = isset($row['status']) ? strtolower($row['status']) : '';
			if ($statusRaw === '' || in_array($statusRaw, array('available', 'active', '1'), true)) { $status = 1; }
			elseif (in_array($statusRaw, array('hidden', 'inactive', 'disabled', '2', '0'), true)) { $status = 2; }
			else { $errors[] = str_replace('{VALUE}', $row['status'], __('import_err_status', true)); }

			$is_featured = $this->pjImportBool(isset($row['is_featured']) ? $row['is_featured'] : '');
			$is_digital  = $this->pjImportBool(isset($row['is_digital']) ? $row['is_digital'] : '');

			$price = 0;
			$priceRaw = isset($row['price']) ? $row['price'] : '';
			if ($priceRaw !== '')
			{
				$priceClean = preg_replace('/[^0-9.]/', '', $priceRaw);
				if ($priceClean === '' || !is_numeric($priceClean)) { $errors[] = str_replace('{VALUE}', $priceRaw, __('import_err_price', true)); }
				else { $price = (float) $priceClean; }
			}

			$qty = 0;
			$stockRaw = isset($row['in_stock']) ? $row['in_stock'] : '';
			if ($stockRaw !== '')
			{
				$stockClean = preg_replace('/[^0-9]/', '', $stockRaw);
				if ($stockClean === '' || !ctype_digit($stockClean)) { $errors[] = str_replace('{VALUE}', $stockRaw, __('import_err_stock', true)); }
				else { $qty = (int) $stockClean; }
			}

			// Same required fields the product create/update form enforces:
			// at least one category and a short description.
			$catRaw = isset($row['categories']) ? trim($row['categories']) : '';
			if ($firstForSku && $catRaw === '') { $errors[] = __('import_err_category', true); }

			$shortRaw = isset($row['short_description']) ? trim($row['short_description']) : '';
			if ($firstForSku && $shortRaw === '') { $errors[] = __('import_err_short', true); }

			if (!empty($errors))
			{
				return array('line' => $line, 'sku' => $sku, 'name' => $name, 'state' => 'error', 'message' => implode('; ', $errors));
			}

			if (!isset($_SESSION['pj_import_seen_skus']) || !is_array($_SESSION['pj_import_seen_skus'])) { $_SESSION['pj_import_seen_skus'] = array(); }
			$_SESSION['pj_import_seen_skus'][$sku] = $line;

			// Additional stock combination for a product already created/updated in
			// this import: append the variant (creating any new attribute value as
			// needed) instead of rebuilding the whole product.
			if (!$firstForSku)
			{
				$pidExisting = (int) $_SESSION['pj_import_sku_pid'][$sku];
				$added = $this->pjImportAppendCombination($row, $pidExisting, $locale_ids, $reserved, $qty, $price);
				$lbl = __('import_detail_variant_added', true);
				if (empty($lbl) || strpos($lbl, 'import_detail_variant_added') !== false) { $lbl = '{COUNT} stock variant(s) added.'; }
				return array('line' => $line, 'sku' => $sku, 'name' => $name, 'state' => 'updated', 'message' => str_replace('{COUNT}', (string) $added, $lbl));
			}

			// ---------- upsert by SKU ----------
			$existing = pjProductModel::factory()->where('t1.sku', $sku)->limit(1, 0)->findAll()->getData();
			$isUpdate = !empty($existing);
			$pid = $isUpdate ? (int) $existing[0]['id'] : 0;

			$attrs = array(
				'sku' => $sku,
				'status' => $status,
				'is_featured' => $is_featured,
				'is_digital' => $is_digital,
			);
			if ($is_digital !== 1)
			{
				$attrs['digital_file'] = ':NULL';
				$attrs['digital_name'] = ':NULL';
				$attrs['digital_expire'] = ':NULL';
			}
			if ($is_digital === 1)
			{
				if (isset($row['digital_name']) && $row['digital_name'] !== '') { $attrs['digital_name'] = $row['digital_name']; }
				if (isset($row['digital_expire']) && $row['digital_expire'] !== '') { $attrs['digital_expire'] = $row['digital_expire']; }
				if (isset($row['digital_file']) && trim($row['digital_file']) !== '')
				{
					$digital = $this->pjImportAddDigitalFile($row['digital_file'], isset($row['digital_name']) ? $row['digital_name'] : '');
					if ($digital !== false)
					{
						$attrs['digital_file'] = $digital['path'];
						$attrs['digital_name'] = $digital['name'];
					}
				}
			}

			if ($isUpdate)
			{
				pjProductModel::factory()->set('id', $pid)->modify($attrs);
				// Clear existing relations so the import fully replaces them.
				pjMultiLangModel::factory()->where('model', 'pjProduct')->where('foreign_id', $pid)->eraseAll();
				pjProductCategoryModel::factory()->where('product_id', $pid)->eraseAll();
				$this->pjActionDeleteProductAttr($pid);
				$this->pjActionDeleteStockAttr($pid);
				pjStockModel::factory()->where('product_id', $pid)->eraseAll();
				$this->pjImportDeleteExtras($pid);
				// NOTE: existing gallery images are intentionally NOT deleted here.
				// On an update the photos URLs (e.g. from an export) can point to the
				// product's own current images, so we must download the new images first
				// and only then remove the old ones (handled in the photos block below).
				pjProductSimilarModel::factory()->where('product_id', $pid)->eraseAll();
			}
			else
			{
				$pid = (int) pjProductModel::factory()->setAttributes($attrs)->insert()->getInsertId();
				if ($pid <= 0)
				{
					return array('line' => $line, 'sku' => $sku, 'name' => $name, 'state' => 'error', 'message' => __('import_err_insert', true));
				}
			}

			// Remember this product for the rest of the import so later rows with
			// the same SKU append their stock combination to it.
			$_SESSION['pj_import_sku_pid'][$sku] = $pid;

			// ---------- name + descriptions ----------
			$short = isset($row['short_description']) ? $row['short_description'] : '';
			$full  = isset($row['full_description']) ? $row['full_description'] : '';
			$i18n = array();
			foreach ($locale_ids as $lid) { $i18n[$lid] = array('name' => $name, 'short_desc' => $short, 'full_desc' => $full); }
			pjMultiLangModel::factory()->saveMultiLang($i18n, $pid, 'pjProduct', 'data');

			// counters for the per-row "Details" summary
			$cntCategories = $cntExtras = $cntImages = $cntSimilar = 0;

			// ---------- categories (auto-create) ----------
			if (isset($row['categories']) && $row['categories'] !== '')
			{
				foreach (preg_split('/\s*[|,]\s*/', $row['categories']) as $cname)
				{
					$cname = trim($cname);
					if ($cname === '') { continue; }
					$cid = $this->pjImportFindOrCreateCategory($cname, $locale_ids);
					if ($cid > 0)
					{
						pjProductCategoryModel::factory()->reset()->set('product_id', $pid)->set('category_id', $cid)->insert();
						$cntCategories++;
					}
				}
			}

			// ---------- attributes ----------
			// Primary, documented format: a single "attributes" column shaped like
			// the extras column, e.g.  "Size: S, M, L | Color: Red, Blue".
			// Groups are separated by "|", each group is "Name: value, value, ...".
			// Backward compatibility: if there is no "attributes" column, any extra
			// (non-reserved) column header is still treated as an attribute group.
			$groups = array();      // group_id => array(item_id, ...)
			$item_group = array();  // item_id  => group_id

			$attrSource = array();  // ordered list of array('name' => groupName, 'items' => array(values))
			if (isset($row['attributes']))
			{
				foreach (preg_split('/\s*\|\s*/', (string) $row['attributes']) as $part)
				{
					$part = trim($part);
					if ($part === '') { continue; }
					$colon = strpos($part, ':');
					if ($colon === false) { continue; } // need "Name: values"
					$gname = trim(substr($part, 0, $colon));
					if ($gname === '') { continue; }
					$items = array();
					foreach (preg_split('/\s*,\s*/', substr($part, $colon + 1)) as $iv)
					{
						$iv = trim($iv);
						if ($iv !== '') { $items[] = $iv; }
					}
					$attrSource[] = array('name' => $gname, 'items' => $items);
				}
			}
			else
			{
				foreach ($row as $col => $val)
				{
					if (in_array($col, $reserved, true)) { continue; }
					$val = trim((string) $val);
					if ($val === '') { continue; }
					$items = array();
					foreach (preg_split('/\s*,\s*/', $val) as $iv)
					{
						$iv = trim($iv);
						if ($iv !== '') { $items[] = $iv; }
					}
					$attrSource[] = array('name' => $col, 'items' => $items);
				}
			}

			$gIndex = 0;
			foreach ($attrSource as $grp)
			{
				if (empty($grp['items'])) { continue; }
				$gid = (int) pjAttributeModel::factory()->reset()
					->setAttributes(array('product_id' => $pid, 'parent_id' => 0, 'order_group' => $gIndex, 'order_item' => 0))
					->insert()->getInsertId();
				if ($gid <= 0) { continue; }
				$gi = array(); foreach ($locale_ids as $lid) { $gi[$lid] = array('name' => $grp['name']); }
				pjMultiLangModel::factory()->saveMultiLang($gi, $gid, 'pjAttribute');
				$iIndex = 0;
				$seenItems = array(); // de-duplicate values within a group so stock combinations never overlap
				foreach ($grp['items'] as $iv)
				{
					$ivKey = mb_strtolower($iv);
					if (isset($seenItems[$ivKey])) { continue; }
					$seenItems[$ivKey] = true;
					$iid = (int) pjAttributeModel::factory()->reset()
						->setAttributes(array('product_id' => $pid, 'parent_id' => $gid, 'order_group' => $gIndex, 'order_item' => $iIndex))
						->insert()->getInsertId();
					if ($iid <= 0) { continue; }
					$ii = array(); foreach ($locale_ids as $lid) { $ii[$lid] = array('name' => $iv); }
					pjMultiLangModel::factory()->saveMultiLang($ii, $iid, 'pjAttribute');
					$groups[$gid][] = $iid;
					$item_group[$iid] = $gid;
					$iIndex++;
				}
				$gIndex++;
			}

			// ---------- stock (one row per attribute combination) ----------
			$createdStockIds = array();
			if (empty($groups))
			{
				$sid = (int) pjStockModel::factory()->reset()
					->setAttributes(array('product_id' => $pid, 'qty' => $qty, 'price' => $price))
					->insert()->getInsertId();
				if ($sid > 0) { $createdStockIds[] = $sid; }
			}
			else
			{
				$combos = array(array());
				foreach ($groups as $gid => $items)
				{
					$next = array();
					foreach ($combos as $combo)
					{
						foreach ($items as $iid) { $c = $combo; $c[] = $iid; $next[] = $c; }
					}
					$combos = $next;
				}
				foreach ($combos as $combo)
				{
					$sid = (int) pjStockModel::factory()->reset()
						->setAttributes(array('product_id' => $pid, 'qty' => $qty, 'price' => $price))
						->insert()->getInsertId();
					if ($sid <= 0) { continue; }
					$createdStockIds[] = $sid;
					foreach ($combo as $iid)
					{
						pjStockAttributeModel::factory()->reset()
							->setAttributes(array('stock_id' => $sid, 'product_id' => $pid, 'attribute_id' => $iid, 'attribute_parent_id' => $item_group[$iid]))
							->insert();
					}
				}
			}

			// ---------- extras ----------
			if (isset($row['extras']) && trim($row['extras']) !== '')
			{
				foreach (preg_split('/\s*\|\s*/', $row['extras']) as $part)
				{
					$part = trim($part);
					if ($part === '') { continue; }
					$colon = strpos($part, ': ');
					$paren = strpos($part, '(');
					$isMulti = ($colon !== false && ($paren === false || $colon < $paren));
					if ($isMulti)
					{
						$title = trim(substr($part, 0, $colon));
						$itemsStr = substr($part, $colon + 2);
						$eid = (int) pjExtraModel::factory()->reset()
							->setAttributes(array('product_id' => $pid, 'type' => 'multi', 'price' => 0, 'is_mandatory' => 0))
							->insert()->getInsertId();
						if ($eid <= 0) { continue; }
						$cntExtras++;
						$te = array(); foreach ($locale_ids as $lid) { $te[$lid] = array('extra_title' => $title); }
						pjMultiLangModel::factory()->saveMultiLang($te, $eid, 'pjExtra');
						foreach (preg_split('/,\s*/', $itemsStr) as $it)
						{
							$pt = $this->pjImportParsePriceToken(trim($it));
							if ($pt['name'] === '') { continue; }
							$iid = (int) pjExtraItemModel::factory()->reset()
								->setAttributes(array('extra_id' => $eid, 'price' => $pt['price']))
								->insert()->getInsertId();
							if ($iid <= 0) { continue; }
							$ie = array(); foreach ($locale_ids as $lid) { $ie[$lid] = array('extra_name' => $pt['name']); }
							pjMultiLangModel::factory()->saveMultiLang($ie, $iid, 'pjExtraItem');
						}
					}
					else
					{
						$pt = $this->pjImportParsePriceToken($part);
						if ($pt['name'] === '') { continue; }
						$eid = (int) pjExtraModel::factory()->reset()
							->setAttributes(array('product_id' => $pid, 'type' => 'single', 'price' => $pt['price'], 'is_mandatory' => 0))
							->insert()->getInsertId();
						if ($eid <= 0) { continue; }
						$cntExtras++;
						$se = array(); foreach ($locale_ids as $lid) { $se[$lid] = array('extra_name' => $pt['name']); }
						pjMultiLangModel::factory()->saveMultiLang($se, $eid, 'pjExtra');
					}
				}
			}

			// ---------- photos (download image URLs into the gallery) ----------
			// Only touch images when the photos column actually has URLs. Download
			// the new images first (the URLs may reference the product's CURRENT
			// images, which must still exist on disk), then remove the old ones.
			$firstImageId = 0;
			if (isset($row['photos']) && trim($row['photos']) !== '')
			{
				$oldImages = pjGalleryModel::factory()
					->where('foreign_id', $pid)->where('model', 'pjProduct')
					->findAll()->getData();

				$psort = 1;
				$addedImages = 0;
				foreach (preg_split('/\s*\|\s*/', $row['photos']) as $purl)
				{
					$purl = trim($purl);
					if ($purl === '') { continue; }
					$newImageId = (int) $this->pjImportAddImage($pid, $purl, $psort);
					if ($newImageId > 0)
					{
						if ($firstImageId === 0) { $firstImageId = $newImageId; }
						$psort++;
						$addedImages++;
					}
				}

				$cntImages = $addedImages;

				// Replace the old images only if at least one new image came in,
				// so a temporary download failure never wipes existing photos.
				if ($addedImages > 0 && !empty($oldImages))
				{
					$oldIds = array();
					foreach ($oldImages as $im)
					{
						$oldIds[] = (int) $im['id'];
						foreach (array('small_path', 'medium_path', 'large_path', 'source_path') as $f)
						{
							if (!empty($im[$f]) && is_file($im[$f])) { @unlink($im[$f]); }
						}
					}
					if (!empty($oldIds)) { pjGalleryModel::factory()->whereIn('id', $oldIds)->eraseAll(); }
				}
			}

			// ---------- assign a primary image to the stock rows ----------
			// The product list, cart and storefront pull each line's thumbnail
			// from stock.image_id (not the gallery directly). Stock rows are
			// recreated on every import, so point them at the product's primary
			// image: the first one we just imported, or - if this row carried no
			// photos - the product's first existing gallery image.
			$primaryImageId = $firstImageId;
			if ($primaryImageId <= 0)
			{
				$gal = pjGalleryModel::factory()
					->where('foreign_id', $pid)->where('model', 'pjProduct')
					->orderBy('t1.sort ASC, t1.id ASC')
					->limit(1, 0)->findAll()->getData();
				if (!empty($gal)) { $primaryImageId = (int) $gal[0]['id']; }
			}
			if ($primaryImageId > 0 && !empty($createdStockIds))
			{
				foreach ($createdStockIds as $stockId)
				{
					pjStockModel::factory()->reset()->set('id', $stockId)->modify(array('image_id' => $primaryImageId));
				}
			}

			// ---------- similar products (match by name) ----------
			if (isset($row['similar_products']) && trim($row['similar_products']) !== '')
			{
				foreach (preg_split('/\s*,\s*/', $row['similar_products']) as $sname)
				{
					$sname = trim($sname);
					if ($sname === '') { continue; }
					$sid = $this->pjImportFindProductByName($sname);
					if ($sid > 0 && $sid !== $pid)
					{
						pjProductSimilarModel::factory()->reset()
							->set('product_id', $pid)->set('similar_id', $sid)->insert();
						$cntSimilar++;
					}
				}
			}

			// ---------- per-row "Details" summary (only the parts that have data) ----------
			$cntVariants = count($createdStockIds);
			$cntAttr = count($groups);
			$detailParts = array();
			if ($cntVariants > 0)   { $detailParts[] = str_replace('{COUNT}', $cntVariants, __('import_detail_variants', true)); }
			if ($cntAttr > 0)       { $detailParts[] = str_replace('{COUNT}', $cntAttr, __('import_detail_attributes', true)); }
			if ($cntImages > 0)     { $detailParts[] = str_replace('{COUNT}', $cntImages, __('import_detail_images', true)); }
			if ($cntExtras > 0)     { $detailParts[] = str_replace('{COUNT}', $cntExtras, __('import_detail_extras', true)); }
			if ($cntCategories > 0) { $detailParts[] = str_replace('{COUNT}', $cntCategories, __('import_detail_categories', true)); }
			if ($cntSimilar > 0)    { $detailParts[] = str_replace('{COUNT}', $cntSimilar, __('import_detail_similar', true)); }
			$detail = implode(' · ', $detailParts);

			return array('line' => $line, 'sku' => $sku, 'name' => $name,
				'state' => $isUpdate ? 'updated' : 'created', 'message' => $detail);
	}

	private function pjImportParseCsv($file, $delimiter = ',')
	{
		if (!is_file($file)) { return false; }
		$handle = @fopen($file, 'r');
		if ($handle === false) { return false; }
		if ($delimiter === 'auto' || $delimiter === '' || $delimiter === null)
		{
			$delimiter = $this->pjImportDetectDelimiter($file);
		}
		$rows = array();
		$keys = null;
		while (($values = fgetcsv($handle, 0, $delimiter)) !== FALSE)
		{
			if ($values === array(null)) { continue; } // blank line
			if ($keys === null) { $keys = array_map('trim', $values); continue; }
			$n = count($keys);
			if (count($values) < $n) { $values = array_pad($values, $n, ''); }
			elseif (count($values) > $n) { $values = array_slice($values, 0, $n); }
			$rows[] = array_combine($keys, $values);
		}
		fclose($handle);
		return $rows;
	}

	/**
	 * Guess the column delimiter from the header line when the user chose
	 * "auto". Looks at the first non-empty line and picks whichever of
	 * comma / semicolon / tab / pipe appears most often.
	 */
	private function pjImportDetectDelimiter($file)
	{
		$line = '';
		$h = @fopen($file, 'r');
		if ($h !== false)
		{
			while (($l = fgets($h)) !== false)
			{
				if (trim($l) !== '') { $line = $l; break; }
			}
			fclose($h);
		}
		$candidates = array(',' => 0, ';' => 0, "\t" => 0, '|' => 0);
		foreach ($candidates as $d => $cnt)
		{
			$candidates[$d] = substr_count($line, $d);
		}
		arsort($candidates);
		$best = key($candidates);
		return $candidates[$best] > 0 ? $best : ',';
	}

	/**
	 * After parsing, make sure the header actually produced the expected
	 * columns. If 'product_name' is missing the user almost always picked the
	 * wrong column separator (e.g. forcing ';' on a comma file), which would
	 * otherwise fail every single row with a useless "required" message.
	 * Returns '' when the header looks fine, or a clear message otherwise.
	 */
	private function pjImportCheckHeaders($rows, $delimiter, $file)
	{
		if (empty($rows) || !is_array($rows[0])) { return ''; }
		$keys = array_keys($rows[0]);
		if (in_array('product_name', $keys, true)) { return ''; }

		$detected = $this->pjImportDetectDelimiter($file);
		if ($delimiter !== 'auto' && $detected !== $delimiter)
		{
			return str_replace(
				array('{DETECTED}', '{SELECTED}'),
				array($this->pjImportDelimiterName($detected), $this->pjImportDelimiterName($delimiter)),
				__('import_err_headers', true)
			);
		}
		return __('import_err_headers_auto', true);
	}

	private function pjImportDelimiterName($d)
	{
		switch ($d)
		{
			case ',':  return __('import_delimiter_comma', true);
			case ';':  return __('import_delimiter_semicolon', true);
			case "\t": return 'Tab';
			case '|':  return 'Pipe ( | )';
			default:   return $d;
		}
	}

	private function pjImportBool($v)
	{
		$v = strtolower(trim((string) $v));
		return in_array($v, array('1', 'yes', 'y', 'true', 'on'), true) ? 1 : 0;
	}

	private function pjImportLocaleIds()
	{
		$ids = pjLocaleModel::factory()->select('t1.id')->findAll()->getDataPair(null, 'id');
		$ids = array_values(array_filter(array_map('intval', (array) $ids)));
		if (empty($ids)) { $ids = array((int) $this->getLocaleId()); }
		return $ids;
	}

	private function pjImportFindOrCreateCategory($name, $locale_ids)
	{
		$ml = pjMultiLangModel::factory()
			->select('t1.foreign_id')
			->where('t1.model', 'pjCategory')
			->where('t1.field', 'name')
			->where('t1.content', $name)
			->limit(1, 0)->findAll()->getData();
		if (!empty($ml)) { return (int) $ml[0]['foreign_id']; }

		// Create as a child of the root category (id 1, "Products"), matching the
		// admin "No parent" option (value 1). Categories must be children of the
		// root or getNode(locale, 1) - used by the admin and storefront - will not
		// list them.
		$cid = (int) pjCategoryModel::factory()->saveNode(array('parent_id' => 1), 1);
		if ($cid > 0)
		{
			$i18n = array();
			foreach ($locale_ids as $lid) { $i18n[$lid] = array('name' => $name); }
			pjMultiLangModel::factory()->saveMultiLang($i18n, $cid, 'pjCategory', 'data');
		}
		return $cid;
	}

	private function pjImportDeleteExtras($pid)
	{
		$extra_ids = pjExtraModel::factory()->where('product_id', $pid)->findAll()->getDataPair(null, 'id');
		if (empty($extra_ids)) { return; }
		$item_ids = pjExtraItemModel::factory()->whereIn('extra_id', $extra_ids)->findAll()->getDataPair(null, 'id');
		pjExtraModel::factory()->where('product_id', $pid)->eraseAll();
		pjMultiLangModel::factory()->where('model', 'pjExtra')->whereIn('foreign_id', $extra_ids)->eraseAll();
		if (!empty($item_ids))
		{
			pjExtraItemModel::factory()->whereIn('extra_id', $extra_ids)->eraseAll();
			pjMultiLangModel::factory()->where('model', 'pjExtraItem')->whereIn('foreign_id', $item_ids)->eraseAll();
		}
	}

	private function pjImportParsePriceToken($token)
	{
		$name = trim((string) $token);
		$price = 0;
		if (preg_match('/^(.*?)\s*\(\s*([0-9]+(?:\.[0-9]+)?)/', $token, $m))
		{
			$name = trim($m[1]);
			$price = (float) $m[2];
		}
		return array('name' => $name, 'price' => $price);
	}

	private function pjImportFindProductByName($name)
	{
		$ml = pjMultiLangModel::factory()
			->select('t1.foreign_id')
			->where('t1.model', 'pjProduct')
			->where('t1.field', 'name')
			->where('t1.content', $name)
			->limit(1, 0)->findAll()->getData();
		return !empty($ml) ? (int) $ml[0]['foreign_id'] : 0;
	}

	private function pjImportImageSizes()
	{
		$o = isset($this->option_arr) && is_array($this->option_arr) ? $this->option_arr : array();
		$dim = function ($k, $d) use ($o) { return isset($o[$k]) && (int) $o[$k] > 0 ? (int) $o[$k] : $d; };
		$crop = function ($k, $d) use ($o) {
			if (empty($o[$k])) { return $d; }
			$v = $o[$k];
			if (strpos($v, '::') !== false) { $p = explode('::', $v); $v = end($p); }
			return in_array($v, array('crop', 'resize'), true) ? ($v === 'crop') : $d;
		};
		return array(
			'small'  => array('w' => $dim('o_thumb_width', 90),  'h' => $dim('o_thumb_height', 68),  'crop' => $crop('o_thumb_crop', true)),
			'medium' => array('w' => $dim('o_medium_width', 215), 'h' => $dim('o_medium_height', 161), 'crop' => $crop('o_medium_crop', true)),
			'large'  => array('w' => $dim('o_large_width', 800),  'h' => $dim('o_large_height', 800),  'crop' => $crop('o_large_crop', false)),
		);
	}

	private function pjImportFetchUrl($url)
	{
		$data = false;
		if (function_exists('curl_init'))
		{
			$ch = curl_init($url);
			curl_setopt_array($ch, array(
				CURLOPT_RETURNTRANSFER => true,
				CURLOPT_FOLLOWLOCATION => true,
				CURLOPT_TIMEOUT => 25,
				CURLOPT_SSL_VERIFYPEER => false,
				CURLOPT_SSL_VERIFYHOST => 0,
				CURLOPT_USERAGENT => 'pjShoppingCart-Import',
			));
			$data = curl_exec($ch);
			$code = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE);
			curl_close($ch);
			if ($data === false || $code >= 400) { $data = false; }
		}
		if ($data === false && ini_get('allow_url_fopen'))
		{
			$ctx = stream_context_create(array(
				'http'  => array('timeout' => 25, 'user_agent' => 'pjShoppingCart-Import', 'follow_location' => 1),
				'ssl'   => array('verify_peer' => false, 'verify_peer_name' => false),
			));
			$data = @file_get_contents($url, false, $ctx);
		}
		return $data;
	}

	private function pjImportAddDigitalFile($file, $name = '')
	{
		$file = trim((string) $file);
		if ($file === '') { return false; }

		$bytes = false;
		$sourceName = trim((string) $name);

		// Exported digital files from this same admin are download links. Copy the
		// original server file directly so re-import does not depend on an HTTP
		// session cookie.
		$query = parse_url($file, PHP_URL_QUERY);
		if ($query)
		{
			parse_str($query, $parts);
			if (isset($parts['controller'], $parts['action'], $parts['id'])
				&& $parts['controller'] === 'pjAdminProducts'
				&& $parts['action'] === 'pjActionOpenDigital')
			{
				$product = pjProductModel::factory()->find((int) $parts['id'])->getData();
				if (!empty($product['digital_file']) && is_file($product['digital_file']))
				{
					$bytes = @file_get_contents($product['digital_file']);
					if ($sourceName === '' && !empty($product['digital_name']))
					{
						$sourceName = $product['digital_name'];
					}
				}
			}
		}

		if ($bytes === false)
		{
			if (preg_match('/^https?:\/\//i', $file))
			{
				$bytes = $this->pjImportFetchUrl($file);
				if ($sourceName === '')
				{
					$path = parse_url($file, PHP_URL_PATH);
					$sourceName = $path ? basename($path) : '';
				}
			}
			else
			{
				$src = is_file($file) ? $file : PJ_INSTALL_PATH . ltrim($file, '/');
				if (is_file($src))
				{
					$bytes = @file_get_contents($src);
					if ($sourceName === '') { $sourceName = basename($src); }
				}
			}
		}

		if ($bytes === false || strlen($bytes) === 0) { return false; }
		if ($sourceName === '') { $sourceName = 'digital-file'; }

		$ext = pathinfo($sourceName, PATHINFO_EXTENSION);
		if ($ext === '') { $ext = 'bin'; }
		$dir = PJ_UPLOAD_PATH . 'digital/';
		if (!is_dir($dir)) { @mkdir($dir, 0755, true); }
		$dst = $dir . md5(uniqid(rand(), true)) . '.' . $ext;
		if (@file_put_contents($dst, $bytes) === false) { return false; }

		return array('path' => $dst, 'name' => $sourceName);
	}

	/**
	 * Append one stock combination (variant) to a product that was already
	 * created/updated earlier in the same import. Reuses the product's existing
	 * attribute groups/values (created by earlier rows) and only adds genuinely
	 * new values, then creates the stock row(s) for this row's combination.
	 * Returns the number of stock rows created.
	 */
	private function pjImportAppendCombination($row, $pid, $locale_ids, $reserved, $qty, $price)
	{
		$pid = (int) $pid;
		if ($pid <= 0) { return 0; }

		// Parse this row's attribute groups/values (same format as the main path).
		$attrSource = array();
		if (isset($row['attributes']))
		{
			foreach (preg_split('/\s*\|\s*/', (string) $row['attributes']) as $part)
			{
				$part = trim($part);
				if ($part === '') { continue; }
				$colon = strpos($part, ':');
				if ($colon === false) { continue; }
				$gname = trim(substr($part, 0, $colon));
				if ($gname === '') { continue; }
				$items = array();
				foreach (preg_split('/\s*,\s*/', substr($part, $colon + 1)) as $iv)
				{
					$iv = trim($iv);
					if ($iv !== '') { $items[] = $iv; }
				}
				$attrSource[] = array('name' => $gname, 'items' => $items);
			}
		}
		else
		{
			foreach ($row as $col => $val)
			{
				if (in_array($col, $reserved, true)) { continue; }
				$val = trim((string) $val);
				if ($val === '') { continue; }
				$items = array();
				foreach (preg_split('/\s*,\s*/', $val) as $iv) { $iv = trim($iv); if ($iv !== '') { $items[] = $iv; } }
				$attrSource[] = array('name' => $col, 'items' => $items);
			}
		}

		// Load the product's current attribute groups + values to reuse them.
		$locale = $this->getLocaleId();
		$existing = pjAttributeModel::factory()
			->select("t1.id, t1.parent_id, t1.order_group, t1.order_item, t2.content AS name")
			->join('pjMultiLang', "t2.model='pjAttribute' AND t2.foreign_id=t1.id AND t2.field='name' AND t2.locale='".$locale."'", 'left outer')
			->where('t1.product_id', $pid)
			->orderBy("t1.order_group ASC, t1.order_item ASC")
			->findAll()->getData();

		$groupByName = array(); // lower(name) => array('id','order','items'=>array(lower(val)=>vid),'maxItem')
		$idByGroup = array();   // gid => lower(name)
		$maxOrderGroup = -1;
		foreach ($existing as $r)
		{
			if ((int) $r['parent_id'] === 0)
			{
				$key = mb_strtolower(trim((string) $r['name']));
				$groupByName[$key] = array('id' => (int) $r['id'], 'order' => (int) $r['order_group'], 'items' => array(), 'maxItem' => -1);
				$idByGroup[(int) $r['id']] = $key;
				if ((int) $r['order_group'] > $maxOrderGroup) { $maxOrderGroup = (int) $r['order_group']; }
			}
		}
		foreach ($existing as $r)
		{
			if ((int) $r['parent_id'] !== 0 && isset($idByGroup[(int) $r['parent_id']]))
			{
				$gkey = $idByGroup[(int) $r['parent_id']];
				$vkey = mb_strtolower(trim((string) $r['name']));
				$groupByName[$gkey]['items'][$vkey] = (int) $r['id'];
				if ((int) $r['order_item'] > $groupByName[$gkey]['maxItem']) { $groupByName[$gkey]['maxItem'] = (int) $r['order_item']; }
			}
		}

		$rowGroups = array(); // gid => array(vid, ...)
		foreach ($attrSource as $grp)
		{
			if (empty($grp['items'])) { continue; }
			$gkey = mb_strtolower(trim($grp['name']));
			if ($gkey === '') { continue; }
			if (!isset($groupByName[$gkey]))
			{
				$maxOrderGroup++;
				$gid = (int) pjAttributeModel::factory()->reset()
					->setAttributes(array('product_id' => $pid, 'parent_id' => 0, 'order_group' => $maxOrderGroup, 'order_item' => 0))
					->insert()->getInsertId();
				if ($gid <= 0) { continue; }
				$gi = array(); foreach ($locale_ids as $lid) { $gi[$lid] = array('name' => $grp['name']); }
				pjMultiLangModel::factory()->saveMultiLang($gi, $gid, 'pjAttribute');
				$groupByName[$gkey] = array('id' => $gid, 'order' => $maxOrderGroup, 'items' => array(), 'maxItem' => -1);
				$idByGroup[$gid] = $gkey;
			}
			$g =& $groupByName[$gkey];
			$gid = $g['id'];
			$vids = array();
			$seen = array();
			foreach ($grp['items'] as $iv)
			{
				$vkey = mb_strtolower($iv);
				if (isset($seen[$vkey])) { continue; }
				$seen[$vkey] = true;
				if (isset($g['items'][$vkey]))
				{
					$vids[] = $g['items'][$vkey];
				}
				else
				{
					$g['maxItem']++;
					$vid = (int) pjAttributeModel::factory()->reset()
						->setAttributes(array('product_id' => $pid, 'parent_id' => $gid, 'order_group' => $g['order'], 'order_item' => $g['maxItem']))
						->insert()->getInsertId();
					if ($vid <= 0) { continue; }
					$ii = array(); foreach ($locale_ids as $lid) { $ii[$lid] = array('name' => $iv); }
					pjMultiLangModel::factory()->saveMultiLang($ii, $vid, 'pjAttribute');
					$g['items'][$vkey] = $vid;
					$vids[] = $vid;
				}
			}
			unset($g);
			if (!empty($vids)) { $rowGroups[$gid] = $vids; }
		}

		// Cartesian of this row's groups -> stock rows (normally one per row).
		$createdStockIds = array();
		if (empty($rowGroups))
		{
			$sid = (int) pjStockModel::factory()->reset()
				->setAttributes(array('product_id' => $pid, 'qty' => $qty, 'price' => $price))
				->insert()->getInsertId();
			if ($sid > 0) { $createdStockIds[] = $sid; }
		}
		else
		{
			$combos = array(array());
			foreach ($rowGroups as $gid => $vids)
			{
				$next = array();
				foreach ($combos as $combo)
				{
					foreach ($vids as $vid) { $c = $combo; $c[$gid] = $vid; $next[] = $c; }
				}
				$combos = $next;
			}
			foreach ($combos as $combo)
			{
				$sid = (int) pjStockModel::factory()->reset()
					->setAttributes(array('product_id' => $pid, 'qty' => $qty, 'price' => $price))
					->insert()->getInsertId();
				if ($sid <= 0) { continue; }
				$createdStockIds[] = $sid;
				foreach ($combo as $gid => $vid)
				{
					pjStockAttributeModel::factory()->reset()
						->setAttributes(array('stock_id' => $sid, 'product_id' => $pid, 'attribute_id' => $vid, 'attribute_parent_id' => $gid))
						->insert();
				}
			}
		}

		// Match the rest of the product's variants: stock.image_id drives the
		// thumbnail, so point new rows at the product's first gallery image.
		if (!empty($createdStockIds))
		{
			$gal = pjGalleryModel::factory()->select('t1.id')->where('t1.foreign_id', $pid)->orderBy('t1.sort ASC')->limit(1, 0)->findAll()->getData();
			if (!empty($gal) && (int) $gal[0]['id'] > 0)
			{
				$imgId = (int) $gal[0]['id'];
				foreach ($createdStockIds as $sid)
				{
					pjStockModel::factory()->reset()->set('id', $sid)->modify(array('image_id' => $imgId));
				}
			}
		}

		return count($createdStockIds);
	}

	private function pjImportAddImage($pid, $url, $sort)
	{
		if (!function_exists('getimagesizefromstring')) { return false; }
		$bytes = $this->pjImportFetchUrl($url);
		if ($bytes === false || strlen($bytes) < 64) { return false; }

		$info = @getimagesizefromstring($bytes);
		if ($info === false) { return false; }
		$type = $info[2];
		$extMap = array(IMAGETYPE_JPEG => 'jpg', IMAGETYPE_PNG => 'png', IMAGETYPE_GIF => 'gif');
		if (!isset($extMap[$type])) { return false; }
		$ext = $extMap[$type];

		$base = $pid . '_' . md5(uniqid(rand(), true)) . '.' . $ext;
		$sourceDir = PJ_UPLOAD_PATH . 'source/';
		if (!is_dir($sourceDir)) { @mkdir($sourceDir, 0755, true); }
		$source_path = $sourceDir . $base;
		if (@file_put_contents($source_path, $bytes) === false) { return false; }

		$data = array(
			'foreign_id'    => (int) $pid,
			'model'         => 'pjProduct',
			'hash'          => '',
			'mime_type'     => image_type_to_mime_type($type),
			'source_path'   => $source_path,
			'source_size'   => filesize($source_path),
			'source_width'  => (int) $info[0],
			'source_height' => (int) $info[1],
			'name'          => basename($base),
			'sort'          => (int) $sort,
			'created'       => date('Y-m-d H:i:s'),
		);

		$Image = new pjImage();
		foreach ($this->pjImportImageSizes() as $key => $cfg)
		{
			$dir = PJ_UPLOAD_PATH . $key . '/';
			if (!is_dir($dir)) { @mkdir($dir, 0755, true); }
			$dst = $dir . $base;
			$Image->loadImage($source_path);
			if ($cfg['crop']) { $Image->resizeSmart($cfg['w'], $cfg['h']); }
			else { $Image->resizeToWidth($cfg['w']); }
			$Image->saveImage($dst);
			@clearstatcache();
			$sz = is_file($dst) ? @getimagesize($dst) : false;
			$data[$key . '_path']   = $dst;
			$data[$key . '_size']   = is_file($dst) ? filesize($dst) : 0;
			$data[$key . '_width']  = $sz ? (int) $sz[0] : 0;
			$data[$key . '_height'] = $sz ? (int) $sz[1] : 0;
		}

		$gid = (int) pjGalleryModel::factory()->reset()->setAttributes($data)->insert()->getInsertId();
		return $gid;
	}

	private function pjImportDeleteImages($pid)
	{
		$imgs = pjGalleryModel::factory()->where('foreign_id', $pid)->where('model', 'pjProduct')->findAll()->getData();
		if (empty($imgs)) { return; }
		foreach ($imgs as $im)
		{
			foreach (array('small_path', 'medium_path', 'large_path', 'source_path') as $f)
			{
				if (!empty($im[$f]) && is_file($im[$f])) { @unlink($im[$f]); }
			}
		}
		pjGalleryModel::factory()->where('foreign_id', $pid)->where('model', 'pjProduct')->eraseAll();
	}

	public function pjActionExtraCopy()
	{
		$this->setAjax(true);
		
		if ($this->isXHR() && $this->isLoged())
		{
			if ($this->_post->check('from_product_id') && $this->_post->toInt('from_product_id') > 0 &&
				$this->_post->check('product_id') && $this->_post->toInt('product_id') > 0)
			{
				$pjExtraModel = pjExtraModel::factory();
				$pjExtraItemModel = pjExtraItemModel::factory();
				$pjMultiLangModel = pjMultiLangModel::factory();
				
				$extras = $pjExtraModel->where('t1.product_id', $this->_post->toInt('from_product_id'))->findAll()->getData();
				$extras_items = $pjExtraItemModel
					->where(sprintf("t1.extra_id IN (SELECT `id` FROM `%s` WHERE `product_id` = '%u')", $pjExtraModel->getTable(), $this->_post->toInt('from_product_id')))
					->findAll()
					->getData();
				
				foreach ($extras as $k => $extra)
				{
					$extras[$k]['items'] = array();
					if ($extra['type'] == 'multi')
					{
						foreach ($extras_items as $key => $item)
						{
							if ($item['extra_id'] == $extra['id'])
							{
								$extras[$k]['items'][] = $item;
							}
						}
					}
				}
				
				$query = sprintf("INSERT INTO `%1\$s` (`foreign_id`, `model`, `locale`, `field`, `content`, `source`)
						SELECT :foreign_id, `model`, `locale`, `field`, `content`, `source`
						FROM `%1\$s`
						WHERE `foreign_id` = :fid
						AND `model` = :model",
					$pjMultiLangModel->getTable());
							
				foreach ($extras as $extra)
				{
					$extra_id = $pjExtraModel->reset()->setAttributes(array(
						'product_id' => $this->_post->toInt('product_id'),
						'type' => $extra['type'],
						'price' => $extra['price'],
						'is_mandatory' => $extra['is_mandatory']
					))->insert()->getInsertId();
					if ($extra_id !== FALSE && (int) $extra_id > 0)
					{
						$pjMultiLangModel->reset()->prepare($query)->exec(array(
							'foreign_id' => $extra_id,
							'fid' => $extra['id'],
							'model' => 'pjExtra'
						));
						
						if ($extra['type'] == 'multi' && isset($extra['items']) && !empty($extra['items']))
						{
							foreach ($extra['items'] as $item)
							{
								$extra_item_id = $pjExtraItemModel->reset()->setAttributes(array(
									'extra_id' => $extra_id,
									'price' => $item['price']
								))->insert()->getInsertId();
								if ($extra_item_id !== FALSE && (int) $extra_item_id > 0)
								{
									$pjMultiLangModel->reset()->prepare($query)->exec(array(
										'foreign_id' => $extra_item_id,
										'fid' => $item['id'],
										'model' => 'pjExtraItem'
									));
								}
							}
						}
					}
				}
				
				pjAppController::jsonResponse(array('status' => 'OK', 'code' => 200, 'text' => 'Extras has been copied'));
			}
		}
		exit;
	}
	
	public function pjActionGetAttributes()
	{
		$this->setAjax(true);
	
		if ($this->isXHR())
		{
			if ($this->_get->check('product_id') && $this->_get->toInt('product_id') > 0)
			{
				$wk = 't1.product_id';
				$wv = $this->_get->toInt('product_id');
			} elseif ($this->_get->check('hash') && $this->_get->toString('hash') != '') {
				$wk = 't1.hash';
				$wv = $this->_get->toString('hash');
			}
			
			$attr_arr = array();
			$a_arr = pjAttributeModel::factory()
				->select('t1.id, t1.product_id, t1.parent_id, t1.hash, t2.content AS name')
				->join('pjMultiLang', "t2.model='pjAttribute' AND t2.foreign_id=t1.id AND t2.field='name' AND t2.locale='".$this->getLocaleId()."'", 'left outer')
				->where($wk, $wv)
				->orderBy('t1.`order_group` ASC, `order_item` ASC')
				->findAll()
				->getData();
			
			$pjMultiLangModel = pjMultiLangModel::factory();
				
			foreach ($a_arr as $attr)
			{
				$attr['i18n'] = $pjMultiLangModel->reset()->getMultiLang($attr['id'], 'pjAttribute');
				if ((int) $attr['parent_id'] === 0)
				{
					$attr_arr[$attr['id']] = $attr;
				} else {
					if (!isset($attr_arr[$attr['parent_id']]['child']))
					{
						$attr_arr[$attr['parent_id']]['child'] = array();
					}
					$attr_arr[$attr['parent_id']]['child'][] = $attr;
				}
			}
			$this->set('attr_arr', array_values($attr_arr));
			
			$this->setLocalesData();
		}
	}
	
	public function pjActionGetExtras()
	{
		$this->setAjax(true);
	
		if ($this->isXHR())
		{
			if (!$this->_get->check('product_id') || $this->_get->toInt('product_id') <= 0)
			{
				return;
			}
			
			$extra_arr = pjExtraModel::factory()->where('t1.product_id', $this->_get->toInt('product_id'))->findAll()->getData();
			$pjExtraItemModel = pjExtraItemModel::factory();
			$pjMultiLangModel = pjMultiLangModel::factory();
			foreach ($extra_arr as $k => $extra)
			{
				$extra_arr[$k]['i18n'] = $pjMultiLangModel->reset()->getMultiLang($extra['id'], 'pjExtra');
				$extra_arr[$k]['extra_items'] = $pjExtraItemModel->reset()->where('t1.extra_id', $extra['id'])->orderBy('t1.price ASC')->findAll()->getData();
				foreach ($extra_arr[$k]['extra_items'] as $key => $val)
				{
					$extra_arr[$k]['extra_items'][$key]['i18n'] = $pjMultiLangModel->reset()->getMultiLang($val['id'], 'pjExtraItem');
				}
			}
			$this->set('extra_arr', $extra_arr);
				
			$this->setLocalesData();
		}
	}
	
	public function pjActionGetHistory()
	{
		$this->setAjax(true);
	
		if ($this->isXHR())
		{
			$product = pjProductModel::factory()->find($this->_get->toInt('id'))->getData();
			
			$history_arr = pjHistoryModel::factory()
				->select("t1.*, t2.id AS sid, t3.content AS name,
						  (		SELECT GROUP_CONCAT(CONCAT_WS(': ', TML2.content, TML1.content) SEPARATOR '&nbsp;|&nbsp;') 
								FROM `".pjStockAttributeModel::factory()->getTable()."` AS `TSA`
									LEFT OUTER JOIN `".pjMultiLangModel::factory()->getTable()."` AS TML1 ON (TML1.model='pjAttribute' AND TML1.foreign_id=TSA.attribute_id AND TML1.field='name' AND TML1.locale='".$this->getLocaleId()."') 
									LEFT OUTER JOIN `".pjMultiLangModel::factory()->getTable()."` AS TML2 ON (TML2.model='pjAttribute' AND TML2.foreign_id=TSA.attribute_parent_id AND TML2.field='name' AND TML2.locale='".$this->getLocaleId()."')
								WHERE t1.record_id=TSA.stock_id) AS attributes	
						 ")
				->join('pjStock', 't2.id=t1.record_id', 'left outer')
				->join('pjMultiLang', "t3.model='pjProduct' AND t3.foreign_id=t2.product_id AND t3.locale='".$this->getLocaleId()."' AND t3.field='name'", 'left outer')
				->where('t1.table_name', pjStockModel::factory()->getTable())
				->where('t2.product_id', $this->_get->toInt('id'))
				->orderBy('t1.created ASC')
				->findAll()
				->getData();
			
			$this->set('product', $product);
			$this->set('history_arr', $history_arr);
		}
	}
	
	public function pjActionGetProducts()
	{
		$this->setAjax(true);
	
		if ($this->isXHR() && $this->isLoged())
		{
			$pjProductModel = pjProductModel::factory();
			if ($this->_get->check('product_id') && $this->_get->toInt('product_id') > 0)
			{
				$pjProductModel->where('t1.id !=', $this->_get->toInt('product_id'));
			}
			if ($this->_get->check('copy'))
			{
				switch ($this->_get->toString('copy'))
				{
					case 'Attr':
						$pjProductModel->where(sprintf("t1.id IN (SELECT `product_id` FROM `%s` WHERE 1)", pjAttributeModel::factory()->getTable()));
						break;
					case 'Extra':
						$pjProductModel->where(sprintf("t1.id IN (SELECT `product_id` FROM `%s` WHERE 1)", pjExtraModel::factory()->getTable()));
						break;
				}
			}
			$this->set('arr', $pjProductModel
				->select('t1.*, t2.content AS name')
				->join('pjMultiLang', "t2.model='pjProduct' AND t2.foreign_id=t1.id AND t2.locale='".$this->getLocaleId()."' AND t2.field='name'", 'left outer')
				->orderBy('name ASC')->findAll()->getData());
		}
	}
	
	public function pjActionGetSimilar()
	{
		$this->setAjax(true);
		
		if ($this->isXHR())
		{
			if ($this->_get->check('id') && $this->_get->toInt('id') > 0)
			{
				$pjProductSimilarModel = pjProductSimilarModel::factory()
					->join('pjProduct', 't2.id=t1.similar_id', 'inner')
					->join('pjMultiLang', "t3.model='pjProduct' AND t3.foreign_id=t2.id AND t3.locale='".$this->getLocaleId()."' AND t3.field='name'", 'left outer')
					->where('t1.product_id', $this->_get->toInt('id'))
				;
				
				$column = 'name';
				$direction = 'ASC';
				$allowed_similar_cols = array('id', 'name', 'sku', 'status', 'created', 'price');
				if ($this->_get->check('direction') && $this->_get->check('column') && in_array(strtoupper($this->_get->toString('direction')), array('ASC', 'DESC')) && in_array($this->_get->toString('column'), $allowed_similar_cols))
				{
					$column = $this->_get->toString('column');
					$direction = strtoupper($this->_get->toString('direction'));
				}
	
				$total = $pjProductSimilarModel->findCount()->getData();
				$rowCount = $this->_get->check('rowCount') && $this->_get->toInt('rowCount') > 0 ? $this->_get->toInt('rowCount') : 10;
				$pages = ceil($total / $rowCount);
				$page = $this->_get->check('page') && $this->_get->toInt('page') > 0 ? $this->_get->toInt('page') : 1;
				$offset = ((int) $page - 1) * $rowCount;
				if ($page > $pages)
				{
					$page = $pages;
				}
			
				$data = $pjProductSimilarModel
					->select('t1.id, t1.similar_id, t2.sku, t2.status, t3.content AS name')
					->orderBy("$column $direction")
					->findAll()
					->getData();
				
				pjAppController::jsonResponse(compact('data', 'total', 'pages', 'page', 'rowCount', 'column', 'direction'));
			}
		}
		exit;
	}
	
	public function pjActionSearchProducts()
	{
		$this->setAjax(true);
		
		$pjProductModel = pjProductModel::factory();
			
		if ($this->_get->check('query'))
		{
			$q = $pjProductModel->escapeStr($this->_get->toString('query'));
			$q = str_replace(array('%', '_'), array('\%', '\_'), $q);
			$pjProductModel->where("(t1.id LIKE '%$q%' OR t1.sku LIKE '%$q%' OR t1.id IN (SELECT `foreign_id` FROM `".pjMultiLangModel::factory()->getTable()."`
				WHERE `field` = 'name'
				AND `model` = 'pjProduct'
				AND `content` LIKE '%$q%'))");
		}
		$arr = $pjProductModel
			->select('t1.*, t2.content AS name')
			->join('pjMultiLang', "t2.model='pjProduct' AND t2.foreign_id=t1.id AND t2.field='name' AND t2.locale='".$this->getLocaleId()."'", 'left outer')
			->where('t1.id !=', $this->_get->toInt('id'))
			->where(sprintf("t1.id NOT IN (SELECT `similar_id` FROM `%s` WHERE `product_id` = '%u')", pjProductSimilarModel::factory()->getTable(), $this->_get->toInt('id')))
			->orderBy('`name` ASC')
			->limit($this->_get->toInt('limit'))->findAll()->getData();
		
		$_arr = array();
		foreach ($arr as $k => $v)
		{
			$_arr[$k]['id'] = $v['id'];
			$_arr[$k]['label'] = $v['name'];
		}			
		pjAppController::jsonResponse($_arr);
	}
	
	public function pjActionStock()
	{
		$this->checkLogin();
	    if (!pjAuth::factory()->hasAccess())
	    {
	        $this->sendForbidden();
	        return;
	    }
	    
		$this->set('has_update', pjAuth::factory('pjAdminProducts', 'pjActionUpdate')->hasAccess());
			
		$this->appendJs('jquery.datagrid.js', PJ_FRAMEWORK_LIBS_PATH . 'pj/js/');
		$this->appendJs('pjAdminProducts.js');
	}
	
	public function pjActionPrintStock()
	{
		$this->setLayout('pjActionPrint');
		
		$pjStockModel = pjStockModel::factory();
		if ($record = $this->_post->toArray('record'))
		{
			$pjStockModel->whereIn('t1.id', $record);
		}
		
		$arr = $pjStockModel
			->select(sprintf("t1.*, t2.content AS name,
				(SELECT `small_path` FROM `%1\$s`
					WHERE `id` = `t1`.`image_id`
					LIMIT 1) AS `pic`,
				(SELECT GROUP_CONCAT(CONCAT_WS('~:~', `tm2`.`content`, `tm1`.`content`) SEPARATOR '~|~')
					FROM `%2\$s` AS `tsa`
					LEFT JOIN `%3\$s` AS `tm1` ON `tm1`.`model` = 'pjAttribute' AND `tm1`.`foreign_id` = `tsa`.`attribute_id` AND `tm1`.`field` = 'name' AND `tm1`.`locale` = '%4\$u'
					LEFT JOIN `%3\$s` AS `tm2` ON `tm2`.`model` = 'pjAttribute' AND `tm2`.`foreign_id` = `tsa`.`attribute_parent_id` AND `tm2`.`field` = 'name' AND `tm2`.`locale` = '%4\$u'
					WHERE `tsa`.`product_id` = `t1`.`product_id`
					AND `tsa`.`stock_id` = `t1`.`id`
					LIMIT 1) AS `stock_attr`
			", pjGalleryModel::factory()->getTable(), pjStockAttributeModel::factory()->getTable(), pjMultiLangModel::factory()->getTable(), $this->getLocaleId()))
			->join('pjMultiLang', "t2.model='pjProduct' AND t2.foreign_id=t1.product_id AND t2.locale='".$this->getLocaleId()."' AND t2.field='name'", 'left outer')
			->join('pjProduct', 't3.id=t1.product_id', 'left outer')
			->orderBy("`name` ASC")
			->findAll()
			->toArray('stock_attr', '~|~')
			->getData();
		$this->set('arr', $arr);
		$this->resetCss()->appendCss('print.css');
	}
	
	public function pjActionAddSimilar()
	{
		$this->setAjax(true);
		
		if ($this->isXHR())
		{
			if ($this->_post->check('product_id') && $this->_post->check('similar_id') && $this->_post->toInt('product_id') > 0 && $this->_post->toInt('similar_id') > 0)
			{
				$insert_id = pjProductSimilarModel::factory($this->_post->raw())->insert()->getInsertId();
				if ($insert_id !== FALSE && (int) $insert_id > 0)
				{
					pjAppController::jsonResponse(array('status' => 'OK', 'code' => 200, 'text' => 'Similar product has been added.'));
				}
				pjAppController::jsonResponse(array('status' => 'ERR', 'code' => 100, 'text' => 'Similar product has not been added.'));
			} else {
				pjAppController::jsonResponse(array('status' => 'ERR', 'code' => 101, 'text' => 'Missing parameters.'));
			}
		}
		exit;
	}
	
	public function pjActionIndex()
	{
		$this->checkLogin();
	    if (!pjAuth::factory()->hasAccess())
	    {
	        $this->sendForbidden();
	        return;
	    }
		
		$this->set('category_arr', pjCategoryModel::factory()->getNode($this->getLocaleId(), 1));
			
		$this->set('has_create', pjAuth::factory('pjAdminProducts', 'pjActionCreate')->hasAccess());
		$this->set('has_update', pjAuth::factory('pjAdminProducts', 'pjActionUpdate')->hasAccess());
		$this->set('has_delete', pjAuth::factory('pjAdminProducts', 'pjActionDeleteProduct')->hasAccess());
		$this->set('has_delete_bulk', pjAuth::factory('pjAdminProducts', 'pjActionDeleteProductBulk')->hasAccess());
		
		$this->appendJs('jquery.datagrid.js', PJ_FRAMEWORK_LIBS_PATH . 'pj/js/');
		$this->appendJs('pjAdminProducts.js');
	}
	
	public function pjActionSaveProduct()
	{
		$this->setAjax(true);
		
		if (!$this->isXHR())
		{
		    self::jsonResponse(array('status' => 'ERR', 'code' => 100, 'text' => 'Missing headers.'));
		}
		
		if (!self::isPost())
		{
		    self::jsonResponse(array('status' => 'ERR', 'code' => 101, 'text' => 'HTTP method not allowed.'));
		}
		
		if (!pjAuth::factory($this->_get->toString('controller'), 'pjActionUpdate')->hasAccess())
		{
		    self::jsonResponse(array('status' => 'ERR', 'code' => 102, 'text' => 'Access denied.'));
		}
		$pjProductModel = pjProductModel::factory();
		$arr = $pjProductModel->find($this->_get->toInt('id'))->getData();
		if (!$arr)
		{
		    self::jsonResponse(array('status' => 'ERR', 'code' => 103, 'text' => 'Product not found.'));
		}
		if (!in_array($this->_post->toString('column'), $pjProductModel->getI18n()))
		{
		    $pjProductModel->reset()->where('id', $this->_get->toInt('id'))->limit(1)->modifyAll(array($this->_post->toString('column') => $this->_post->toString('value')));
		} else {
		    pjMultiLangModel::factory()->updateMultiLang(array($this->getLocaleId() => array($this->_post->toString('column') => $this->_post->toString('value'))), $this->_get->toInt('id'), 'pjProduct', 'data');
		}
		self::jsonResponse(array('status' => 'OK', 'code' => 201, 'text' => 'Product has been updated.'));
		exit;
	}

	/**
	 * Inline-update a product's stock quantity directly from the product list.
	 * Only allowed for products that have exactly ONE stock row, because a total
	 * cannot be unambiguously split across multiple variants. Multi-variant
	 * products must be edited from the product's stock page.
	 */
	public function pjActionSaveStockQty()
	{
		$this->setAjax(true);

		if (!$this->isXHR())
		{
			self::jsonResponse(array('status' => 'ERR', 'code' => 100, 'text' => 'Missing headers.'));
		}
		if (!self::isPost())
		{
			self::jsonResponse(array('status' => 'ERR', 'code' => 101, 'text' => 'HTTP method not allowed.'));
		}
		if (!pjAuth::factory($this->_get->toString('controller'), 'pjActionUpdate')->hasAccess())
		{
			self::jsonResponse(array('status' => 'ERR', 'code' => 102, 'text' => 'Access denied.'));
		}

		$product_id = $this->_get->toInt('id');
		if ($product_id <= 0)
		{
			self::jsonResponse(array('status' => 'ERR', 'code' => 103, 'text' => 'Invalid product.'));
		}

		$qty = $this->_post->toInt('qty');
		if ($qty < 0)
		{
			$qty = 0;
		}

		$pjStockModel = pjStockModel::factory();
		$stocks = $pjStockModel->reset()->where('product_id', $product_id)->findAll()->getData();
		if (count($stocks) !== 1)
		{
			self::jsonResponse(array('status' => 'ERR', 'code' => 104, 'text' => 'This product has multiple stock variants. Edit it from the product\'s stock tab.'));
		}

		$pjStockModel->reset()->where('id', (int) $stocks[0]['id'])->limit(1)->modifyAll(array('qty' => $qty));
		self::jsonResponse(array('status' => 'OK', 'code' => 200, 'text' => 'Stock updated.', 'total_stock' => $qty));
		exit;
	}

	public function pjActionSaveStock()
	{
		$this->setAjax(true);
	
		if ($this->isXHR() && $this->isLoged())
		{
			$pjStockModel = pjStockModel::factory();
			if (!in_array($this->_post->toString('column'), $pjStockModel->getI18n()))
			{
				if ($this->_post->toString('column') == 'qty')
				{
					$before = $pjStockModel->reset()->find($this->_get->toInt('id'))->getData();
				}
				$affected_rows = $pjStockModel->reset()->set('id', $this->_get->toInt('id'))->modify(array($this->_post->toString('column') => $this->_post->toString('value')))->getAffectedRows();
				if ($this->_post->toString('column') == 'qty' && $affected_rows == 1)
				{
					$after = $pjStockModel->reset()->find($this->_get->toInt('id'))->getData();
					pjAppController::addToHistory($this->_get->toInt('id'), $this->getUserId(), $pjStockModel->getTable(), $before, $after);
				}
			} else {
				pjMultiLangModel::factory()->updateMultiLang(array($this->getLocaleId() => array($this->_post->toString('column') => $this->_post->toString('value'))), $this->_get->toInt('id'), 'pjStock');
			}
		}
		exit;
	}
	
	public function pjActionUpdate()
	{
		$this->checkLogin();
	    if (!pjAuth::factory()->hasAccess())
	    {
	        $this->sendForbidden();
	        return;
	    }
		
		$pjExtraModel = pjExtraModel::factory();
		$pjExtraItemModel = pjExtraItemModel::factory();
		$pjAttributeModel = pjAttributeModel::factory();
		$pjStockModel = pjStockModel::factory();
		$pjStockAttributeModel = pjStockAttributeModel::factory();
		$pjProductCategoryModel = pjProductCategoryModel::factory();
		$pjMultiLangModel = pjMultiLangModel::factory();
		$pjProductModel = pjProductModel::factory();
		
		$post_max_size = pjUtil::getPostMaxSize();
		if ($_SERVER['REQUEST_METHOD'] == 'POST' && isset($_SERVER['CONTENT_LENGTH']) && (int) $_SERVER['CONTENT_LENGTH'] > $post_max_size)
		{
			pjUtil::redirect(PJ_INSTALL_URL . "index.php?controller=pjAdminProducts&action=pjActionIndex&err=AP09");
		}
		
		if (self::isPost() && $this->_post->toInt('product_update'))
		{
			$err = 'AP01';
			
			if (!$this->_post->check('id') || $this->_post->toInt('id') <= 0)
			{
				pjUtil::redirect(PJ_INSTALL_URL . 'index.php?controller=pjAdminProducts&action=pjActionIndex&err=AP08');
			}
			$product_arr = $pjProductModel->find($this->_post->toInt('id'))->getData();
			if (empty($product_arr))
			{
				pjUtil::redirect(PJ_INSTALL_URL . 'index.php?controller=pjAdminProducts&action=pjActionIndex&err=AP08');
			}
			
			# Stock start ----------------------------------------------
			# Get/prepare
			
			$sa_arr = $pjStockAttributeModel
				->select("t1.stock_id, GROUP_CONCAT(CONCAT_WS(':', t2.image_id, t1.attribute_parent_id, t1.attribute_id) ORDER BY t1.attribute_parent_id ASC, t1.attribute_id ASC SEPARATOR '|') AS `str`")
				->join('pjStock', 't2.id=t1.stock_id AND t2.product_id=t1.product_id', 'inner')
				->where('t2.product_id', $this->_post->toInt('id'))
				->groupBy('t1.stock_id')
				->findAll()
				->getDataPair('stock_id', 'str');
			
			$i_arr = $u_arr = array();
			
			if ($stock_qty_arr = $this->_post->toArray('stock_qty'))
			{
				$stock_price_arr = $this->_post->toArray('stock_price');
				$stock_image_id_arr = $this->_post->toArray('stock_image_id');
				$stock_attribute_arr = $this->_post->toArray('stock_attribute');
				foreach ($stock_qty_arr as $k => $whatever)
				{
					if (strpos($k, 'x_') === 0)
					{
						if ((float) $stock_price_arr[$k] > 0 && (int) $stock_image_id_arr[$k] > 0)
						{
							# Insert new stock
							$tmp = array();
							if (isset($stock_attribute_arr[$k]))
							{
								foreach ($stock_attribute_arr[$k] as $attr_parent_id => $attr_id)
								{
								    $tmp[] = $stock_image_id_arr[$k] . ":" . $attr_parent_id . ":" . $attr_id;
								}
							}
							asort($tmp);
							$i_arr[$k] = join("|", $tmp);
						}
					} else {
						# Update attr
						if (isset($stock_attribute_arr[$k]))
						{
							# Add items again
							$tmp = array();
							foreach ($stock_attribute_arr[$k] as $attr_parent_id => $attr_id)
							{
							    $tmp[] = $stock_image_id_arr[$k] . ":" . $attr_parent_id . ":" . $attr_id;
							}
							asort($tmp);
							$u_arr[$k] = join("|", $tmp);
						}
					}
				}
				
				foreach ($stock_qty_arr as $k => $whatever)
				{
					if (strpos($k, 'x_') === 0)
					{
						if ((float) $stock_price_arr[$k] > 0 && (int) $stock_image_id_arr[$k] > 0 && !in_array($i_arr[$k], $sa_arr))
						{
							# Insert new stock
							$stock_id = $pjStockModel
								->reset()
								->set('product_id', $this->_post->toInt('id'))
								->set('image_id', $stock_image_id_arr[$k])
								->set('qty', $stock_qty_arr[$k])
								->set('price', $stock_price_arr[$k])
								->insert()
								->getInsertId();
	
							if ($stock_id !== false && (int) $stock_id > 0)
							{
								if (isset($stock_attribute_arr[$k]))
								{
									$pjStockAttributeModel->begin();
									foreach ($stock_attribute_arr[$k] as $attr_parent_id => $attr_id)
									{
										$pjStockAttributeModel
											->reset()
											->set('stock_id', $stock_id)
											->set('product_id', $this->_post->toInt('id'))
											->set('attribute_parent_id', $attr_parent_id)
											->set('attribute_id', $attr_id)
											->insert();
									}
									$pjStockAttributeModel->commit();
								}
							}
						}
					} else {
						# Update attr
						$before = $pjStockModel->reset()->find($k)->getData();
						if ($pjStockModel
							->reset()
							->set('id', $k)
							->modify(array(
								'image_id' => $stock_image_id_arr[$k],
								'qty' => $stock_qty_arr[$k],
								'price' => $stock_price_arr[$k]
							))
							->getAffectedRows() == 1)
						{
							$after = $pjStockModel->reset()->find($k)->getData();
							pjAppController::addToHistory($k, $this->getUserId(), $pjStockModel->getTable(), $before, $after);
						}
						//if (isset($sa_arr[$k]) && isset($u_arr[$k]) && $sa_arr[$k] != $u_arr[$k] && !in_array($u_arr[$k], $sa_arr))
						if (isset($u_arr[$k]) && !in_array($u_arr[$k], $sa_arr) && (
								!isset($sa_arr[$k]) ||
								(isset($sa_arr[$k]) && $sa_arr[$k] != $u_arr[$k])
							)
						)
						{
							# Delete items ------------------
							$pjStockAttributeModel->reset()->where('stock_id', $k)->eraseAll();
							if (isset($stock_attribute_arr[$k]))
							{
								# Add items again
								$pjStockAttributeModel->begin();
								foreach ($stock_attribute_arr[$k] as $attr_parent_id => $attr_id)
								{
									$pjStockAttributeModel
										->reset()
										->set('stock_id', $k)
										->set('product_id', $this->_post->toInt('id'))
										->set('attribute_parent_id', $attr_parent_id)
										->set('attribute_id', $attr_id)
										->insert();
								}
								$pjStockAttributeModel->commit();
							}
						}
					}
				}
			}
			# Stock end ----------------------------------------------

			$i18n_arr = $this->pjActionAttrHandle($this->_post->toInt('id'));
			if ($this->_post->check('is_digital'))
			{
				$this->pjActionDeleteProductAttr($product_arr['id']);
				$this->pjActionDeleteStockAttr($product_arr['id']);
				
				$statement = sprintf("DELETE FROM `%1\$s`
					WHERE `product_id` = :product_id
					AND `id` NOT IN (
					  SELECT `id`
					  FROM (
					    SELECT `id`
					    FROM `%1\$s`
					    WHERE `product_id` = :product_id
					    ORDER BY `id` ASC
					    LIMIT 1
					  ) `foo`);", $pjStockModel->getTable());
				
				$pjStockModel->prepare($statement)->exec(array('product_id' => $product_arr['id']));
			}

			$i18n_arr = $this->_post->toArray('i18n');
			# Extras start -----------------------------------------
			# Get/Prepare
			$extra_items = array();
			$extra_arr = $pjExtraModel->where('t1.product_id', $this->_post->toInt('id'))->findAll()->getDataPair('id', 'type');
			foreach ($extra_arr as $id => $type)
			{
				if ($type == 'multi')
				{
					$extra_items[$id] = $pjExtraItemModel->reset()->where('t1.extra_id', $id)->findAll()->getDataPair('id', 'id');
				}
			}
			# Update ------------------
			if ($extra_type_arr = $this->_post->toArray('extra_type'))
			{
				$extra_price_arr = $this->_post->toArray('extra_price');
				$extra_is_mandatory_arr = $this->_post->toArray('extra_is_mandatory');
				foreach ($extra_type_arr as $k => $type)
				{
					# Insert new extra ------------------
					if (strpos($k, 'x_') === 0)
					{
						$data = array();
						$data['product_id'] = $this->_post->toInt('id');
						$data['type'] = $type;
						switch ($type)
						{
							case 'single':
								$data['price'] = $extra_price_arr[$k];
								break;
							case 'multi':
								break;
						}
						$data['is_mandatory'] = isset($extra_is_mandatory_arr[$k]) ? 1 : 0;
						$extra_id = $pjExtraModel->reset()->setAttributes($data)->insert()->getInsertId();
						if ($extra_id !== false && (int) $extra_id > 0)
						{
							switch ($type)
							{
								case 'single':
									$tmp = $this->pjActionTurnI18n($i18n_arr, 'extra_name', $k);
									$pjMultiLangModel->saveMultiLang($tmp, $extra_id, 'pjExtra');
									break;
								case 'multi':
									$tmp = $this->pjActionTurnI18n($i18n_arr, 'extra_title', $k);
									$pjMultiLangModel->saveMultiLang($tmp, $extra_id, 'pjExtra');
									
									$edata = array();
									$edata['extra_id'] = $extra_id;
									foreach ($extra_price_arr[$k] as $index => $price)
									{
											$edata['price'] = $price;
											$ei_id = $pjExtraItemModel->reset()->setAttributes($edata)->insert()->getInsertId();
											if ($ei_id !== false && (int) $ei_id > 0)
											{
												$tmp = $this->pjActionTurnI18n($i18n_arr, 'extra_name', $k, $index);
												$pjMultiLangModel->saveMultiLang($tmp, $ei_id, 'pjExtraItem');
											}
									}
									break;
							}
						}
					} else {
						# Update extra
						$pjExtraModel->reset()->set('id', $k)->modify(array(
							'type' => $type,
							'price' => $type == 'single' ? $extra_price_arr[$k] : ':NULL',
							'is_mandatory' => isset($extra_is_mandatory_arr[$k]) ? 1 : 0
						));
						### ---
						switch ($type)
						{
							case 'multi':
								# value of column 'type' in DB is single
								$tmp = $this->pjActionTurnI18n($i18n_arr, 'extra_title', $k);
								if ($extra_arr[$k] == 'single')
								{
									$pjMultiLangModel->reset()->where('model', 'pjExtra')->where('foreign_id', $k)->eraseAll();
									$pjMultiLangModel->saveMultiLang($tmp, $k, 'pjExtra');
								} else {
									$pjMultiLangModel->updateMultiLang($tmp, $k, 'pjExtra');
								}
								# Delete items ------------------
								$diff = array_diff($extra_items[$k], array_keys($extra_price_arr[$k]));
								if (count($diff) > 0)
								{
									$pjExtraItemModel->reset()->whereIn('id', $diff)->eraseAll();
									$pjMultiLangModel->reset()->where('model', 'pjExtraItem')->whereIn('foreign_id', $diff)->eraseAll();
								}
								
								foreach ($extra_price_arr[$k] as $index => $price)
								{
									if (strpos($index, 'y_') === 0)
									{
										# Add items
											$ei_id = $pjExtraItemModel->reset()->setAttributes(array(
												'extra_id' => $k,
												'price' => $price
											))->insert()->getInsertId();
											if ($ei_id !== false && (int) $ei_id > 0)
											{
												$tmp = $this->pjActionTurnI18n($i18n_arr, 'extra_name', $k, $index);
												$pjMultiLangModel->saveMultiLang($tmp, $ei_id, 'pjExtraItem');
											}
									} else {
										# Update items
											$pjExtraItemModel->reset()->set('id', $index)->modify(array(
												'price' => $price
											));
											$tmp = $this->pjActionTurnI18n($i18n_arr, 'extra_name', $k, $index);
											$pjMultiLangModel->updateMultiLang($tmp, $index, 'pjExtraItem');
									}
								}
								break;
							case 'single':
								# value of column 'type' in DB is multi
								if ($extra_arr[$k] == 'multi')
								{
									$ei_ids = $pjExtraItemModel->reset()->where('extra_id', $k)->findAll()->getDataPair(NULL, 'id');
									if (!empty($ei_ids))
									{
										$pjExtraItemModel->eraseAll();
										$pjMultiLangModel->reset()->where('model', 'pjExtraItem')->whereIn('foreign_id', $ei_ids)->eraseAll();
									}
									$pjMultiLangModel->reset()->where('model', 'pjExtra')->where('foreign_id', $k)->where('field', 'extra_title')->eraseAll();
								}
								$tmp = $this->pjActionTurnI18n($i18n_arr, 'extra_name', $k);
								$pjMultiLangModel->updateMultiLang($tmp, $k, 'pjExtra');
								break;
						}
						### ---
					}
				}
			}
			# Extras end -----------------------------------------

			# Categories start -----------------------------------
			$pjProductCategoryModel->where('product_id', $this->_post->toInt('id'))->eraseAll();
			if ($category_id_arr = $this->_post->toArray('category_id'))
			{
				$pjProductCategoryModel->begin();
				foreach ($category_id_arr as $category_id)
				{
					$pjProductCategoryModel
						->reset()
						->set('product_id', $this->_post->toInt('id'))
						->set('category_id', $category_id)
						->insert();
				}
				$pjProductCategoryModel->commit();
			}
			# Categories end -------------------------------------
				
			$data = array();
			$data['is_featured'] = $this->_post->check('is_featured') ? 1 : 0;
			$data['is_digital'] = $this->_post->check('is_digital') ? 1 : 0;
			if ($this->_post->check('is_digital'))
			{
				if ($this->_post->check('digital_choose'))
				{
					switch ($this->_post->toInt('digital_choose'))
					{
						case 1:
							if (isset($_FILES['digital_file']))
							{
								if($_FILES['digital_file']['error'] == 0)
								{
									$pjUpload = new pjUpload();
									$pjUpload->setAllowedTypes(array('*'));
									$pjUpload->setAllowedExt(array('*'));
									if ($pjUpload->load($_FILES['digital_file']))
									{
										$name = $pjUpload->getFile('name');
										$file = PJ_UPLOAD_PATH . 'digital/' . md5(uniqid(rand(), true)) . "." . $pjUpload->getExtension();
										if ($pjUpload->save($file))
										{
											// Remove the previous digital file so replacing it
											// does not orphan the old upload on disk.
											$oldDigital = pjProductModel::factory()->select('t1.digital_file')->find($this->_post->toInt('id'))->getData();
											if (!empty($oldDigital['digital_file']) && $oldDigital['digital_file'] !== $file && is_file($oldDigital['digital_file']))
											{
												@unlink($oldDigital['digital_file']);
											}
											$data['digital_file'] = $file;
											$data['digital_name'] = $name;
										}
									} else {
										$err = 'AP10';
									}
								}else if($_FILES['digital_file']['error'] != 4){
									$err = 'AP10';
									$data['is_digital'] = 0;
								}
							}
							break;
						case 2:
							if(file_exists($this->_post->toString('digital_file')))
							{
								$data['digital_file'] = $this->_post->toString('digital_file');
								$data['digital_name'] = basename($this->_post->toString('digital_file'));
							}else{
								$err = 'AP11';
								$data['is_digital'] = 0;
							}
							break;
					}
				}
				if($err == 'AP01')
				{
					$data['digital_expire'] = sprintf("%s:%s:00", $this->_post->toString('hour'), $this->_post->toString('minute'));
				}else{
					$data['digital_file'] = ':NULL';
					$data['digital_name'] = ':NULL';
					$data['digital_expire'] = ':NULL';
				}
			} else {
				$data['digital_file'] = ':NULL';
				$data['digital_name'] = ':NULL';
				$data['digital_expire'] = ':NULL';
			}
			
			$pjProductModel->reset()->set('id', $this->_post->toInt('id'))->modify(array_merge($this->_post->raw(), $data));
			
			$i18n_arr = $this->_post->toI18n('i18n');
			if ($i18n_arr)
			{
				foreach ($i18n_arr as $locale_id => $locale_arr)
				{
				    if (isset($i18n_arr[$locale_id]['extra_title'])) {
					   unset($i18n_arr[$locale_id]['extra_title']);
				    }
				    if (isset($i18n_arr[$locale_id]['extra_name'])) {
					   unset($i18n_arr[$locale_id]['extra_name']);
				    }
				    if (isset($i18n_arr[$locale_id]['attr_group'])) {
				        unset($i18n_arr[$locale_id]['attr_group']);
				    }
				    if (isset($i18n_arr[$locale_id]['attr_item'])) {
				        unset($i18n_arr[$locale_id]['attr_item']);
				    }
				}
				$pjMultiLangModel->updateMultiLang($i18n_arr, $this->_post->toInt('id'), 'pjProduct');
			}
			pjUtil::redirect(sprintf("%s?controller=pjAdminProducts&action=pjActionUpdate&id=%u&tab=%s&err=%s", $_SERVER['PHP_SELF'], $this->_post->toInt('id'), $this->_post->toString('tab'), $err));
		} else {
			$arr = $pjProductModel->find($this->_get->toInt('id'))->getData();
			if (count($arr) === 0)
			{
				pjUtil::redirect(sprintf("%s?controller=pjAdminProducts&action=pjActionIndex&err=%s", $_SERVER['PHP_SELF'], 'AP08'));
			}
			$pjMultiLangModel = pjMultiLangModel::factory();
			$arr['i18n'] = $pjMultiLangModel->getMultiLang($arr['id'], 'pjProduct');
			$this->set('arr', $arr);
			
			$this->set('category_arr', pjCategoryModel::factory()->getNode($this->getLocaleId(), 1));
			$this->set('pc_arr', pjProductCategoryModel::factory()->where('t1.product_id', $arr['id'])->orderBy('t1.category_id ASC')->findAll()->getDataPair('category_id', 'category_id'));
			$extra_arr = pjExtraModel::factory()->where('t1.product_id', $arr['id'])->findAll()->getData();
			$pjExtraItemModel = pjExtraItemModel::factory();
			foreach ($extra_arr as $k => $extra)
			{
				$extra_arr[$k]['i18n'] = $pjMultiLangModel->reset()->getMultiLang($extra['id'], 'pjExtra');
				$extra_arr[$k]['extra_items'] = $pjExtraItemModel->reset()->where('t1.extra_id', $extra['id'])->orderBy('t1.price ASC')->findAll()->getData();
				foreach ($extra_arr[$k]['extra_items'] as $key => $val)
				{
					$extra_arr[$k]['extra_items'][$key]['i18n'] = $pjMultiLangModel->reset()->getMultiLang($val['id'], 'pjExtraItem');
				}
			}
			$this->set('extra_arr', $extra_arr);
			$attr_arr = array();
			// Do not change col_name, direction
			$a_arr = pjAttributeModel::factory()
				->select('t1.id, t1.product_id, t1.parent_id, t1.hash, t2.content AS name')
				->join('pjMultiLang', "t2.model='pjAttribute' AND t2.foreign_id=t1.id AND t2.field='name' AND t2.locale='".$this->getLocaleId()."'", 'left outer')
				->where('t1.product_id', $arr['id'])
				->orderBy('t1.`order_group`, `order_item` ASC')->findAll()->getData();
			
			foreach ($a_arr as $attr)
			{
				$attr['i18n'] = $pjMultiLangModel->reset()->getMultiLang($attr['id'], 'pjAttribute');
				if ((int) $attr['parent_id'] === 0)
				{
					$attr_arr[$attr['id']] = $attr;
				} else {
					if (!isset($attr_arr[$attr['parent_id']]['child']))
					{
						$attr_arr[$attr['parent_id']]['child'] = array();
					}
					$attr_arr[$attr['parent_id']]['child'][] = $attr;
				}
			}
			$this->set('attr_arr', array_values($attr_arr));
			
			$stock_arr = pjStockModel::factory()
				->select('t1.*, t2.small_path')
				->join('pjGallery', 't2.id=t1.image_id', 'left outer')
				->where('t1.product_id', $arr['id'])
				->findAll()
				->getData();

			$pjStockAttributeModel = pjStockAttributeModel::factory();
			foreach ($stock_arr as $k => $stock)
			{
				$stock_arr[$k]['attrs'] = $pjStockAttributeModel->reset()
					->where('t1.stock_id', $stock['id'])
					->orderBy('t1.attribute_id ASC')
					->findAll()
					->getDataPair('attribute_parent_id', 'attribute_id');
			}
			$this->set('stock_arr', $stock_arr);
			
			$this->setLocalesData();
			
			$gallery_arr = pjGalleryModel::factory()
				->where('t1.foreign_id', $this->_get->toInt('id'))
				->orderBy('ISNULL(t1.sort), t1.sort ASC, t1.id ASC')
				->findAll()
				->getData();
			$this->set('gallery_arr', $gallery_arr);
			
			pjGalleryModel::factory()->where('foreign_id', $this->_get->toInt('id'))->modifyAll(array('model'=>'pjProduct'));
						
			$this->set('has_update', pjAuth::factory('pjAdminProducts', 'pjActionUpdate')->hasAccess());
			
			# Gallery plugin
            $this->appendCss('pj-gallery.css', pjObject::getConstant('pjGallery', 'PLUGIN_CSS_PATH'));
            $this->appendJs('ajaxupload.js', PJ_THIRD_PARTY_PATH . 'ajaxupload/');
            $this->appendJs('jquery.gallery.js', pjObject::getConstant('pjGallery', 'PLUGIN_JS_PATH'));
			
			$this->appendCss('css/select2.min.css', PJ_THIRD_PARTY_PATH . 'select2/');
			$this->appendJs('js/select2.full.min.js', PJ_THIRD_PARTY_PATH . 'select2/');
			
			$this->appendJs('jquery.datagrid.js', PJ_FRAMEWORK_LIBS_PATH . 'pj/js/');		
			$this->appendJs('tinymce.min.js', PJ_THIRD_PARTY_PATH . 'tinymce/');			
			$this->appendJs('jquery.multilang.js', PJ_FRAMEWORK_LIBS_PATH . 'pj/js/');
			$this->appendJs('pjQueryAutocompleter.min.js')
				->appendJs('pjAdminProducts.js');
		}
	}
	
	private function pjActionTurnI18n($data, $key, $id, $index=NULL, $new_key=NULL)
	{
		$arr = array();
		$arr_index = is_null($new_key) ? $key : $new_key;
		foreach ($data as $locale => $locale_arr)
		{
			$arr[$locale] = array(
				$arr_index => is_null($index) ?
					(isset($locale_arr[$key]) && isset($locale_arr[$key][$id]) ? $locale_arr[$key][$id] : NULL) :
					(isset($locale_arr[$key]) && isset($locale_arr[$key][$id]) && isset($locale_arr[$key][$id][$index]) ? $locale_arr[$key][$id][$index] : NULL)
			);
		}
		
		return $arr;
	}

	public function pjActionLoadImages()
	{
		$this->setAjax(true);
		
		if ($this->isXHR() && $this->isLoged())
		{
			$arr = array();
			if ($this->_get->check('product_id') && $this->_get->toInt('product_id') > 0)
			{
				$arr = pjGalleryModel::factory()->where('t1.foreign_id', $this->_get->toInt('product_id'))->orderBy('ISNULL(t1.sort), t1.sort ASC, t1.id ASC')->findAll()->getData();
			} elseif ($this->_get->check('hash') && $this->_get->toString('hash') != '') {
				$arr = pjGalleryModel::factory()->where('t1.hash', $this->_get->toString('hash'))->orderBy('ISNULL(t1.sort), t1.sort ASC, t1.id ASC')->findAll()->getData();
			}
			
			$this->set('arr', $arr);
		}
	}
	
	public function pjActionCheckStockAttributes()
	{
	    $this->setAjax(true);
	    
	    if ($this->isXHR() && $this->isLoged())
	    {   
	       $i_arr = array();	       
	       if ($stock_qty_arr = $this->_post->toArray('stock_qty'))
	       {
	           $stock_image_id_arr = $this->_post->toArray('stock_image_id');
	           $stock_attribute_arr = $this->_post->toArray('stock_attribute');
	           foreach ($stock_qty_arr as $k => $whatever)
	           {
	               $tmp = array();
	               if (isset($stock_attribute_arr[$k]))
	               {
	                   foreach ($stock_attribute_arr[$k] as $attr_parent_id => $attr_id)
	                   {
	                       $tmp[] = $stock_image_id_arr[$k] . ":" . $attr_parent_id . ":" . $attr_id;
	                   }
	               }
	               asort($tmp);
	               $str_key = join("|", $tmp);
	               if (in_array($str_key, $i_arr)) {
	                   pjAppController::jsonResponse(array('status' => 'ERR'));
	                   break;
	               }
	               $i_arr[] = $str_key;
	           }
	       }
	       pjAppController::jsonResponse(array('status' => 'OK'));
	    }
	}
}
?>
