<?php
/**
 * Claim Controller for Warranty Cart
 *
 * @package WarrantyCart
 */

namespace WarrantyCart\REST\Controllers;

/*
 * The claim rules and storage, aliased because this controller is also
 * called Claims. The domain class is what decides whether a claim may be
 * filed at all; this one only turns HTTP into a call to it.
 */
use WarrantyCart\Core\ClaimAttachments;
use WarrantyCart\Core\ClaimMessages;
use WarrantyCart\Core\Claims as ClaimStore;
use WarrantyCart\Core\GuestAccess;
use WarrantyCart\REST\Controller;
use WarrantyCart\REST\Csv;
use WP_REST_Request;
use WP_REST_Response;
use WP_REST_Server;

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

/**
 * Warranty claims, for customers and for the shop.
 *
 * Two audiences on one resource, so the routes are split by who they serve
 * rather than sharing one endpoint with a branch inside: `/claims` is the
 * customer's own, `/admin/claims` is every claim on the store. A permission
 * callback that has to work out which caller it is looking at is a permission
 * callback waiting to get it wrong.
 */
class Claims extends Controller {

	/** Rows an export will fetch at most. */
	private const EXPORT_LIMIT = 10000;

	/**
	 * Constructor
	 */
	public function __construct() {
		$this->rest_base = 'claims';
	}

