<?php
/**
 * Credit note document type.
 *
 * @package Kramar
 * @since   1.0.0
 */

declare( strict_types=1 );

namespace Kramar\Modules\Invoices;

defined( 'ABSPATH' ) || exit;

/**
 * Credit note — linked to a WC refund, references original invoice.
 *
 * @since 1.0.0
 */
final class Credit_Note extends Document {

	private \WC_Order_Refund $refund;

	/**
	 * @since 1.0.0
	 *
	 * @param \WC_Order        $order    Parent order.
	 * @param \WC_Order_Refund $refund   The refund object.
	 * @param array            $settings Module settings.
	 */
	public function __construct( \WC_Order $order, \WC_Order_Refund $refund, array $settings ) {
		parent::__construct( $order, $settings );
		$this->refund = $refund;
	}

	/** {@inheritDoc} */
	public function get_type(): string {
		return 'credit-note';
	}

	/** {@inheritDoc} */
	public function get_title_label(): string {
		return __( 'CREDIT NOTE', 'kramar' );
	}

	/**
	 * @since 1.0.0
	 */
	public function get_refund(): \WC_Order_Refund {
		return $this->refund;
	}

	/**
	 * Get the original invoice number this credit note references.
	 *
	 * @since 1.0.0
	 */
	public function get_original_invoice_number(): string {
		return (string) $this->order->get_meta( '_kramar_invoice_number' );
	}

	/**
	 * Assign a credit note number.
	 *
	 * @since 1.0.0
	 *
	 * @return string Formatted credit note number.
	 */
	public function assign_number(): string {
		$existing = $this->refund->get_meta( '_kramar_credit_note_number' );
		if ( '' !== $existing && false !== $existing ) {
			return (string) $existing;
		}

		// Credit notes draw from the shared invoice number series (the table-wide
		// AUTO_INCREMENT counter). A truly separate, gapless series would need its
		// own counter + prefix; the half-built 'separate' option was removed. See L8.
		$raw = Number_Store::get_next_number( $this->refund->get_id(), 'invoice' );

		$formatted = Number_Store::format_number(
			$raw,
			$this->settings['number_prefix'] ?? '',
			$this->settings['number_suffix'] ?? '',
			(int) ( $this->settings['number_padding'] ?? 4 )
		);

		$this->refund->update_meta_data( '_kramar_credit_note_number', $formatted );
		$this->refund->update_meta_data( '_kramar_credit_note_date', current_time( 'mysql' ) );
		$this->refund->save_meta_data();

		return $formatted;
	}

	/** {@inheritDoc} */
	public function get_document_data(): array {
		$data                   = parent::get_document_data();
		$existing_cn            = (string) $this->refund->get_meta( '_kramar_credit_note_number' );
		if ( $this->preview && '' === $existing_cn ) {
			// Display-only placeholder — no DB write, no sequence consumed.
			$data['number'] = Number_Store::format_number(
				1,
				$this->settings['number_prefix'] ?? '',
				$this->settings['number_suffix'] ?? '',
				(int) ( $this->settings['number_padding'] ?? 4 )
			);
		} else {
			$data['number'] = $this->assign_number();
		}
		$data['original_invoice'] = $this->get_original_invoice_number();

		$cn_date = (string) $this->refund->get_meta( '_kramar_credit_note_date' );
		if ( '' !== $cn_date ) {
			$dt = \DateTime::createFromFormat( 'Y-m-d H:i:s', $cn_date );
			if ( $dt ) {
				$data['date'] = wp_date( wc_date_format(), $dt->getTimestamp() );
			}
		}

		return $data;
	}

