<?php
/**
 * Warranty claims.
 *
 * @package WarrantyCart
 */

namespace WarrantyCart\Core;

use WP_Error;

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

/**
 * Creating, reading and moving warranty claims.
 *
 * A warranty a customer cannot claim against is a receipt, not a warranty.
 * Until this existed the plugin sold cover, recorded it, and reminded people
 * when it was about to lapse — with no way for any of them to ask for the
 * thing they had paid for.
 */
class Claims {

	/**
	 * Whether customers may file claims at all.
	 */
	public const ENABLED_OPTION = 'warranty_cart_claims_enabled';

	/**
	 * The statuses a claim can hold.
	 *
	 * `pending` is where every claim starts. `in_progress` exists because the
	 * gap between "we have read this" and "we have decided" is the part a
	 * customer most wants to see; without it a claim sits at pending for a
	 * fortnight and reads as ignored.
	 *
	 * @return array<string, string> Status key to human label.
	 */
	public static function statuses(): array {
		return array(
			'pending'     => __( 'Pending', 'warranty-cart' ),
			'in_progress' => __( 'In progress', 'warranty-cart' ),
			'approved'    => __( 'Approved', 'warranty-cart' ),
			'rejected'    => __( 'Rejected', 'warranty-cart' ),
			'resolved'    => __( 'Resolved', 'warranty-cart' ),
		);
	}

	/**
	 * How a claim ended.
	 *
	 * Separate from the status on purpose. The status is where a claim is in
	 * the queue; the outcome is what the shop actually did about it, and the
	 * two answer different questions — "approved" does not say whether the
	 * customer got a new one, got theirs mended, or got their money back, and
	 * the difference between those is most of what a warranty costs to run.
	 *
	 * `resolution` stays as it was: a note to the customer in somebody's own
	 * words. It is not a substitute for this, because prose cannot be summed.
	 *
	 * @return array<string, string> Outcome key to human label.
	 */
	public static function outcomes(): array {
		return array(
			'repair'   => __( 'Repaired', 'warranty-cart' ),
			'replace'  => __( 'Replaced', 'warranty-cart' ),
			'refund'   => __( 'Refunded', 'warranty-cart' ),
			'credit'   => __( 'Store credit', 'warranty-cart' ),
			'goodwill' => __( 'Goodwill', 'warranty-cart' ),
			'denied'   => __( 'Denied', 'warranty-cart' ),
		);
	}

	/**
	 * Whether the claims feature is switched on.
	 *
	 * @return bool
	 */
	public static function is_enabled(): bool {
		return (bool) get_option( self::ENABLED_OPTION, true );
	}