	/**
	 * Register routes
	 *
	 * @return void
	 */
	public function register_routes(): void {
		// The customer's own claims.
		register_rest_route(
			$this->namespace,
			'/' . $this->rest_base,
			array(
				array(
					'methods'             => WP_REST_Server::READABLE,
					'callback'            => array( $this, 'get_my_claims' ),
					'permission_callback' => array( $this, 'check_customer_permission' ),
				),
				array(
					'methods'             => WP_REST_Server::CREATABLE,
					'callback'            => array( $this, 'create_claim' ),
					'permission_callback' => array( $this, 'check_customer_permission' ),
					'args'                => array(
						'warranty_id' => array(
							'required'          => true,
							'type'              => 'integer',
							'description'       => __( 'The warranty being claimed against.', 'warranty-cart' ),
							'sanitize_callback' => 'absint',
						),
						'subject'     => array(
							'required'          => true,
							'type'              => 'string',
							'description'       => __( 'A short summary of the problem.', 'warranty-cart' ),
							'sanitize_callback' => 'sanitize_text_field',
						),
						'description' => array(
							'required'          => true,
							'type'              => 'string',
							'description'       => __( 'What has gone wrong.', 'warranty-cart' ),
							'sanitize_callback' => 'sanitize_textarea_field',
						),
					),
				),
			)
		);

		/*
		 * Warranties on an order, for somebody with no account.
		 *
		 * Open to anyone, because a guest has nothing to authenticate with —
		 * `GuestAccess` is where the question is actually answered, using the
		 * order number and the address it was placed with. It refuses a wrong
		 * order and a wrong address identically, and counts failures, so this
		 * cannot be used to find out which order numbers exist.
		 */
		register_rest_route(
			$this->namespace,
			'/' . $this->rest_base . '/guest/lookup',
			array(
				array(
					'methods'             => WP_REST_Server::CREATABLE,
					'callback'            => array( $this, 'guest_lookup' ),
					'permission_callback' => '__return_true',
					'args'                => array(
						'order_id' => array(
							'required'          => true,
							'type'              => 'integer',
							'description'       => __( 'The order number.', 'warranty-cart' ),
							'sanitize_callback' => 'absint',
						),
						'email'    => array(
							'required'          => true,
							'type'              => 'string',
							'description'       => __( 'The email address the order was placed with.', 'warranty-cart' ),
							'sanitize_callback' => 'sanitize_email',
						),
					),
				),
			)
		);

		// Filing one, having proved the order is theirs.
		register_rest_route(
			$this->namespace,
			'/' . $this->rest_base . '/guest',
			array(
				array(
					'methods'             => WP_REST_Server::CREATABLE,
					'callback'            => array( $this, 'create_guest_claim' ),
					'permission_callback' => '__return_true',
					'args'                => array(
						'order_id'    => array(
							'required'          => true,
							'type'              => 'integer',
							'sanitize_callback' => 'absint',
						),
						'email'       => array(
							'required'          => true,
							'type'              => 'string',
							'sanitize_callback' => 'sanitize_email',
						),
						'warranty_id' => array(
							'required'          => true,
							'type'              => 'integer',
							'sanitize_callback' => 'absint',
						),
						'subject'     => array(
							'required'          => true,
							'type'              => 'string',
							'sanitize_callback' => 'sanitize_text_field',
						),
						'description' => array(
							'required'          => true,
							'type'              => 'string',
							'sanitize_callback' => 'sanitize_textarea_field',
						),
					),
				),
			)
		);

		/*
		 * Attachment download.
		 *
		 * The files live outside the web root's reach on purpose — see
		 * `Core\ClaimAttachments` — so this route is how anybody gets one, and
		 * it is where the "may you see this?" question is answered: the customer
		 * who filed the claim, or somebody who can work the claim queue.
		 */
		register_rest_route(
			$this->namespace,
			'/' . $this->rest_base . '/(?P<id>\\d+)/attachments/(?P<attachment_id>\\d+)',
			array(
				array(
					'methods'             => WP_REST_Server::READABLE,
					'callback'            => array( $this, 'download_attachment' ),
					'permission_callback' => 'is_user_logged_in',
					'args'                => array(
						'id'            => array(
							'required'          => true,
							'type'              => 'integer',
							'sanitize_callback' => 'absint',
						),
						'attachment_id' => array(
							'required'          => true,
							'type'              => 'integer',
							'sanitize_callback' => 'absint',
						),
					),
				),
			)
		);

		// Every claim on the store.
		register_rest_route(
			$this->namespace,
			'/admin/claims',
			array(
				array(
					'methods'             => WP_REST_Server::READABLE,
					'callback'            => array( $this, 'get_claims' ),
					'permission_callback' => array( $this, 'check_admin_permission' ),
					'args'                => array(
						'page'     => array(
							'required' => false,
							'type'     => 'integer',
							'default'  => 1,
							'minimum'  => 1,
						),
						'per_page' => array(
							'required' => false,
							'type'     => 'integer',
							'default'  => 10,
							'minimum'  => 1,
							'maximum'  => 100,
						),
						'status'   => array(
							'required'          => false,
							'type'              => 'string',
							'description'       => __( 'Filter by claim status.', 'warranty-cart' ),
							'sanitize_callback' => 'sanitize_text_field',
						),
						'search'   => array(
							'required'          => false,
							'type'              => 'string',
							'sanitize_callback' => 'sanitize_text_field',
						),
					),
				),
			)
		);

		register_rest_route(
			$this->namespace,
			'/admin/claims/export',
			array(
				array(
					'methods'             => WP_REST_Server::READABLE,
					'callback'            => array( $this, 'export_claims_csv' ),
					'permission_callback' => array( $this, 'check_admin_permission' ),
				),
			)
		);

		register_rest_route(
			$this->namespace,
			'/admin/claims/(?P<id>\d+)',
			array(

				/*
				 * Reading one claim.
				 *
				 * Only the update method was registered, so a claim could be
				 * changed and never fetched on its own — the admin had to load
				 * the whole list and pick the row out of it. Fine for a dialog
				 * opened from that list; useless for a page somebody can link
				 * to, bookmark or reload.
				 */
				array(
					'methods'             => WP_REST_Server::READABLE,
					'callback'            => array( $this, 'get_claim' ),
					'permission_callback' => array( $this, 'check_admin_permission' ),
					'args'                => array(
						'id' => array(
							'required'          => true,
							'type'              => 'integer',
							'sanitize_callback' => 'absint',
						),
					),
				),
				array(
					'methods'             => WP_REST_Server::EDITABLE,
					'callback'            => array( $this, 'update_claim' ),
					'permission_callback' => array( $this, 'check_admin_permission' ),
					'args'                => array(
						'id'                   => array(
							'required'          => true,
							'type'              => 'integer',
							'sanitize_callback' => 'absint',
						),
						'status'               => array(
							'required'          => true,
							'type'              => 'string',
							'enum'              => array_keys( ClaimStore::statuses() ),
							'sanitize_callback' => 'sanitize_text_field',
						),
						'resolution'           => array(
							'required'          => false,
							'type'              => 'string',
							'sanitize_callback' => 'sanitize_textarea_field',
						),

						/*
						 * What the shop did, as opposed to where the claim is.
						 * Optional, because a claim moving from pending to
						 * in_progress has no outcome yet — and forcing one at
						 * that point would mean recording a decision nobody has
						 * made.
						 */
						'outcome'              => array(
							'required'          => false,
							'type'              => 'string',
							'enum'              => array_merge( array( '' ), array_keys( ClaimStore::outcomes() ) ),
							'description'       => __( 'What the shop did about the claim.', 'warranty-cart' ),
							'sanitize_callback' => 'sanitize_text_field',
						),
						'outcome_cost'         => array(
							'required'    => false,
							'type'        => 'number',
							'minimum'     => 0,
							'description' => __( 'What the outcome cost the shop.', 'warranty-cart' ),
						),
						'replacement_order_id' => array(
							'required'          => false,
							'type'              => 'integer',
							'description'       => __( 'An order raised to replace the goods.', 'warranty-cart' ),
							'sanitize_callback' => 'absint',
						),
					),
				),
			)
		);

		/*
		 * Saying something about a claim without deciding it.
		 *
		 * Separate from the update route above on purpose: writing a message is
		 * not a status change, and folding it in would mean either sending a
		 * status-change email for a question, or making the shop pick a status
		 * before it can ask one.
		 */
		register_rest_route(
			$this->namespace,
			'/admin/claims/(?P<id>\d+)/messages',
			array(
				array(
					'methods'             => WP_REST_Server::CREATABLE,
					'callback'            => array( $this, 'add_message' ),
					'permission_callback' => array( $this, 'check_admin_permission' ),
					'args'                => array(
						'id'         => array(
							'required'          => true,
							'type'              => 'integer',
							'sanitize_callback' => 'absint',
						),
						'body'       => array(
							'required'          => true,
							'type'              => 'string',
							'description'       => __( 'What to say.', 'warranty-cart' ),
							'sanitize_callback' => 'sanitize_textarea_field',
						),
						'visibility' => array(
							'required'          => false,
							'type'              => 'string',
							'default'           => ClaimMessages::TO_CUSTOMER,
							'enum'              => array_keys( ClaimMessages::visibilities() ),
							'description'       => __( 'Whether the customer reads it.', 'warranty-cart' ),
							'sanitize_callback' => 'sanitize_text_field',
						),
					),
				),
			)
		);
	}