	/**
	 * Get refunded line items (negative amounts).
	 *
	 * @since 1.0.0
	 * @return list<array{name:string,sku:string,quantity:int,unit_price:float,total:float,tax_rate:float,tax_amount:float,image_url:string,meta:string}>
	 */
	public function get_refund_items(): array {
		$items = [];

		foreach ( $this->refund->get_items() as $item ) {
			$product = $item->get_product();
			$qty     = abs( $item->get_quantity() );
			$total   = abs( (float) $item->get_total() );
			$tax     = abs( (float) $item->get_total_tax() );

			$tax_rate   = $total > 0 ? round( ( $tax / $total ) * 100, 1 ) : 0.0;
			$unit_price = $qty > 0 ? $total / $qty : 0.0;

			$items[] = [
				'name'       => $item->get_name(),
				'sku'        => $product instanceof \WC_Product ? $product->get_sku() : '',
				'quantity'   => $qty,
				'unit_price' => -$unit_price,
				'total'      => -$total,
				'tax_rate'   => $tax_rate,
				'tax_amount' => -$tax,
				'image_url'  => '',
				'meta'       => '',
			];
		}

		// An amount-only refund has no line items to itemise, but a credit note
		// with an empty table describes nothing. Fall back to a single line
		// carrying the derived net and VAT, so the table, the VAT breakdown and
		// the total all agree.
		if ( empty( $items ) ) {
			$totals = $this->get_refund_totals();

			if ( abs( $totals['total'] ) >= 0.005 ) {
				$net = (float) $totals['subtotal'];
				$vat = (float) $totals['tax'];
				// The order's actual rate, not one re-derived from the rounded
				// net and VAT above — that reports 21% VAT as "21.1%".
				$rate = $this->get_parent_vat_rate();

				$reason = trim( (string) $this->refund->get_reason() );

				$items[] = [
					'name'       => '' !== $reason ? $reason : __( 'Refund', 'kramar' ),
					'sku'        => '',
					'quantity'   => 1,
					'unit_price' => $net,
					'total'      => $net,
					'tax_rate'   => $rate,
					'tax_amount' => $vat,
					'image_url'  => '',
					'meta'       => '',
				];
			}
		}

		return $items;
	}

	/**
	 * @since 1.0.0
	 * @return array{subtotal:float,shipping:float,discount:float,tax:float,total:float}
	 */
	public function get_refund_totals(): array {
		$totals = [
			'subtotal' => (float) $this->refund->get_subtotal(),
			'shipping' => (float) $this->refund->get_shipping_total(),
			'discount' => 0.0,
			'tax'      => (float) $this->refund->get_total_tax(),
			'total'    => (float) $this->refund->get_total(),
		];

		// A refund entered as a plain amount — WooCommerce's default admin flow,
		// where a figure is typed into the refund box without picking line items
		// — carries no line items and no tax lines at all, so subtotal and tax
		// both read 0 while the total is the full refunded gross. Rendered as-is
		// that produces a credit note showing "Subtotal 0,00", no VAT line, and a
		// gross total that reconciles with nothing: not a valid corrective
		// invoice under Directive 2006/112/EC art. 226(8)-(10). Derive the split
		// from the VAT rate the parent order was actually taxed at.
		if ( abs( $totals['tax'] ) < 0.005 && abs( $totals['subtotal'] ) < 0.005 && abs( $totals['total'] ) >= 0.005 ) {
			$rate = $this->get_parent_vat_rate();

			// Prefer the per-rate apportionment when the order carries more than
			// one rate, so the Subtotal and VAT printed here are the sum of the
			// very rows the breakdown shows. Deriving them separately from a single
			// blended rate is what let the two disagree.
			$split = $this->apportion_refund( abs( (float) $totals['total'] ) );
			if ( ! empty( $split ) ) {
				$net_sum = 0.0;
				$tax_sum = 0.0;
				foreach ( $split as $row ) {
					$net_sum += $row['net'];
					$tax_sum += $row['tax'];
				}

				$totals['subtotal'] = -round( $net_sum, 2 );
				$totals['tax']      = -round( $tax_sum, 2 );
			} elseif ( $rate > 0 ) {
				$gross = abs( $totals['total'] );
				$net   = round( $gross / ( 1 + ( $rate / 100 ) ), 2 );

				// Credit-note figures are negative, matching the line items.
				$totals['subtotal'] = -$net;
				$totals['tax']      = -round( $gross - $net, 2 );
			} else {
				// The order carried no VAT (0% rate, reverse charge, or a
				// tax-free shop): the whole refund is net, and no VAT line is
				// correct rather than missing.
				$totals['subtotal'] = $totals['total'];
			}
		}

		return $totals;
	}

