<?php

/*
Есть продукты A, B, C, D, E, F, G, H, I, J, K, L, M. Каждый продукт стоит определенную сумму.
Есть набор правил расчета итоговой суммы:
Если одновременно выбраны А и B, то их суммарная стоимость уменьшается на 10% (для каждой пары А и B)
Если одновременно выбраны D и E, то их суммарная стоимость уменьшается на 5% (для каждой пары D и E)
Если одновременно выбраны E,F,G, то их суммарная стоимость уменьшается на 5% (для каждой тройки E,F,G)
Если одновременно выбраны А и один из [K,L,M], то стоимость выбранного продукта уменьшается на 5%
Если пользователь выбрал одновременно 3 продукта, он получает скидку 5% от суммы заказа
Если пользователь выбрал одновременно 4 продукта, он получает скидку 10% от суммы заказа
Если пользователь выбрал одновременно 5 продуктов, он получает скидку 20% от суммы заказа
Описанные скидки 5,6,7 не суммируются, применяется только одна из них
Продукты A и C не участвуют в скидках 5,6,7
Каждый товар может участвовать только в одной скидке. Скидки применяются последовательно в порядке описанном выше.

Необходимо написать программу на PHP с использованием ООП которая имея на входе набор продуктов (один продукт может встречаться несколько раз) рассчитывала суммарную их стоимость.
*/

class ProductCollection
{
    private $products;

    public function __construct(array $products = [])
    {
        foreach ($products as $product) {
            $this->addProduct($product);
        }
    }

    public function addProduct(Product $product)
    {
        $this->products[] = $product;
    }

    public function getByName($name)
    {
        foreach ($this->products as $product) {
            if ($product->name === $name) {
                return $product;
            }
        }
        return null;
    }

    public function getCount()
    {
        return count($this->products);
    }

    public function getWithoutNames(array $withoutNames)
    {
        $productsWithoutNames = array_filter(
            $this->products,
            function (Product $p) use ($withoutNames) {
                return !in_array($p->name, $withoutNames);
            }
        );

        return new self($productsWithoutNames);
    }

    public function getSum()
    {
        return array_reduce(
            $this->products,
            function ($prev, $next) { return $prev + $next->price; },
            0
        );
    }

    public function hasNames(array $names)
    {
        $productsNames = array_map(function ($p) { return $p->name; }, $this->products);
        return !array_diff($names, $productsNames);
    }

    public function getOneOfName(array $names)
    {
        foreach ($this->products as $product) {
            if (in_array($product->name, $names)) {
                return $product;
            }
        }
        return null;
    }

    public function deleteByNames(array $names)
    {
        $deleted = [];

        foreach ($names as $name) {
            $deleted[] = $this->deleteByName($name);
        }

        return new self($deleted);
    }

    public function deleteByName($name)
    {
        $productCount = count($this->products);
        for ($i = 0; $i < $productCount; $i++) {
            if ($this->products[$i]->name === $name) {
                return array_splice($this->products, $i, 1)[0];
            }
        }
        return null;
    }
}

class Product
{
    public $name;
    public $price;

    public function __construct($name, $price)
    {
        $this->name = $name;
        $this->price = $price;
    }
}

// Скидка, формирующаяся на основе количества элементов в списке товаров
class CountDiscount
{
	private $count;
	private $percent;
	private $withoutNames;
	
    public function __construct($count, $percent, array $withoutNames = [])
    {
        $this->count = $count;
        $this->percent = $percent;
        $this->withoutNames = $withoutNames;
    }

    public function match(ProductCollection $pc)
    {
        return $pc->getWithoutNames($this->withoutNames)->getCount() >= $this->count;
    }

    public function getPercent()
    {
        return $this->percent;
    }
}

interface CombinationDiscountInterface
{
    public function getSumAfterApplyingDiscount(ProductCollection $productCollection);
}

// Скидка, формирующаяся на основе комбинации определённых товаров
class CombinationDiscount implements CombinationDiscountInterface
{
    private $productNames;
    private $percent;

