<?php
/**
 * Ownership-scoped portal document download (customer-facing PDF/print).
 *
 * This is the customer counterpart to the staff WrenchPress_Documents_Admin
 * handler. It reuses the SAME frozen document service and PDF renderer but
 * replaces the staff capability gate with a session-derived ownership gate: the
 * requested work order must belong to the logged-in
 * customer resolved from WrenchPress_Portal_Session::current_customer_id().
 * The customer_id is taken ONLY from the verified session, never from request
 * input, so a customer can never fetch another customer's document (the
 * PORT-04 / IDOR invariant, RESEARCH Pitfall 2).
 *
 * A distinct admin-post action (wrenchpress_portal_document) is registered on
 * both the nopriv and priv hooks so the portal path never routes through the
 * staff generate action. The security order is fixed: verify the per-work-order
 * nonce (CSRF) first, then the session, then row ownership. A missing work
 * order and a not-owned work order return the identical neutral "Not found."
 * message so the response is not an existence oracle (T-08-05-02).
 *
 * The buffer-flush + header + stream logic is copied verbatim from the staff
 * handler so nothing precedes the %PDF- signature (T-08-05-05). When the bundled
 * PDF engine is unavailable the handler degrades to the print view.
 *
 * @package WrenchPress
 */

defined( 'ABSPATH' ) || exit;

/**
 * Class WrenchPress_Portal_Documents
 */
class WrenchPress_Portal_Documents {

	/**
	 * Admin-post action name for the ownership-scoped portal download.
	 *
	 * Deliberately distinct from WrenchPress_Documents_Admin::ACTION so the
	 * customer path is never able to route through the staff, capability-gated
	 * handler.
	 */
	const ACTION = 'wrenchpress_portal_document';

	/**
	 * Nonce-action prefix for a per-work-order portal download. The work-order id
	 * is appended so a nonce minted for one work order cannot download a document
	 * for another.
	 */
	const NONCE_PREFIX = 'wrenchpress_portal_doc_';

	/**
	 * Register the download handler on admin-post.php.
	 *
	 * Both the nopriv and priv hooks are registered so an anonymous portal
	 * customer (the normal case) and a logged-in staffer previewing the portal
	 * both reach handle(). Access is never granted by being logged in as a WP
	 * user: the ownership gate is the only authority.
	 *
	 * @return void
	 */
	public static function register() {
		add_action( 'admin_post_nopriv_' . self::ACTION, array( __CLASS__, 'handle' ) );
		add_action( 'admin_post_' . self::ACTION, array( __CLASS__, 'handle' ) );
	}

	/**
	 * Handle an ownership-scoped download request.
	 *
	 * Security order is fixed and must not be reordered: nonce first (CSRF), then
	 * the session, then row ownership. The customer_id is derived ONLY from the
	 * verified portal session; no id is ever read from request input. A missing
	 * work order and a not-owned work order both emit the identical neutral
	 * "Not found." so the endpoint is not an existence oracle. The frozen record
	 * is fetched through the idempotent service, so a reprint reuses the original
	 * number and figures.
	 *
	 * @return void
	 */
	public static function handle() {
		$wo_id = isset( $_GET['work_order'] ) ? absint( wp_unslash( $_GET['work_order'] ) ) : 0;

		// 1. Nonce (CSRF) FIRST, before the session or any data access.
		check_admin_referer( self::NONCE_PREFIX . $wo_id );

		// 2. Session: the customer_id comes ONLY from the verified session row,
		// never from request input (PORT-04 / IDOR invariant).
		$customer_id = WrenchPress_Portal_Session::current_customer_id();

		if ( 0 === $customer_id ) {
			wp_die( esc_html__( 'Please sign in to download documents.', 'pitdesk-repair-crm' ) );
		}

		// 3. Ownership gate: the work order must belong to this session's customer.
		// Not-found and not-owned collapse to one neutral message (no leak).
		$work_order = $wo_id > 0 ? WrenchPress_Work_Orders_Repository::get( $wo_id ) : null;

		if ( ! $work_order || (int) $work_order->customer_id !== $customer_id ) {
			wp_die( esc_html__( 'Not found.', 'pitdesk-repair-crm' ) );
		}

		// 4. A 'new' work order has no issuable document (D-03). The customer owns
		// it, so this is not a leak; report that nothing is available yet.
		if ( 'new' === (string) $work_order->status ) {
			wp_die( esc_html__( 'No invoice or estimate is available for this job yet.', 'pitdesk-repair-crm' ) );
		}

		// 5. Reuse the frozen Phase 7 document service (idempotent: reprints reuse
		// the number). The document type is derived from the status; the customer
		// path never accepts a type or notes override from the request.
		$doc_type = WrenchPress_Document_Service::default_doc_type( (string) $work_order->status );
		$record   = WrenchPress_Document_Service::issue_or_get( $wo_id, $doc_type );

		if ( ! $record ) {
			wp_die( esc_html__( 'No invoice or estimate is available for this job yet.', 'pitdesk-repair-crm' ) );
		}

		$customer = WrenchPress_Customers_Repository::get( (int) $work_order->customer_id );
		$vehicle  = WrenchPress_Vehicles_Repository::get( (int) $work_order->vehicle_id );

		// Single source-of-truth HTML string, used identically by both outputs.
		$html = WrenchPress_Document_Template::render( $record, $customer, $vehicle );

		// PDF path: only when the bundled engine is available and produced bytes;
		// otherwise degrade cleanly to the print view.
		if ( WrenchPress_Pdf_Renderer::is_available() ) {
			$pdf = WrenchPress_Pdf_Renderer::to_pdf( $html );

			if ( '' !== $pdf ) {
				self::stream_pdf( $pdf, self::filename( $record ) );
			}
		}

		self::stream_html( $html );
	}