	/**
	 * {@inheritDoc}
	 *
	 * WooCommerce drops a zero-value tax entry when it writes a refund line, so
	 * a zero-rated item is credited with no rate recorded against it at all —
	 * unlike on the order, where the zero entry survives. The credit note then
	 * had no 0% row and no taxable base for that amount, while the invoice for
	 * the same goods showed both. Art. 226(8) wants the taxable amount per rate
	 * on either document. Recover the rate from the order line the refund points
	 * at, crediting it at zero tax.
	 *
	 * @since 1.0.2
	 */
	protected function resolve_item_tax_rates( \WC_Order_Item $item ): array {
		$parent_item_id = (int) $item->get_meta( '_refunded_item_id' );
		if ( $parent_item_id <= 0 ) {
			return [];
		}

		$parent_item = $this->order->get_item( $parent_item_id );
		if ( ! $parent_item instanceof \WC_Order_Item ) {
			return [];
		}

		$taxes = $parent_item->get_taxes();
		$per   = isset( $taxes['total'] ) && is_array( $taxes['total'] ) ? $taxes['total'] : [];

		$rates = [];
		foreach ( array_keys( $per ) as $rate_id ) {
			$rates[ $rate_id ] = 0.0;
		}

		return $rates;
	}

	/**
	 * {@inheritDoc}
	 *
	 * Fees credited by this refund, not the parent order's fees.
	 *
	 * @since 1.0.2
	 */
	public function get_fee_lines(): array {
		return $this->collect_fee_lines( $this->refund );
	}

	/**
	 * {@inheritDoc}
	 *
	 * A credit note breaks down the VAT it credits, not the parent order's. The
	 * renderer used to derive the base here as tax divided by rate, which lands
	 * a cent or two off the refund's own subtotal — "Subtotal -24,79" printed
	 * directly above "VAT 21% over -24,81" on the same document, on any
	 * tax-inclusive price whose net and tax do not divide evenly. Summing the
	 * refund's own lines, exactly as an invoice does, makes the base the number
	 * the subtotal is built from.
	 *
	 * @since 1.0.2
	 */
	public function get_totals_by_vat_rate(): array {
		$totals = $this->get_refund_totals();
		$rates  = $this->compute_vat_rates( $this->refund, $totals );

		if ( ! empty( $rates ) ) {
			return $rates;
		}

		// A refund entered as a plain amount carries no items and no tax lines at
		// all, so there is nothing to sum. get_refund_totals() has already derived
		// the net/VAT split from the parent order's rate; mirror it as a single
		// row so the breakdown still reconciles with the total below it.
		$tax = (float) ( $totals['tax'] ?? 0.0 );
		$net = (float) ( $totals['subtotal'] ?? 0.0 );

		if ( abs( $tax ) < 0.005 ) {
			return [];
		}

		// One apportionment, shared with get_refund_totals() so the rows and the
		// Subtotal/VAT/Total above them are derived from the same numbers.
		$rows = $this->apportion_refund( abs( (float) ( $totals['total'] ?? 0.0 ) ) );

		if ( empty( $rows ) ) {
			return [
				[
					'rate'     => $this->get_parent_vat_rate(),
					'label'    => __( 'VAT', 'kramar' ),
					'subtotal' => round( $net, 2 ),
					'tax'      => round( $tax, 2 ),
				],
			];
		}

		$out = [];
		foreach ( $rows as $row ) {
			$out[] = [
				'rate'     => $row['rate'],
				'label'    => $row['label'],
				'subtotal' => -$row['net'],
				'tax'      => -$row['tax'],
			];
		}

		return $out;
	}