	/**
	 * A claim belongs to an account, so filing one requires being signed in.
	 *
	 * @param WP_REST_Request $request REST request object.
	 *
	 * @return bool
	 */
	public function check_customer_permission( WP_REST_Request $request ): bool {
		return is_user_logged_in();
	}

	/**
	 * The signed-in customer's claims.
	 *
	 * @param WP_REST_Request $request REST request object.
	 *
	 * @return WP_REST_Response
	 */
	public function get_my_claims( WP_REST_Request $request ): WP_REST_Response {
		$claims = ClaimStore::for_customer( get_current_user_id() );

		return $this->success_response(
			array( 'claims' => array_map( array( $this, 'shape' ), $claims ) )
		);
	}

	/**
	 * File a claim.
	 *
	 * @param WP_REST_Request $request REST request object.
	 *
	 * @return WP_REST_Response
	 */
	public function create_claim( WP_REST_Request $request ): WP_REST_Response {
		$claim_id = ClaimStore::create(
			(int) $request->get_param( 'warranty_id' ),
			get_current_user_id(),
			(string) $request->get_param( 'subject' ),
			(string) $request->get_param( 'description' ),
			$request->get_file_params()
		);

		if ( is_wp_error( $claim_id ) ) {
			$status = $claim_id->get_error_data();

			return $this->error_response(
				$claim_id->get_error_message(),
				is_array( $status ) && isset( $status['status'] ) ? (int) $status['status'] : 400
			);
		}

		$claim = ClaimStore::get( $claim_id );

		return $this->success_response(
			array(
				'message' => __( 'Your claim has been submitted.', 'warranty-cart' ),
				'claim'   => $claim ? $this->shape( $claim ) : null,
			)
		);
	}

