<?php
/**
 * Orchestration for issuing invoices and estimates.
 *
 * This service turns a work order into a frozen document exactly once. On the
 * first issue of a given type it assigns an atomic number, snapshots the live
 * totals, line items, notes and shop branding, writes the immutable record, and
 * logs a single timeline event. On every later request for the same
 * (work_order_id, doc_type) pair it returns the existing frozen record with no
 * new number and no new event, so a reprint always reproduces the original
 * figures (INV-02 / INV-03 / INV-06). The freeze source is the existing
 * work-order calculators read once at issue time; reprints render only from the
 * stored snapshot.
 *
 * @package WrenchPress
 */

defined( 'ABSPATH' ) || exit;

/**
 * Class WrenchPress_Document_Service
 */
class WrenchPress_Document_Service {

	/**
	 * Issue a document once, or return the already-issued frozen record.
	 *
	 * Idempotent by design. If a document already exists for the pair it is
	 * returned verbatim with no counter call and no event (the reuse branch,
	 * D-04/D-05). Otherwise a number is assigned, the snapshot is frozen, and the
	 * record is created; the timeline event is logged only after a successful
	 * create (INV-06 first-issue-only).
	 *
	 * Race recovery (B1, INV-02/D-04, threat T-07-05-01): if two requests issue
	 * the same pair concurrently, the loser's create() fails against the unique
	 * (work_order_id, doc_type) key and returns false. In that case the method
	 * does NOT log an event and does NOT return false; it re-reads and returns the
	 * winning record via find(), so it always resolves to the co-issued frozen
	 * document. The extra sequence number the loser consumed is an acceptable gap.
	 *
	 * @param int         $work_order_id  Work-order id.
	 * @param string      $doc_type       'invoice' or 'estimate'.
	 * @param string|null $notes_override Optional notes body; falls back to the
	 *                                    Settings notes default when null.
	 * @return object|null The frozen document record, or null when the work order
	 *                     does not exist (nothing to issue) or a document number
	 *                     could not be allocated (fail-safe, no record written).
	 */
	public static function issue_or_get( $work_order_id, $doc_type, $notes_override = null ) {
		$existing = WrenchPress_Documents_Repository::find( $work_order_id, $doc_type );

		if ( $existing ) {
			// find() returns the raw row with line_items/branding still JSON-encoded;
			// re-read through get() so the caller always receives the decoded snapshot
			// shape the template renders from (INV-03 reprint fidelity).
			return WrenchPress_Documents_Repository::get( (int) $existing->id );
		}

		// Build the snapshot before consuming a number so a missing work order
		// never burns a counter value.
		$snapshot = self::build_snapshot( $work_order_id, $doc_type, $notes_override );

		if ( null === $snapshot ) {
			return null;
		}

		$number = WrenchPress_Document_Counters::next( $doc_type );

		// A 0 return means the counter could not allocate a number (a missing or
		// unseeded counter row). Never persist a document with doc_number = 0;
		// treat it as a failure to issue so the caller degrades to an error notice
		// rather than emitting a bad document number.
		if ( $number < 1 ) {
			return null;
		}

		$id = WrenchPress_Documents_Repository::create( $work_order_id, $doc_type, $number, $snapshot );

		if ( ! $id ) {
			// A concurrent request won the unique key; re-read and return the
			// winner (decoded via get()) without logging a duplicate event.
			$winner = WrenchPress_Documents_Repository::find( $work_order_id, $doc_type );

			return $winner ? WrenchPress_Documents_Repository::get( (int) $winner->id ) : null;
		}

		WrenchPress_Events_Repository::log_document( $work_order_id, $doc_type, $number );

		return WrenchPress_Documents_Repository::get( $id );
	}

	/**
	 * Derive the default document type from a work-order status (D-03).
	 *
	 * An estimate-status work order defaults to an estimate; every other issuable
	 * status defaults to an invoice. The 'new' status is refused upstream (07-06)
	 * and never reaches here.
	 *
	 * @param string $wo_status Work-order status slug.
	 * @return string 'estimate' or 'invoice'.
	 */
	public static function default_doc_type( $wo_status ) {
		return ( 'estimate' === (string) $wo_status ) ? 'estimate' : 'invoice';
	}

	/**
	 * Freeze the live figures, line items, notes and branding for a work order.
	 *
	 * Reads the existing calculators exactly once (totals() and
	 * list_for_work_order()) and captures the shop identity and currency settings
	 * as they stand at issue time, so the frozen document keeps its original
	 * branding even if the shop later changes its name, logo or currency. The
	 * returned array is the exact shape the repository create() binds and the
	 * template (07-06) reads back after json_decode.
	 *
	 * @param int         $work_order_id  Work-order id.
	 * @param string      $doc_type       'invoice' or 'estimate' (recorded by the
	 *                                    caller; kept for signature parity and
	 *                                    future per-type snapshot rules).
	 * @param string|null $notes_override Optional notes body; falls back to the
	 *                                    Settings notes default when null.
	 * @return array<string,mixed>|null The snapshot, or null when the work order
	 *                                  does not exist.
	 */
	public static function build_snapshot( $work_order_id, $doc_type, $notes_override = null ) {
		unset( $doc_type );

		$work_order = WrenchPress_Work_Orders_Repository::get( $work_order_id );

		if ( ! $work_order ) {
			return null;
		}

		$totals = WrenchPress_Work_Order_Items_Repository::totals( $work_order->id, $work_order->tax_rate );
		$items  = WrenchPress_Work_Order_Items_Repository::list_for_work_order( $work_order->id );

		$settings = WrenchPress_Settings_Page::get();

		$notes = ( null !== $notes_override )
			? (string) $notes_override
			: (string) $settings['document_notes_default'];

		return array(
			'labor'       => $totals['labor'],
			'parts'       => $totals['parts'],
			'subtotal'    => $totals['subtotal'],
			'tax'         => $totals['tax'],
			'grand_total' => $totals['grand_total'],
			'tax_rate'    => (string) $work_order->tax_rate,
			'line_items'  => $items,
			'notes'       => $notes,
			'branding'    => array(
				'shop_name'          => WrenchPress_Settings_Page::shop_name(),
				'shop_address'       => (string) $settings['shop_address'],
				'shop_phone'         => (string) $settings['shop_phone'],
				'shop_email'         => WrenchPress_Settings_Page::shop_email(),
				'shop_tax_number'    => (string) $settings['shop_tax_number'],
				'shop_logo_id'       => (int) $settings['shop_logo_id'],
				'currency_symbol'    => (string) $settings['currency_symbol'],
				'currency_code'      => (string) $settings['currency_code'],
				'currency_placement' => WrenchPress_Settings_Page::currency_placement(),
			),
		);
	}
}