	/**
	 * Split an amount-only refund across the rates the order really carries.
	 *
	 * An amount-only refund — a figure typed into WooCommerce's refund box, its
	 * default flow — has no line items, so there is nothing to sum and the split
	 * has to come from the parent order. Weighting by each rate's share of the
	 * order's gross keeps every row internally true: the gross apportioned to a
	 * rate divided by (1 + rate) is that row's net, and the remainder is its tax,
	 * so each row sums to the gross it was given, and base x rate matches
	 * the tax beside it to within a cent — the rounding limit, not an exact
	 * identity: a 10,00 gross at 21% is 8,26 + 1,74, while 8,26 x 21% is 1,73.
	 *
	 * An earlier attempt derived each row's tax from the parent's tax shares but
	 * gave the final row whatever net was left over from a separately computed
	 * total — two numbers with no arithmetic relationship, which printed rows
	 * like "VAT 9% (over -8,22): -0,40" where 9% of 8,22 is 0,74. Before that it
	 * printed a single blended "VAT 17,07%", a rate nobody was charged.
	 *
	 * @since 1.0.2
	 *
	 * @param float $gross Positive gross amount being credited.
	 * @return list<array{rate:float,label:string,net:float,tax:float}> Empty when
	 *         the order carries fewer than two rates and no split is needed.
	 */
	private function apportion_refund( float $gross ): array {
		if ( $gross < 0.005 ) {
			return [];
		}

		$parent = [];
		$total_gross = 0.0;

		foreach ( $this->compute_vat_rates( $this->order, $this->get_grand_totals() ) as $row ) {
			$row_gross = (float) $row['subtotal'] + (float) $row['tax'];
			if ( abs( $row_gross ) < 0.005 ) {
				continue;
			}

			$parent[] = [
				'rate'  => (float) $row['rate'],
				'label' => (string) $row['label'],
				'gross' => $row_gross,
			];
			$total_gross += $row_gross;
		}

		if ( count( $parent ) < 2 || abs( $total_gross ) < 0.005 ) {
			return [];
		}

		$rows      = [];
		$remaining = round( $gross, 2 );
		$last      = count( $parent ) - 1;

		foreach ( $parent as $i => $row ) {
			// The last row takes what is left, so the split is exact to the cent.
			$share = $i === $last
				? $remaining
				: round( $gross * ( $row['gross'] / $total_gross ), 2 );

			if ( $i !== $last ) {
				$remaining = round( $remaining - $share, 2 );
			}

			$net = round( $share / ( 1 + ( $row['rate'] / 100 ) ), 2 );

			$rows[] = [
				'rate'  => $row['rate'],
				'label' => $row['label'],
				'net'   => $net,
				'tax'   => round( $share - $net, 2 ),
			];
		}

		return $rows;
	}

	/**
	 * The VAT rate the parent order was taxed at, for deriving the split of a
	 * refund that carries no tax lines of its own.
	 *
	 * Prefers the order's own rate when everything on it was taxed identically —
	 * the overwhelmingly common case. Falls back to the order's effective rate
	 * (tax over net) when it mixes rates, which is the closest single rate that
	 * still makes the credit note reconcile.
	 *
	 * @since 1.0.2
	 */
	private function get_parent_vat_rate(): float {
		$rates = [];

		foreach ( $this->order->get_items( 'tax' ) as $tax_item ) {
			$pct = $this->rate_percent_for( (int) $tax_item->get_rate_id() );
			if ( null !== $pct && $pct > 0 ) {
				$rates[] = round( $pct, 4 );
			}
		}

		$rates = array_values( array_unique( $rates ) );
		if ( 1 === count( $rates ) ) {
			return $rates[0];
		}

		$order_tax = (float) $this->order->get_total_tax();
		$order_net = (float) $this->order->get_total() - $order_tax;

		return $order_net > 0 ? round( ( $order_tax / $order_net ) * 100, 2 ) : 0.0;
	}

	/**
	 * Generate and store credit note PDF.
	 *
	 * @since 1.0.0
	 *
	 * @return string Relative path to stored file.
	 */
	public function generate_and_store(): string {
		$existing = $this->refund->get_meta( '_kramar_credit_note_file' );
		if ( '' !== $existing && false !== $existing ) {
			return (string) $existing;
		}

		$number = $this->assign_number();
		$html   = Block_Renderer::render( $this );
		$pdf    = Pdf_Renderer::render( $html, $this->settings['paper_size'] ?? 'a4' );

		$rel_path = Storage::store( $pdf, 'credit-note', $number );

		$this->refund->update_meta_data( '_kramar_credit_note_file', $rel_path );
		$this->refund->save_meta_data();

		return $rel_path;
	}

	/**
	 * @since 1.0.0
	 */
	public function get_filename(): string {
		$number = $this->refund->get_meta( '_kramar_credit_note_number' );
		$safe   = preg_replace( '/[^a-zA-Z0-9\-]/', '', (string) $number );
		return 'credit-note-' . $safe . '.pdf';
	}
}