	/**
	 * The warranties on a guest's order.
	 *
	 * @param WP_REST_Request $request REST request object.
	 *
	 * @return WP_REST_Response
	 */
	public function guest_lookup( WP_REST_Request $request ): WP_REST_Response {
		$warranties = GuestAccess::warranties(
			(int) $request->get_param( 'order_id' ),
			(string) $request->get_param( 'email' )
		);

		if ( is_wp_error( $warranties ) ) {
			return $this->error_from( $warranties );
		}

		$claimable = array();

		foreach ( $warranties as $warranty ) {
			$product = wc_get_product( (int) $warranty->product_id );

			$claimable[] = array(
				'id'         => (int) $warranty->id,
				'name'       => $product ? $product->get_name() : __( 'Warranty', 'warranty-cart' ),
				'status'     => (string) $warranty->status,
				'expires_at' => (string) $warranty->expiration_date,

				/*
				 * Said here rather than left for the claim form to discover.
				 * A customer looking at expired cover should be told why the
				 * button is missing, not shown one that refuses them.
				 */
				'claimable'  => 'active' === $warranty->status,
			);
		}

		return $this->success_response( array( 'warranties' => $claimable ) );
	}

	/**
	 * File a claim without an account.
	 *
	 * @param WP_REST_Request $request REST request object.
	 *
	 * @return WP_REST_Response
	 */
	public function create_guest_claim( WP_REST_Request $request ): WP_REST_Response {
		$email = (string) $request->get_param( 'email' );

		/*
		 * Proved again here, not trusted from the lookup.
		 *
		 * Nothing carries between the two requests — there is no session for a
		 * guest — so a claim arriving with a warranty id and an address has to
		 * establish the pair for itself. `Claims::create()` checks it a third
		 * time for the same reason.
		 */
		$verified = GuestAccess::verify( (int) $request->get_param( 'order_id' ), $email );

		if ( is_wp_error( $verified ) ) {
			return $this->error_from( $verified );
		}

		$claim_id = ClaimStore::create(
			(int) $request->get_param( 'warranty_id' ),
			0,
			(string) $request->get_param( 'subject' ),
			(string) $request->get_param( 'description' ),
			$request->get_file_params(),
			$email
		);

		if ( is_wp_error( $claim_id ) ) {
			return $this->error_from( $claim_id );
		}

		$claim = ClaimStore::get( $claim_id );

		return $this->success_response(
			array(
				'message' => __( 'Your claim has been submitted. We will email you about it.', 'warranty-cart' ),
				'claim'   => $claim ? $this->shape( $claim ) : null,
			)
		);
	}

	/**
	 * Turn a WP_Error into the response its status asked for.
	 *
	 * @param \WP_Error $error The refusal.
	 *
	 * @return WP_REST_Response
	 */
	private function error_from( \WP_Error $error ): WP_REST_Response {
		$data = $error->get_error_data();

		return $this->error_response(
			$error->get_error_message(),
			is_array( $data ) && isset( $data['status'] ) ? (int) $data['status'] : 400
		);
	}