	/**
	 * The claims table name.
	 *
	 * @return string
	 */
	public static function table(): string {
		global $wpdb;

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

	/**
	 * File a claim against a warranty.
	 *
	 * @param int    $warranty_id  The warranty being claimed against.
	 * @param int    $customer_id  Who is claiming, or 0 for a guest.
	 * @param string $subject      Short summary.
	 * @param string $description  What happened.
	 * @param array  $files        Uploaded evidence, `$_FILES`-shaped. Optional.
	 * @param string $guest_email  The address a guest proved the order with.
	 *                             Ignored when a customer ID is given.
	 *
	 * @return int|WP_Error The new claim ID, or why it was refused.
	 */
	public static function create( int $warranty_id, int $customer_id, string $subject, string $description, array $files = array(), string $guest_email = '' ) {
		global $wpdb;

		if ( ! self::is_enabled() ) {
			return new WP_Error(
				'warranty_cart_claims_disabled',
				__( 'Claims are not being accepted.', 'warranty-cart' ),
				array( 'status' => 403 )
			);
		}

		$warranty = self::warranty( $warranty_id );

		if ( ! $warranty ) {
			return new WP_Error(
				'warranty_cart_no_warranty',
				__( 'That warranty could not be found.', 'warranty-cart' ),
				array( 'status' => 404 )
			);
		}

		/*
		 * The claimant must own the warranty.
		 *
		 * Checked here rather than only in the REST layer: this is the rule
		 * that stops one customer claiming against another's cover, and it
		 * should hold no matter which code path reaches it.
		 *
		 * Two ways to satisfy it, and the second is not weaker than the first.
		 * A logged-in customer is the warranty's own `customer_id`. A guest —
		 * somebody who checked out without registering, whose cover therefore
		 * has no `customer_id` to match — proves it with the order number and
		 * the address that order was placed with, which is the same pair
		 * WooCommerce's own order tracking accepts.
		 */
		$guest_email = sanitize_email( $guest_email );

		if ( $customer_id > 0 ) {
			if ( (int) $warranty->customer_id !== $customer_id ) {
				return new WP_Error(
					'warranty_cart_not_yours',
					__( 'That warranty belongs to a different account.', 'warranty-cart' ),
					array( 'status' => 403 )
				);
			}
		} elseif ( '' === $guest_email || ! GuestAccess::owns( $warranty, $guest_email ) ) {
			return new WP_Error(
				'warranty_cart_not_yours',
				__( 'That warranty belongs to a different account.', 'warranty-cart' ),
				array( 'status' => 403 )
			);
		}

		if ( 'active' !== $warranty->status ) {
			return new WP_Error(
				'warranty_cart_warranty_inactive',
				__( 'That warranty is no longer active.', 'warranty-cart' ),
				array( 'status' => 400 )
			);
		}

		$subject     = trim( $subject );
		$description = trim( $description );

		if ( '' === $subject || '' === $description ) {
			return new WP_Error(
				'warranty_cart_claim_incomplete',
				__( 'Please describe what has gone wrong.', 'warranty-cart' ),
				array( 'status' => 400 )
			);
		}

		/*
		 * Proof, where the shop insists on it.
		 *
		 * Checked before the insert so a claim that cannot be accepted is never
		 * created — the alternative is a pending claim the shop has to reject
		 * for a reason the customer was never told at the time.
		 */
		if ( ClaimAttachments::proof_required() && empty( $files ) ) {
			return new WP_Error(
				'warranty_cart_claim_proof_required',
				__( 'Attach a photograph or a receipt to support your claim.', 'warranty-cart' ),
				array( 'status' => 400 )
			);
		}

		$now = current_time( 'mysql' );

		$inserted = $wpdb->insert(
			self::table(),
			array(
				'warranty_id' => $warranty_id,
				'order_id'    => (int) $warranty->order_id,
				'customer_id' => $customer_id,

				/*
				 * Where to write back to when there is no account to look up.
				 * Stored on the claim rather than read off the order each time,
				 * so the shop's reply goes to the address the claim was actually
				 * filed under even if the order is later edited.
				 */
				'guest_email' => $customer_id > 0 ? null : $guest_email,
				'product_id'  => (int) $warranty->product_id,
				'subject'     => $subject,
				'description' => $description,
				'status'      => 'pending',
				'created_at'  => $now,
				'updated_at'  => $now,
			),
			array( '%d', '%d', '%d', '%s', '%d', '%s', '%s', '%s', '%s', '%s' )
		);

		if ( ! $inserted ) {
			return new WP_Error(
				'warranty_cart_claim_failed',
				__( 'The claim could not be saved. Please try again.', 'warranty-cart' ),
				array( 'status' => 500 )
			);
		}

		$claim_id = (int) $wpdb->insert_id;

		/*
		 * Evidence, if any came with the claim.
		 *
		 * Stored after the insert because an attachment needs a claim to belong
		 * to. If a file is refused — too large, wrong type — the claim is rolled
		 * back rather than left standing without the proof the shop asked for:
		 * a half-filed claim is worse than a refused one, because the customer
		 * believes it went through.
		 */
		if ( ! empty( $files ) ) {
			$stored = ClaimAttachments::store_many( $claim_id, $files, $customer_id );

			if ( is_wp_error( $stored ) ) {
				ClaimAttachments::delete_for_claim( $claim_id );

				// phpcs:ignore WordPress.DB.DirectDatabaseQuery
				$wpdb->delete( self::table(), array( 'id' => $claim_id ), array( '%d' ) );

				return $stored;
			}
		}

		/**
		 * Fires once a claim has been filed.
		 *
		 * @param int    $claim_id    The claim.
		 * @param object $warranty    The warranty it is against.
		 * @param int    $customer_id Who filed it.
		 */
		do_action( 'warranty_cart_claim_created', $claim_id, $warranty, $customer_id );

		return $claim_id;
	}

	/**
	 * Move a claim to a new status.
	 *
	 * @param int    $claim_id   The claim.
	 * @param string $status     One of self::statuses().
	 * @param string $resolution Optional note explaining the decision.
	 * @param array  $outcome    Optional {
	 *     What the shop did, and what it cost.
	 *
	 *     @type string     $outcome              One of self::outcomes(), or '' to leave it.
	 *     @type float|null $cost                 What the outcome cost the shop.
	 *     @type int        $replacement_order_id An order raised to replace the goods.
	 * }
	 *
	 * @return true|WP_Error
	 */
	public static function set_status( int $claim_id, string $status, string $resolution = '', array $outcome = array() ) {
		global $wpdb;

		if ( ! array_key_exists( $status, self::statuses() ) ) {
			return new WP_Error(
				'warranty_cart_bad_status',
				__( 'That is not a valid claim status.', 'warranty-cart' ),
				array( 'status' => 400 )
			);
		}

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

		if ( ! $claim ) {
			return new WP_Error(
				'warranty_cart_no_claim',
				__( 'That claim could not be found.', 'warranty-cart' ),
				array( 'status' => 404 )
			);
		}

		$previous = (string) $claim->status;

		$data    = array(
			'status'     => $status,
			'updated_at' => current_time( 'mysql' ),
		);
		$formats = array( '%s', '%s' );

		// An empty note must not wipe one already written.
		if ( '' !== trim( $resolution ) ) {
			$data['resolution'] = trim( $resolution );
			$formats[]          = '%s';
		}

		$outcome_key = isset( $outcome['outcome'] ) ? (string) $outcome['outcome'] : '';

		if ( '' !== $outcome_key ) {
			if ( ! array_key_exists( $outcome_key, self::outcomes() ) ) {
				return new WP_Error(
					'warranty_cart_bad_outcome',
					__( 'That is not a valid claim outcome.', 'warranty-cart' ),
					array( 'status' => 400 )
				);
			}

			$data['outcome'] = $outcome_key;
			$formats[]       = '%s';

			/*
			 * The currency is stamped with the decision, not read back later.
			 *
			 * A shop that changes its store currency next year must not silently
			 * restate what last year's claims cost.
			 */
			$data['outcome_currency'] = function_exists( 'get_woocommerce_currency' )
				? get_woocommerce_currency()
				: '';
			$formats[]                = '%s';
		}

		/*
		 * Cost is written whenever it is supplied, including as 0.00 — a repair
		 * done in-house genuinely can cost nothing, and that is a different fact
		 * from "nobody recorded it", which is what NULL means here.
		 */
		if ( array_key_exists( 'cost', $outcome ) && null !== $outcome['cost'] && '' !== $outcome['cost'] ) {
			$data['outcome_cost'] = round( (float) $outcome['cost'], 2 );
			$formats[]            = '%f';
		}

		if ( ! empty( $outcome['replacement_order_id'] ) ) {
			$data['replacement_order_id'] = (int) $outcome['replacement_order_id'];
			$formats[]                    = '%d';
		}

		$updated = $wpdb->update( self::table(), $data, array( 'id' => $claim_id ), $formats, array( '%d' ) );

		if ( false === $updated ) {
			return new WP_Error(
				'warranty_cart_claim_update_failed',
				__( 'The claim could not be updated.', 'warranty-cart' ),
				array( 'status' => 500 )
			);
		}

		if ( $previous !== $status ) {
			/**
			 * Fires when a claim changes status.
			 *
			 * @param int    $claim_id The claim.
			 * @param string $status   The new status.
			 * @param string $previous The status it left.
			 */
			do_action( 'warranty_cart_claim_status_changed', $claim_id, $status, $previous );
		}

		if ( '' !== $outcome_key ) {
			/**
			 * Fires when a claim is given an outcome.
			 *
			 * @since 2.1.0
			 *
			 * @param int        $claim_id The claim.
			 * @param string     $outcome  One of self::outcomes().
			 * @param float|null $cost     What it cost, if recorded.
			 */
			do_action(
				'warranty_cart_claim_outcome_set',
				$claim_id,
				$outcome_key,
				isset( $data['outcome_cost'] ) ? (float) $data['outcome_cost'] : null
			);
		}

		return true;
	}

	/**
	 * One claim.
	 *
	 * @param int $claim_id The claim.
	 *
	 * @return object|null
	 */
	public static function get( int $claim_id ): ?object {
		global $wpdb;

		$table = self::table();

		// phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
		$row = $wpdb->get_row( $wpdb->prepare( "SELECT * FROM {$table} WHERE id = %d", $claim_id ) );
		// phpcs:enable WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching

		return $row ? $row : null;
	}

	/**
	 * One customer's claims, newest first.
	 *
	 * @param int $customer_id The customer.
	 *
	 * @return array<int, object>
	 */
	public static function for_customer( int $customer_id ): array {
		global $wpdb;

		if ( $customer_id <= 0 ) {
			return array();
		}

		$table = self::table();

		// phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
		$rows = $wpdb->get_results(
			$wpdb->prepare(
				"SELECT * FROM {$table} WHERE customer_id = %d ORDER BY created_at DESC",
				$customer_id
			)
		);
		// phpcs:enable WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching

		return is_array( $rows ) ? $rows : array();
	}

	/**
	 * The warranty a claim would be filed against.
	 *
	 * @param int $warranty_id The warranty.
	 *
	 * @return object|null
	 */
	private static function warranty( int $warranty_id ): ?object {
		global $wpdb;

		$table = $wpdb->prefix . 'warranty_cart_warranties';

		// phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
		$row = $wpdb->get_row( $wpdb->prepare( "SELECT * FROM {$table} WHERE id = %d", $warranty_id ) );
		// phpcs:enable WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching

		return $row ? $row : null;
	}
}
