<?php

namespace SoftminalB2B\Pricing;

if (!defined('ABSPATH')) {
    exit;
}

/**
 * The result of one precedence walk: the price, which level produced it, and
 * the trail of everything considered along the way.
 *
 * The trail is what the A7 Price Explainer renders. It is produced by the real
 * walk rather than a second implementation, so the explanation can never
 * disagree with the price the shopper is charged.
 */
class ResolvedPrice
{
    /** Precedence levels, highest wins (FLOWS §7). */
    public const SOURCE_QUOTE         = 'quote';
    public const SOURCE_CUSTOMER      = 'customer';
    public const SOURCE_COMPANY       = 'company';
    public const SOURCE_GROUP_PRODUCT = 'group_product';
    public const SOURCE_GROUP_TAXONOMY = 'group_taxonomy';

    /**
     * A rule scoped to one marketplace vendor's whole catalogue. Has no rule
     * source in core — the `softminal-b2b-for-dokan` bridge supplies it through
     * the `smb2b_pricing_level_vendor` filter. Ranks below any product/taxonomy
     * rule but above the store-wide global band (the locked precedence ladder:
     * customer > company > product > category > tag > vendor > global).
     */
    public const SOURCE_VENDOR        = 'vendor';
    public const SOURCE_GROUP_GLOBAL  = 'group_global';
    public const SOURCE_WOO           = 'woo';

    private float $price;
    private float $basePrice;
    private string $source;
    private ?int $ruleId;
    private int $qty;

    /** @var array<int, array{level:string, outcome:string, detail:string, rule_id:int|null}> */
    private array $trail;

    /**
     * @param array<int, array{level:string, outcome:string, detail:string, rule_id:int|null}> $trail
     */
    public function __construct(
        float $price,
        float $basePrice,
        string $source,
        ?int $ruleId = null,
        int $qty = 1,
        array $trail = []
    ) {
        $this->price     = $price;
        $this->basePrice = $basePrice;
        $this->source    = $source;
        $this->ruleId    = $ruleId;
        $this->qty       = $qty;
        $this->trail     = $trail;
    }

    public function price(): float
    {
        return $this->price;
    }

    /**
     * A copy of this result at a new price, keeping the base, provenance and
     * trail intact. The ergonomic way for a `smb2b_resolved_price` handler to
     * adjust the charged price without rebuilding the whole object; the trail
     * gains a note so the Price Explainer shows the override happened.
     */
    public function withPrice(float $price, string $reason = 'filtered'): self
    {
        $trail   = $this->trail;
        $trail[] = [
            'level'   => 'filter',
            'outcome' => 'applied',
            'detail'  => $reason,
            'rule_id' => null,
        ];

        return new self($price, $this->basePrice, $this->source, $this->ruleId, $this->qty, $trail);
    }

    public function basePrice(): float
    {
        return $this->basePrice;
    }

    public function source(): string
    {
        return $this->source;
    }

    public function ruleId(): ?int
    {
        return $this->ruleId;
    }

    public function qty(): int
    {
        return $this->qty;
    }

    /**
     * @return array<int, array{level:string, outcome:string, detail:string, rule_id:int|null}>
     */
    public function trail(): array
    {
        return $this->trail;
    }

    /**
     * True when a B2B rule produced this price (any source other than Woo's own).
     *
     * This is the gate for whether the resolved price should be *charged*. It is
     * deliberately distinct from isDiscounted(): a B2B rule wins the precedence
     * walk and must apply even when it lands above the current shop/sale price
     * (FLOWS §7 — "the shop price is lower, but the B2B rule takes priority").
     * A fixed contract price and a percent-off-regular price are both meant to
     * hold regardless of a temporary storewide sale.
     */
    public function applies(): bool
    {
        return $this->source !== self::SOURCE_WOO;
    }

    /**
     * True only when the resolved price is an actual saving off the current shop
     * price. Governs *presentation* (strike-through, savings badge), never
     * whether the price is charged — use applies() for that.
     */
    public function isDiscounted(): bool
    {
        return $this->source !== self::SOURCE_WOO && $this->price < $this->basePrice;
    }

    public function savings(): float
    {
        return max(0.0, $this->basePrice - $this->price);
    }

    public function savingsPercent(): float
    {
        if ($this->basePrice <= 0) {
            return 0.0;
        }

        return round(($this->savings() / $this->basePrice) * 100, 2);
    }

    /**
     * Plain-language summary for the Price Explainer. Deliberately jargon-free:
     * the UX Doctrine forbids words like "precedence" in merchant-facing copy.
     */
    public function explain(): string
    {
        $lines = [];

        foreach ($this->trail as $step) {
            $lines[] = $step['detail'];
        }

        if (empty($lines)) {
            return __('The normal shop price applies.', 'softminal-b2b-wholesale-for-woocommerce');
        }

        return implode(' ', $lines);
    }

    /**
     * @return array<string, mixed>
     */
    public function toArray(): array
    {
        return [
            'price'           => $this->price,
            'base_price'      => $this->basePrice,
            'source'          => $this->source,
            'rule_id'         => $this->ruleId,
            'qty'             => $this->qty,
            'is_discounted'   => $this->isDiscounted(),
            'savings'         => $this->savings(),
            'savings_percent' => $this->savingsPercent(),
            'trail'           => $this->trail,
            'explanation'     => $this->explain(),
        ];
    }
}