	/**
	 * Every claim, paged and filterable.
	 *
	 * @param WP_REST_Request $request REST request object.
	 *
	 * @return WP_REST_Response
	 */
	public function get_claims( WP_REST_Request $request ): WP_REST_Response {
		global $wpdb;

		$page     = max( 1, (int) $request->get_param( 'page' ) );
		$per_page = max( 1, (int) $request->get_param( 'per_page' ) );
		$status   = (string) $request->get_param( 'status' );
		$search   = (string) $request->get_param( 'search' );

		$table = ClaimStore::table();

		$where  = array( '1=1' );
		$params = array();

		/*
		 * 'all' is the select's value for "no filter", not a status.
		 *
		 * Sending it through as one produced `WHERE status = 'all'`, which
		 * matches nothing — the same mistake that made the warranty records
		 * screen report zero records on every install.
		 */
		if ( '' !== $status && 'all' !== $status ) {
			$where[]  = 'status = %s';
			$params[] = $status;
		}

		if ( '' !== $search ) {
			$where[]  = '( subject LIKE %s OR description LIKE %s )';
			$like     = '%' . $wpdb->esc_like( $search ) . '%';
			$params[] = $like;
			$params[] = $like;
		}

		$clause = implode( ' AND ', $where );
		$offset = ( $page - 1 ) * $per_page;

		/*
		 * The WHERE clause is assembled above, so its placeholders are not
		 * visible to a static reading of these two calls -- phpcs counts the
		 * literal %s in the string and finds none, or finds fewer than the
		 * arguments passed. Every value still goes through prepare(); only the
		 * clause skeleton is built in PHP, and it is built from literals, not
		 * from input.
		 */
		// phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQL.NotPrepared, WordPress.DB.PreparedSQLPlaceholders, WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
		$total = (int) $wpdb->get_var(
			$params
				? $wpdb->prepare( "SELECT COUNT(*) FROM {$table} WHERE {$clause}", $params )
				: "SELECT COUNT(*) FROM {$table} WHERE {$clause}"
		);

		$rows = $wpdb->get_results(
			$wpdb->prepare(
				"SELECT * FROM {$table} WHERE {$clause} ORDER BY created_at DESC LIMIT %d OFFSET %d",
				array_merge( $params, array( $per_page, $offset ) )
			)
		);
		// phpcs:enable WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQL.NotPrepared, WordPress.DB.PreparedSQLPlaceholders, WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching

		return $this->success_response(
			array(
				'claims'      => array_map( array( $this, 'shape' ), is_array( $rows ) ? $rows : array() ),
				'total'       => $total,
				'page'        => $page,
				'per_page'    => $per_page,
				'total_pages' => $per_page > 0 ? (int) ceil( $total / $per_page ) : 0,
				'statuses'    => ClaimStore::statuses(),

				// Published alongside the statuses because the admin needs both
				// to render one decision form, and fetching them separately
				// would leave a select briefly empty.
				'outcomes'    => ClaimStore::outcomes(),
			)
		);
	}

	/**
	 * Move a claim along.
	 *
	 * @param WP_REST_Request $request REST request object.
	 *
	 * @return WP_REST_Response
	 */
	public function update_claim( WP_REST_Request $request ): WP_REST_Response {
		$claim_id = (int) $request->get_param( 'id' );

		$result = ClaimStore::set_status(
			$claim_id,
			(string) $request->get_param( 'status' ),
			(string) $request->get_param( 'resolution' ),
			array(
				'outcome'              => (string) $request->get_param( 'outcome' ),
				'cost'                 => $request->get_param( 'outcome_cost' ),
				'replacement_order_id' => (int) $request->get_param( 'replacement_order_id' ),
			)
		);

		if ( is_wp_error( $result ) ) {
			$data = $result->get_error_data();

			return $this->error_response(
				$result->get_error_message(),
				is_array( $data ) && isset( $data['status'] ) ? (int) $data['status'] : 400
			);
		}

		$claim = ClaimStore::get( $claim_id );

		return $this->success_response(
			array(
				'message' => __( 'Claim updated.', 'warranty-cart' ),
				'claim'   => $claim ? $this->shape( $claim ) : null,
			)
		);
	}