    public function __construct(array $productNames, $percent)
    {
        $this->productNames = $productNames;
        $this->percent = $percent;
    }

    public function getSumAfterApplyingDiscount(ProductCollection $pc)
    {
        if ($pc->hasNames($this->productNames)) {
            $deleted = $pc->deleteByNames($this->productNames);
            return $deleted->getSum() - $deleted->getSum() * $this->percent;
        }
        return 0;
    }
}

// Если одновременно выбраны А и один из [K,L,M], то стоимость выбранного продукта...
class CombinationOneOfDiscount implements CombinationDiscountInterface
{
    private $productName;
    private $oneOf;
    private $percent;

    public function __construct($productName, array $oneOf, $percent)
    {
        $this->productName = $productName;
        $this->oneOf = $oneOf;
        $this->percent = $percent;
    }

    public function getSumAfterApplyingDiscount(ProductCollection $pc)
    {
        $oneOf = $pc->getOneOfName($this->oneOf);
        $product = $pc->getByName($this->productName);
        if ($oneOf && $pc->hasNames([$this->productName])) {
            $pc->deleteByNames([$oneOf->name, $product->name]);
            return $product->price + ($oneOf->price - $oneOf->price * $this->percent);
        }
        return 0;
    }
}

class Calculator
{
    private $combinationDiscounts;
    private $productCollection;
    private $countDiscounts;
    
    public function __construct(ProductCollection $productCollection)
    {
        $this->productCollection = $productCollection;
        $this->combinationDiscounts = [];
        $this->countDiscounts = [];
    }
    
    public function addCombinationDiscount(CombinationDiscountInterface $cd)
    {
        $this->combinationDiscounts[] = $cd;
        return $this;
    }

    public function addCountDiscount(CountDiscount $cd)
    {
        $this->countDiscounts[] = $cd;
        return $this;
    }

    public function calculateTotalPrice()
    {
        $totalPrice = 0;
        $discountFromOrderSum = $this->getDiscountFromOrderSum();

        foreach ($this->combinationDiscounts as $combinationDiscount) {
            $totalPrice += $combinationDiscount->getSumAfterApplyingDiscount($this->productCollection);
        }

        $totalPrice += $this->productCollection->getSum();

        return $totalPrice - $discountFromOrderSum;
    }

    private function getDiscountFromOrderSum()
    {
        foreach ($this->countDiscounts as $countDiscount) {
            if ($countDiscount->match($this->productCollection)) {
                return $this->productCollection->getSum() * $countDiscount->getPercent();
            }
        }
        return 0;
    }
}

// ======================================================================

$pc = new ProductCollection([
    new Product('a', 100),
    new Product('b', 300),
    new Product('c', 200),
    new Product('d', 200),
    new Product('e', 100),
    new Product('c', 100),
]);

// Тесты класса ProductCollection
assert($pc->getCount() === 6);
assert($pc->hasNames(['a', 'c', 'e']) === true);
assert($pc->hasNames(['a', 'Z']) === false);
assert($pc->getOneOfName(['a', 'Z']) == true);
assert($pc->getOneOfName(['Q', 'Z']) == false);
assert($pc->getSum() === 1000);
assert($pc->getWithoutNames(['a', 'b', 'e'])->getSum() === 500);

$c = new Calculator($pc);
$c
    // Если пользователь выбрал одновременно 3 продукта, он получает скидку 5% от суммы заказа
    ->addCountDiscount(new CountDiscount(3, 0.05, ['a', 'c']))
    // Если пользователь выбрал одновременно 4 продукта, он получает скидку 10% от суммы заказа
    ->addCountDiscount(new CountDiscount(4, 0.1, ['a', 'c']))
    // Если пользователь выбрал одновременно 5 продуктов, он получает скидку 20% от суммы заказа
    ->addCountDiscount(new CountDiscount(5, 0.2, ['a', 'c']))