	/**
	 * Whether the given work order belongs to the given customer.
	 *
	 * The ownership predicate, extracted so the cross-customer isolation test can
	 * assert the gate at its seam without triggering wp_die(). A missing work
	 * order is never owned. customer_id must be the session-derived id.
	 *
	 * @param int $customer_id Session-derived customer id.
	 * @param int $wo_id       Requested work-order id.
	 * @return bool True when the work order exists and belongs to the customer.
	 */
	public static function owns( $customer_id, $wo_id ) {
		$customer_id = absint( $customer_id );
		$wo_id       = absint( $wo_id );

		if ( 0 === $customer_id || 0 === $wo_id ) {
			return false;
		}

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

		return $work_order && (int) $work_order->customer_id === $customer_id;
	}

	/**
	 * Build the dashboard "Download PDF" control for one work order.
	 *
	 * Returns a nonced GET link to admin-post.php carrying the portal ACTION, the
	 * work-order id, and a per-work-order nonce for NONCE_PREFIX . id. A 'new'
	 * work order has no document, so the UI-SPEC "no document available" copy is
	 * shown instead of a dead link. When the PDF engine is unavailable the label
	 * offers the printable view (the handler degrades to it), so the control is
	 * never a broken promise of a PDF.
	 *
	 * @param object $wo Work-order row (id and status).
	 * @return string Control markup, or '' when the input is not a work order.
	 */
	public static function download_link( $wo ) {
		if ( ! is_object( $wo ) || ! isset( $wo->id ) ) {
			return '';
		}

		$id     = (int) $wo->id;
		$status = isset( $wo->status ) ? (string) $wo->status : '';

		if ( 'new' === $status ) {
			return '<p class="egcrm-portal__doc egcrm-portal__doc--empty">'
				. esc_html__( 'No invoice or estimate is available for this job yet.', 'pitdesk-repair-crm' )
				. '</p>';
		}

		$url = add_query_arg(
			array(
				'action'     => self::ACTION,
				'work_order' => $id,
				'_wpnonce'   => wp_create_nonce( self::NONCE_PREFIX . $id ),
			),
			admin_url( 'admin-post.php' )
		);

		$label = WrenchPress_Pdf_Renderer::is_available()
			? __( 'Download PDF', 'pitdesk-repair-crm' )
			: __( 'View printable document', 'pitdesk-repair-crm' );

		$html  = '<p class="egcrm-portal__doc">';
		$html .= '<a class="egcrm-btn egcrm-btn--secondary" href="' . esc_url( $url ) . '" rel="nofollow">';
		$html .= esc_html( $label );
		$html .= '</a>';
		$html .= '</p>';

		return $html;
	}

	/**
	 * Emit the print view: the identical template HTML on a bare page.
	 *
	 * All output buffers are discarded first so the response is exactly the
	 * template string, then the request ends. Copied verbatim from the staff
	 * handler so the two paths stream identically.
	 *
	 * @param string $html Complete standalone HTML document.
	 * @return void
	 */
	private static function stream_html( $html ) {
		while ( ob_get_level() ) {
			ob_end_clean();
		}

		nocache_headers();
		header( 'Content-Type: text/html; charset=' . get_option( 'blog_charset' ) );
		echo $html; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- $html is the pre-escaped, self-contained document built by WrenchPress_Document_Template::render().
		exit;
	}

	/**
	 * Stream binary PDF bytes as a download.
	 *
	 * Every output buffer is flushed before the headers so no stray byte precedes
	 * the %PDF- signature and corrupts the file (T-08-05-05), then the request
	 * ends. Copied verbatim from the staff handler.
	 *
	 * @param string $pdf      Binary PDF bytes (begins with %PDF-).
	 * @param string $filename Sanitized base filename (without extension).
	 * @return void
	 */
	private static function stream_pdf( $pdf, $filename ) {
		while ( ob_get_level() ) {
			ob_end_clean();
		}

		nocache_headers();
		header( 'Content-Type: application/pdf' );
		header( 'Content-Disposition: attachment; filename="' . $filename . '.pdf"' );
		header( 'Content-Length: ' . strlen( $pdf ) );
		echo $pdf; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- binary PDF stream, not HTML.
		exit;
	}

	/**
	 * Build a sanitized download filename from the document label and number.
	 *
	 * @param object $record Frozen document record (doc_type, doc_number).
	 * @return string Sanitized base filename, for example "Invoice-0001".
	 */
	private static function filename( $record ) {
		$label = ( isset( $record->doc_type ) && 'estimate' === $record->doc_type )
			? __( 'Estimate', 'pitdesk-repair-crm' )
			: __( 'Invoice', 'pitdesk-repair-crm' );

		$number = isset( $record->doc_number ) ? absint( $record->doc_number ) : 0;

		return sanitize_file_name( sprintf( '%s-%04d', $label, $number ) );
	}
}