	/**
	 * One claim.
	 *
	 * @param WP_REST_Request $request REST request object.
	 *
	 * @return WP_REST_Response|WP_Error
	 */
	public function get_claim( WP_REST_Request $request ) {
		$claim = ClaimStore::get( (int) $request->get_param( 'id' ) );

		if ( ! $claim ) {
			return $this->error_response( __( 'That claim could not be found.', 'warranty-cart' ), 404 );
		}

		// Staff route, so the internal notes belong in the answer.
		return $this->success_response( array( 'claim' => $this->shape( $claim, true ) ) );
	}

	/**
	 * Write a message on a claim.
	 *
	 * @param WP_REST_Request $request REST request object.
	 *
	 * @return WP_REST_Response
	 */
	public function add_message( WP_REST_Request $request ): WP_REST_Response {
		$claim_id = (int) $request->get_param( 'id' );

		$message_id = ClaimMessages::add(
			$claim_id,
			(string) $request->get_param( 'body' ),
			(string) $request->get_param( 'visibility' )
		);

		if ( is_wp_error( $message_id ) ) {
			return $this->error_from( $message_id );
		}

		return $this->success_response(
			array(
				'message'  => ClaimMessages::INTERNAL === $request->get_param( 'visibility' )
					? __( 'Note saved.', 'warranty-cart' )
					: __( 'Message sent to the customer.', 'warranty-cart' ),

				// The whole thread back, so the caller never has to guess what
				// its own write produced.
				'messages' => $this->shape_messages( $claim_id, true ),
			)
		);
	}

	/**
	 * Messages on a claim, ready for JSON.
	 *
	 * @param int  $claim_id         The claim.
	 * @param bool $include_internal Whether notes the customer must not see are
	 *                               included. Only ever true for staff.
	 *
	 * @return array<int, array<string, mixed>>
	 */
	private function shape_messages( int $claim_id, bool $include_internal = false ): array {
		$shaped = array();

		foreach ( ClaimMessages::for_claim( $claim_id, $include_internal ) as $message ) {
			$shaped[] = array(
				'id'          => (int) $message->id,
				'author_name' => (string) $message->author_name,
				'visibility'  => (string) $message->visibility,
				'body'        => (string) $message->body,
				'created_at'  => (string) $message->created_at,
			);
		}

		return $shaped;
	}

	/**
	 * Send one attachment to somebody entitled to see it.
	 *
	 * Streams the file rather than redirecting to it: the directory is closed,
	 * and a redirect would only work by opening it.
	 *
	 * @param WP_REST_Request $request REST request object.
	 *
	 * @return WP_REST_Response|void
	 */
	public function download_attachment( WP_REST_Request $request ) {
		$claim_id      = (int) $request->get_param( 'id' );
		$attachment_id = (int) $request->get_param( 'attachment_id' );

		$attachment = ClaimAttachments::get( $attachment_id );

		if ( ! $attachment || (int) $attachment->claim_id !== $claim_id ) {
			return $this->error_response( __( 'That file could not be found.', 'warranty-cart' ), 404 );
		}

		$claim = ClaimStore::get( $claim_id );

		if ( ! $claim ) {
			return $this->error_response( __( 'That claim could not be found.', 'warranty-cart' ), 404 );
		}

		/*
		 * Two ways to be allowed: it is your claim, or you work here.
		 *
		 * `manage_options` is included for the same reason as in
		 * `Controller::check_admin_permission()` — an administrator whose site
		 * has not re-run the capability grant must not be locked out.
		 */
		$viewer   = get_current_user_id();
		$is_owner = $viewer > 0 && (int) $claim->customer_id === $viewer;
		$is_staff = current_user_can( apply_filters( 'warranty_cart_rest_capability', 'manage_warranty_cart', $request ) )
			|| current_user_can( 'manage_options' );

		if ( ! $is_owner && ! $is_staff ) {
			return $this->error_response( __( 'You do not have permission to view that file.', 'warranty-cart' ), 403 );
		}

		$path = ClaimAttachments::path( $attachment );

		if ( '' === $path ) {
			return $this->error_response( __( 'That file is no longer on the server.', 'warranty-cart' ), 404 );
		}

		/*
		 * `inline` rather than `attachment`: staff are looking at photographs,
		 * and forcing a download to see a picture of a cracked screen is the
		 * kind of small friction that makes a queue slower all day. `nosniff`
		 * because the type is ours, not the browser's guess at it.
		 */
		header( 'Content-Type: ' . $attachment->mime_type );
		header( 'Content-Length: ' . (string) filesize( $path ) );
		header( 'Content-Disposition: inline; filename="' . rawurlencode( (string) $attachment->original_name ) . '"' );
		header( 'X-Content-Type-Options: nosniff' );
		header( 'Cache-Control: private, max-age=600' );

		readfile( $path ); // phpcs:ignore WordPress.WP.AlternativeFunctions

		exit;
	}