;

// Суммарно - 1000
// сработал первый Discount, т.к. осталось 3 товара после того, как избавились от всех A,C
// 1000 минус 5 процентов от 1000
assert($c->calculateTotalPrice() == 950);

// ======================================================================

$pc2 = new ProductCollection([
    new Product('a', 100),
    new Product('a', 100),
    new Product('a', 100),
    new Product('b', 100),
    new Product('b', 100),
]);

$pc2->deleteByNames(['a', 'a', 'b']);
assert($pc2->getCount() === 2);

// ======================================================================

$pc3 = new ProductCollection([
    new Product('a', 50),
    new Product('b', 50),
    new Product('e', 50),
    new Product('f', 25),
    new Product('g', 25),
]);

$c = new Calculator($pc3);
$c
    // Если одновременно выбраны А и B, то их суммарная стоимость уменьшается на 10% (для каждой пары А и B)
    ->addCombinationDiscount(new CombinationDiscount(['a', 'b'], 0.1))
    // Если одновременно выбраны E,F,G, то их суммарная стоимость уменьшается на 5% (для каждой тройки E,F,G)
    ->addCombinationDiscount(new CombinationDiscount(['e', 'f', 'g'], 0.05))
;

assert($c->calculateTotalPrice() == 90 + 95);

// ======================================================================

$pc4 = new ProductCollection([
    new Product('a', 100),
    new Product('k', 100),
    new Product('p', 100),
]);

$c = new Calculator($pc4);
$c
    // Если одновременно выбраны А и один из [K,L,M], то стоимость выбранного продукта уменьшается на 5%
    ->addCombinationDiscount(new CombinationOneOfDiscount('a', ['k', 'l', 'm'], 0.05))
;

assert($c->calculateTotalPrice() == 100 + 95 + 100);

// ======================================================================

$pc5 = new ProductCollection([
    new Product('a', 100), // 1 шаг
    new Product('b', 100), // 1 шаг
    new Product('c', 100), // 4 шаг
    new Product('d', 100), // 2 шаг 
    new Product('e', 100), // 2 шаг
    new Product('f', 100), // 4 шаг
    new Product('g', 100), // 4 шаг
    new Product('h', 100), // 4 шаг
    new Product('i', 100), // 4 шаг
    new Product('j', 100), // 3 шаг
    new Product('a', 100), // 3 шаг
]);

$c = new Calculator($pc5);
$c
    ->addCountDiscount(new CountDiscount(3, 0.05, ['a', 'c']))
    ->addCountDiscount(new CountDiscount(4, 0.1, ['a', 'c']))
    ->addCountDiscount(new CountDiscount(5, 0.2, ['a', 'c']))
    ->addCombinationDiscount(new CombinationDiscount(['a', 'b'], 0.1))
    ->addCombinationDiscount(new CombinationDiscount(['d', 'e'], 0.05))
    ->addCombinationDiscount(new CombinationDiscount(['f', 'e', 'g'], 0.05))
    ->addCombinationDiscount(new CombinationOneOfDiscount('a', ['k', 'j', 'm'], 0.05))
;

// За вычетом всех AC остаётся больше 5-и продуктов, от totalPrice нужно будет вычитать 20 процентов "суммы заказа"
// Сработал Discount для AB, теперь к totalPrice вместо 100 + 100 нужно добавить 90 + 90 = 180 (шаг 1)
// Сработал Discount для DE, к totalPrice вместо 200 нужно прибавить 95 + 95 = 190 (шаг 2)
// Не сработал Discount для FEG, так как E уже использовался
// CombinationOneOfDiscount сработал, так как есть j (шаг 3), выходит a + 5 процентов от j = 100 + 95
// Складываем оставшиеся cfghi = 5 * 100 (шаг 4)
// Считаем: 180 + 190 + 195 + 500 = 1065
// 20 процентов от 1100 это 55
assert($c->calculateTotalPrice() == 1065 - 55);