<?php
/**
 * Data-access layer for issued documents (invoices and estimates).
 *
 * A document row is a freeze-on-issue snapshot: once written it is never changed.
 * This repository therefore exposes create/find/get only and deliberately carries
 * no update() or delete() method, so an issued invoice or estimate can never be
 * silently rewritten. All reads and writes go through $wpdb with prepared
 * statements and an explicit format array; the only interpolated identifier is the
 * trusted, prefix-built table name. DECIMAL money columns (labor, parts, subtotal,
 * tax_rate, tax_amount, grand_total) are bound as strings, never floats, and the
 * variable-length line_items and branding snapshots are stored as JSON via
 * wp_json_encode and read back with json_decode; the table never uses PHP
 * object serialization (Security V5 / object-injection safe).
 *
 * @package WrenchPress
 */

defined( 'ABSPATH' ) || exit;

/**
 * Class WrenchPress_Documents_Repository
 */
class WrenchPress_Documents_Repository {

	/**
	 * Fully qualified documents table name.
	 *
	 * @return string
	 */
	protected static function table() {
		global $wpdb;

		return $wpdb->prefix . 'wrenchpress_documents';
	}

	/**
	 * Persist a frozen document snapshot and return its new integer id.
	 *
	 * The DECIMAL columns are cast to string and bound with %s (binding as %f
	 * would force a lossy float round-trip); ids are bound %d; the line_items and
	 * branding snapshots are wp_json_encode'd and bound %s. issued_at and issued_by
	 * are stamped here so the frozen record captures who issued it and when. The
	 * caller is responsible for having assigned $doc_number through the atomic
	 * counter before calling this.
	 *
	 * @param int    $work_order_id Work-order id the document belongs to.
	 * @param string $doc_type      'invoice' or 'estimate'.
	 * @param int    $doc_number    Assigned sequential number for this doc_type.
	 * @param array  $snapshot      labor, parts, subtotal, tax_rate, tax,
	 *                              grand_total, line_items, branding, notes.
	 * @return int|false New document id, or false on failure (including a raced
	 *                   insert that collides with the unique work_order_type key).
	 */
	public static function create( $work_order_id, $doc_type, $doc_number, array $snapshot ) {
		global $wpdb;

		$row = array(
			'work_order_id' => absint( $work_order_id ),
			'doc_type'      => (string) $doc_type,
			'doc_number'    => absint( $doc_number ),
			'labor'         => (string) $snapshot['labor'],
			'parts'         => (string) $snapshot['parts'],
			'subtotal'      => (string) $snapshot['subtotal'],
			'tax_rate'      => (string) $snapshot['tax_rate'],
			'tax_amount'    => (string) $snapshot['tax'],
			'grand_total'   => (string) $snapshot['grand_total'],
			'line_items'    => wp_json_encode( $snapshot['line_items'] ),
			'branding'      => wp_json_encode( $snapshot['branding'] ),
			'notes'         => (string) $snapshot['notes'],
			'issued_at'     => current_time( 'mysql' ),
			'issued_by'     => (int) get_current_user_id(),
		);

		$formats = array(
			'%d', // work_order_id.
			'%s', // doc_type.
			'%d', // doc_number.
			'%s', // labor (DECIMAL passed as string).
			'%s', // parts (DECIMAL passed as string).
			'%s', // subtotal (DECIMAL passed as string).
			'%s', // tax_rate (DECIMAL passed as string).
			'%s', // tax_amount (DECIMAL passed as string).
			'%s', // grand_total (DECIMAL passed as string).
			'%s', // line_items (JSON).
			'%s', // branding (JSON).
			'%s', // notes.
			'%s', // issued_at.
			'%d', // issued_by.
		);

		// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Custom plugin table write via the $wpdb array API, intentionally not object-cached.
		$inserted = $wpdb->insert( self::table(), $row, $formats );

		if ( false === $inserted ) {
			return false;
		}

		return (int) $wpdb->insert_id;
	}

	/**
	 * Find the single existing document for a work order and type.
	 *
	 * This is the D-04/D-05 reuse contract: the unique (work_order_id, doc_type)
	 * key guarantees at most one row, so a hit means the document was already
	 * issued and its frozen number and figures must be reused rather than a new
	 * number assigned.
	 *
	 * @param int    $work_order_id Work-order id.
	 * @param string $doc_type      'invoice' or 'estimate'.
	 * @return object|null The row, or null when no document exists for that pair.
	 */
	public static function find( $work_order_id, $doc_type ) {
		global $wpdb;

		// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Custom plugin table, intentionally not object-cached.
		$row = $wpdb->get_row(
			$wpdb->prepare(
				'SELECT * FROM %i WHERE work_order_id = %d AND doc_type = %s',
				self::table(),
				absint( $work_order_id ),
				(string) $doc_type
			)
		);

		return $row ? $row : null;
	}

	/**
	 * Fetch one document by id, decoding its JSON snapshots.
	 *
	 * The line_items and branding columns are decoded with json_decode(..., true)
	 * so callers receive associative arrays; no PHP object deserialization is used
	 * here (Security V5 / object-injection safe).
	 *
	 * @param int $id Document id.
	 * @return object|null The row with line_items and branding decoded to arrays,
	 *                     or null when no such document exists.
	 */
	public static function get( $id ) {
		global $wpdb;

		$id = absint( $id );

		if ( 0 === $id ) {
			return null;
		}

		// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Custom plugin table, intentionally not object-cached.
		$row = $wpdb->get_row(
			$wpdb->prepare(
				'SELECT * FROM %i WHERE id = %d',
				self::table(),
				$id
			)
		);

		if ( ! $row ) {
			return null;
		}

		$row->line_items = json_decode( (string) $row->line_items, true );
		$row->branding   = json_decode( (string) $row->branding, true );

		return $row;
	}
}