	/**
	 * One claim row as the API returns it.
	 *
	 * @param object $claim A row from the claims table.
	 *
	 * @return array<string, mixed>
	 */
	private function shape( object $claim, bool $include_internal = false ): array {
		$statuses = ClaimStore::statuses();
		$outcomes = ClaimStore::outcomes();
		$status   = (string) $claim->status;
		$product  = wc_get_product( (int) $claim->product_id );
		$user     = $claim->customer_id ? get_userdata( (int) $claim->customer_id ) : false;

		return array(
			'id'                   => (int) $claim->id,
			'warranty_id'          => (int) $claim->warranty_id,
			'order_id'             => (int) $claim->order_id,
			'customer_id'          => (int) $claim->customer_id,
			'customer_name'        => $user ? $user->display_name : __( 'Guest', 'warranty-cart' ),

			/*
			 * A guest claim has an address too, and it is the one every reply
			 * goes to. Showing nothing here left staff answering a claim with no
			 * idea where the answer was being sent.
			 */
			'customer_email'       => $user ? $user->user_email : (string) ( $claim->guest_email ?? '' ),
			'customer_avatar'      => $claim->customer_id
				? (string) get_avatar_url( (int) $claim->customer_id, array( 'size' => 64 ) )
				: '',
			'product_id'           => (int) $claim->product_id,
			'product_name'         => $product ? $product->get_name() : __( 'Warranty', 'warranty-cart' ),
			'subject'              => (string) $claim->subject,
			'description'          => (string) $claim->description,
			'status'               => $status,
			'status_label'         => $statuses[ $status ] ?? $status,
			'resolution'           => (string) ( $claim->resolution ?? '' ),

			/*
			 * Null rather than 0 for a cost nobody recorded. A claim settled
			 * for nothing and a claim whose cost was never entered are
			 * different facts, and summing them as if they were the same is
			 * how a loss ratio comes out flattering.
			 */
			'outcome'              => (string) ( $claim->outcome ?? '' ),
			'outcome_label'        => isset( $claim->outcome ) && isset( $outcomes[ $claim->outcome ] )
				? $outcomes[ $claim->outcome ]
				: '',
			'outcome_cost'         => isset( $claim->outcome_cost ) && null !== $claim->outcome_cost
				? (float) $claim->outcome_cost
				: null,
			'outcome_currency'     => (string) ( $claim->outcome_currency ?? '' ),
			'replacement_order_id' => (int) ( $claim->replacement_order_id ?? 0 ),
			'attachments'          => $this->shape_attachments( (int) $claim->id ),

			/*
			 * Internal notes only where the caller has been checked. This is
			 * shared with the customer's own claims route, so defaulting the
			 * other way would post the shop's private notes to the person the
			 * claim is against.
			 */
			'messages'             => $this->shape_messages( (int) $claim->id, $include_internal ),
			'created_at'           => (string) $claim->created_at,
			'updated_at'           => (string) ( $claim->updated_at ?? '' ),
		);
	}

	/**
	 * A claim's evidence, as the API returns it.
	 *
	 * The URL is the REST route, not a path into uploads — the files are not
	 * reachable that way, which is the point of them.
	 *
	 * @param int $claim_id The claim.
	 *
	 * @return array<int, array<string, mixed>>
	 */
	private function shape_attachments( int $claim_id ): array {
		$shaped = array();

		foreach ( ClaimAttachments::for_claim( $claim_id ) as $attachment ) {
			$shaped[] = array(
				'id'        => (int) $attachment->id,
				'name'      => (string) $attachment->original_name,
				'mime_type' => (string) $attachment->mime_type,
				'size'      => (int) $attachment->file_size,
				'is_image'  => 0 === strpos( (string) $attachment->mime_type, 'image/' ),
				'url'       => rest_url(
					sprintf( '%s/%s/%d/attachments/%d', $this->namespace, $this->rest_base, $claim_id, (int) $attachment->id )
				),
			);
		}

		return $shaped;
	}

	/**
	 * Export claims as CSV.
	 *
	 * Runs the admin list's own query so the file matches the screen, filters
	 * and all.
	 *
	 * @param WP_REST_Request $request REST request object.
	 *
	 * @return WP_REST_Response|WP_Error
	 */
	public function export_claims_csv( WP_REST_Request $request ) {
		$copy = clone $request;
		$copy->set_param( 'page', 1 );
		$copy->set_param( 'per_page', self::EXPORT_LIMIT );

		$data   = $this->get_claims( $copy )->get_data();
		$claims = $data['data']['claims'] ?? array();

		if ( empty( $claims ) ) {
			return $this->error_response( __( 'No claims found to export.', 'warranty-cart' ), 404 );
		}

		$header = array(
			__( 'Claim ID', 'warranty-cart' ),
			__( 'Filed', 'warranty-cart' ),
			__( 'Status', 'warranty-cart' ),
			__( 'Customer', 'warranty-cart' ),
			__( 'Email', 'warranty-cart' ),
			__( 'Order ID', 'warranty-cart' ),
			__( 'Warranty', 'warranty-cart' ),
			__( 'Subject', 'warranty-cart' ),
			__( 'Description', 'warranty-cart' ),
			__( 'Resolution', 'warranty-cart' ),

			// The typed outcome and its cost, so the spreadsheet can answer what
			// a shop's claims cost against what its cover earned — which is the
			// question the free-text resolution column never could.
			__( 'Outcome', 'warranty-cart' ),
			__( 'Cost', 'warranty-cart' ),
			__( 'Currency', 'warranty-cart' ),
			__( 'Replacement Order', 'warranty-cart' ),
			__( 'Last Updated', 'warranty-cart' ),
		);

		$rows = array();

		foreach ( $claims as $claim ) {
			$rows[] = array(
				$claim['id'],
				$claim['created_at'],
				$claim['status_label'],
				$claim['customer_name'],
				$claim['customer_email'],
				$claim['order_id'],
				$claim['product_name'],
				$claim['subject'],
				$claim['description'],
				$claim['resolution'],
				$claim['outcome_label'],
				null === $claim['outcome_cost'] ? '' : $claim['outcome_cost'],
				$claim['outcome_currency'],
				$claim['replacement_order_id'] ? $claim['replacement_order_id'] : '',
				$claim['updated_at'],
			);
		}

		return $this->file_response(
			$this->export_filename( 'warranty-claims' ),
			Csv::build( $header, $rows ),
			'text/csv',
			$this->truncation_notice( (int) ( $data['data']['total'] ?? 0 ), self::EXPORT_LIMIT )
		);
	}
}
