<?php
/**
 * Customer-facing warranty list in WooCommerce My Account.
 *
 * @package WarrantyCart
 */

namespace WarrantyCart\Frontend;

use WarrantyCart\Core\ClaimMessages;
use WarrantyCart\Core\Claims;
use WarrantyCart\Core\Renewals;
use WarrantyCart\Core\Warranties;

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

/**
 * Adds a "Warranties" tab to My Account.
 *
 * Until this existed a customer could buy a warranty and then have no way to
 * see it: the record went into the plugin's own table, which is not the order
 * and so never appeared under My Account > Orders. The only readers were the
 * shop admin and the expiry email.
 */
class MyAccount {

	/**
	 * The My Account endpoint slug.
	 *
	 * Not filterable through an option: the slug is baked into rewrite rules,
	 * and an option a site owner can change without a flush produces a 404 on
	 * a page that used to work.
	 */
	public const ENDPOINT = 'warranties';

	/**
	 * Whether the tab is switched on.
	 */
	public const ENABLED_OPTION = 'warranty_cart_myaccount_enabled';

	/**
	 * The tab's label.
	 */
	/**
	 * How many warranties a customer sees per page.
	 */
	public const PER_PAGE_OPTION = 'warranty_cart_account_per_page';

	/**
	 * Whether the customer is asked to register a serial number.
	 */
	public const SERIALS_OPTION = 'warranty_cart_serials_enabled';

	/**
	 * Rows per page on the warranties tab.
	 *
	 * @return int
	 */
	public static function per_page(): int {
		$stored = (int) \get_option( self::PER_PAGE_OPTION, 10 );

		/**
		 * How many warranties to a page.
		 *
		 * @param int $per_page Rows per page.
		 */
		return max( 1, (int) \apply_filters( 'warranty_cart_account_warranties_per_page', $stored > 0 ? $stored : 10 ) );
	}

	/**
	 * Whether serial-number registration is offered at all.
	 *
	 * A shop selling shirts has nothing to register, and a field asking for the
	 * serial number of a shirt is a question the customer cannot answer.
	 *
	 * @return bool
	 */
	public static function serials_enabled(): bool {
		return 'no' !== \get_option( self::SERIALS_OPTION, 'yes' );
	}

	public const LABEL_OPTION = 'warranty_cart_myaccount_label';

	/**
	 * Set to '1' when the rewrite rules need rebuilding.
	 *
	 * Flushing on every load is expensive; flushing inside the REST request
	 * that changed the setting happens before the endpoint is registered on
	 * the next page load, so it flushes the wrong rule set. Deferring to the
	 * next `init` is the only ordering that works.
	 */
	public const FLUSH_OPTION = 'warranty_cart_flush_rewrites';

	/**
	 * Constructor
	 */
	public function __construct() {
		\add_action( 'init', array( $this, 'add_endpoint' ) );
		\add_filter( 'query_vars', array( $this, 'add_query_var' ) );
		\add_filter( 'woocommerce_account_menu_items', array( $this, 'add_menu_item' ) );
		\add_action(
			'woocommerce_account_' . self::ENDPOINT . '_endpoint',
			array( $this, 'render' )
		);
	}

	/**
	 * Whether the tab should appear.
	 *
	 * @return bool
	 */
	public static function is_enabled(): bool {
		return (bool) \get_option( self::ENABLED_OPTION, true );
	}

	/**
	 * The tab's label, falling back to a sensible default.
	 *
	 * @return string
	 */
	public static function label(): string {
		$label = \get_option( self::LABEL_OPTION, '' );

		if ( ! is_string( $label ) || '' === trim( $label ) ) {
			return \__( 'Warranties', 'warranty-cart' );
		}

		return $label;
	}

	/**
	 * Register the endpoint, and rebuild rewrite rules when asked.
	 *
	 * The endpoint is registered whether or not the tab is enabled. Removing
	 * it when the setting is switched off would 404 any bookmark a customer
	 * already has; hiding the menu item is enough.
	 *
	 * @return void
	 */
	public function add_endpoint(): void {
		\add_rewrite_endpoint( self::ENDPOINT, EP_ROOT | EP_PAGES );

		if ( \get_option( self::FLUSH_OPTION ) ) {
			\delete_option( self::FLUSH_OPTION );
			\flush_rewrite_rules( false );
		}
	}

	/**
	 * Make the endpoint's value readable.
	 *
	 * @param array<int, string> $vars Query vars.
	 *
	 * @return array<int, string>
	 */
	public function add_query_var( $vars ): array {
		$vars[] = self::ENDPOINT;

		return $vars;
	}

	/**
	 * Slot the tab into the account menu.
	 *
	 * Inserted before "Logout" rather than appended, because appending puts it
	 * after the logout link, which reads as though the account section has
	 * ended.
	 *
	 * @param array<string, string> $items Menu items.
	 *
	 * @return array<string, string>
	 */
	public function add_menu_item( $items ): array {
		if ( ! self::is_enabled() || ! is_array( $items ) ) {
			return $items;
		}

		$logout = isset( $items['customer-logout'] ) ? $items['customer-logout'] : null;

		if ( null !== $logout ) {
			unset( $items['customer-logout'] );
		}

		$items[ self::ENDPOINT ] = self::label();

		if ( null !== $logout ) {
			$items['customer-logout'] = $logout;
		}

		return $items;
	}

	/**
	 * Render the tab: one warranty, or the list of them.
	 *
	 * WooCommerce hands the endpoint's value to this action, so
	 * `/my-account/warranties/` lists and `/my-account/warranties/42/` opens
	 * one — the same shape as `view-order/42`, which is the page customers
	 * already know.
	 *
	 * @param string $value The endpoint value, if any.
	 *
	 * @return void
	 */
	public function render( $value = '' ): void {
		$number      = is_numeric( $value ) ? (int) $value : 0;
		$customer_id = \get_current_user_id();

		/*
		 * One numeric endpoint value, two meanings.
		 *
		 * `/warranties/2/` is either the customer's warranty #2 or the second
		 * page of their list, and WooCommerce's own tabs have the same
		 * ambiguity — `orders/2` is page two because an order is never
		 * addressed that way. Here it is, so the number is resolved by asking
		 * whether this customer owns a warranty with that ID: if they do it is
		 * a warranty, and if they do not it is a page number.
		 *
		 * That ordering matters. A customer with three warranties whose IDs
		 * happen to be 1, 2 and 3 would otherwise find pages two and three of
		 * their own list unreachable.
		 */
		$warranty = $number > 0 ? $this->get_customer_warranty( $number, $customer_id ) : null;

		if ( null !== $warranty ) {
			$this->render_single( $warranty );

			return;
		}

		/*
		 * A number that is not one of their warranties and not a page they have
		 * is a bad address, not an empty list — the same distinction
		 * WooCommerce draws when you ask for page nine of four.
		 */
		if ( $number > 1 ) {
			$listing = $this->get_customer_warranties( $customer_id, $number, self::per_page() );

			if ( empty( $listing['warranties'] ) ) {
				$this->render_missing();

				return;
			}
		}

		$this->render_list( max( 1, $number ) );
	}

	/**
	 * The warranty asked for is not this customer's, or does not exist.
	 *
	 * @return void
	 */
	private function render_missing(): void {
		\wc_get_template(
			'frontend/my-account-warranty-missing.php',
			array( 'list_url' => \wc_get_endpoint_url( self::ENDPOINT, '', \wc_get_page_permalink( 'myaccount' ) ) ),
			'warranty-cart',
			WARRANTY_CART_PATH . 'templates/'
		);
	}

	/**
	 * One warranty, in full.
	 *
	 * @param object $warranty The record, already checked against its owner.
	 *
	 * @return void
	 */
	private function render_single( object $warranty ): void {
		$customer_id = \get_current_user_id();
		$warranty_id = (int) $warranty->id;

		\wc_get_template(
			'frontend/my-account-warranty.php',
			array(
				'warranty'         => $warranty,
				'renewals'         => Warranties::renewal_history( $warranty_id ),
				'serials_enabled'  => self::serials_enabled(),
				'claims'           => Claims::is_enabled()
					? array_values(
						array_filter(
							Claims::for_customer( $customer_id ),
							static function ( $claim ) use ( $warranty_id ) {
								return (int) $claim->warranty_id === $warranty_id;
							}
						)
					)
					: array(),
				'claims_enabled'   => Claims::is_enabled(),

				/*
				 * What the shop has said about each claim, keyed by claim.
				 *
				 * Fetched for the whole set rather than per row: a customer with
				 * six claims would otherwise cost six queries to render a page
				 * that is mostly empty.
				 */
				'claim_messages'   => Claims::is_enabled()
					? ClaimMessages::for_claims(
						array_map(
							static function ( $claim ) {
								return (int) $claim->id;
							},
							Claims::for_customer( $customer_id )
						)
					)
					: array(),
				'statuses'         => Claims::statuses(),
				'renewals_enabled' => Renewals::is_enabled(),
				'can_renew'        => Renewals::is_enabled()
					&& true === Renewals::can_renew( $warranty, $customer_id ),
				'list_url'         => \wc_get_endpoint_url( self::ENDPOINT, '', \wc_get_page_permalink( 'myaccount' ) ),
			),
			'warranty-cart',
			WARRANTY_CART_PATH . 'templates/'
		);
	}

	/**
	 * One of the customer's own warranties, or null.
	 *
	 * @param int $warranty_id Which one.
	 * @param int $customer_id Who is asking.
	 *
	 * @return object|null
	 */
	private function get_customer_warranty( int $warranty_id, int $customer_id ): ?object {
		global $wpdb;

		if ( $warranty_id < 1 || $customer_id < 1 ) {
			return null;
		}

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

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

		return $warranty ? $warranty : null;
	}

	/**
	 * Render the customer's warranties.
	 *
	 * @return void
	 */
	private function render_list( int $page = 1 ): void {
		$customer_id = \get_current_user_id();

		$per_page = self::per_page();

		$listing = $this->get_customer_warranties( $customer_id, $page, $per_page );

		\wc_get_template(
			'frontend/my-account-warranties.php',
			array(
				'warranties'       => $listing['warranties'],
				'total'            => $listing['total'],
				'page'             => $page,
				'pages'            => $listing['pages'],
				'endpoint'         => self::ENDPOINT,
				'claims'           => Claims::is_enabled()
					? Claims::for_customer( $customer_id )
					: array(),
				'claims_enabled'   => Claims::is_enabled(),
				'claim_messages'   => Claims::is_enabled()
					? ClaimMessages::for_claims(
						array_map(
							static function ( $claim ) {
								return (int) $claim->id;
							},
							Claims::for_customer( $customer_id )
						)
					)
					: array(),
				'statuses'         => Claims::statuses(),
				// Whether the column is drawn at all; each row then asks
				// Renewals whether *that* warranty can be renewed.
				'renewals_enabled' => Renewals::is_enabled(),

				/*
				 * How many times each warranty has been renewed.
				 *
				 * A count, not the history. The list showed the whole thing in a
				 * disclosure inside the Expires cell, which put a nested list
				 * inside a table cell on a screen that is already a table — the
				 * detail belongs on the warranty's own page, and the row only
				 * needs to say there is something to go and read.
				 */
				'renewal_counts'   => $this->renewal_counts( $customer_id ),
			),
			'warranty-cart',
			WARRANTY_CART_PATH . 'templates/'
		);
	}

	/**
	 * How many times each of a customer's warranties has been renewed.
	 *
	 * One query for the tab rather than one per row, and a count rather than
	 * the rows themselves — the list links to the warranty's page, which is
	 * where the history is read.
	 *
	 * Counted by joining to the warranties table on `customer_id`: this is the
	 * boundary between one customer's records and everybody else's, and it
	 * should not depend on the caller having filtered correctly.
	 *
	 * @param int $customer_id User ID.
	 *
	 * @return array<int, int> Keyed by warranty ID.
	 */
	private function renewal_counts( int $customer_id ): array {
		global $wpdb;

		$renewals   = $wpdb->prefix . 'warranty_cart_renewals';
		$warranties = $wpdb->prefix . 'warranty_cart_warranties';

		if ( $wpdb->get_var( $wpdb->prepare( 'SHOW TABLES LIKE %s', $renewals ) ) !== $renewals ) {
			return array();
		}

		// phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQL.NotPrepared
		$rows = $wpdb->get_results(
			$wpdb->prepare(
				"SELECT r.warranty_id, COUNT(*) AS total
				 FROM {$renewals} r
				 INNER JOIN {$warranties} w ON r.warranty_id = w.id
				 WHERE w.customer_id = %d
				 GROUP BY r.warranty_id",
				$customer_id
			)
		);
		// phpcs:enable WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQL.NotPrepared

		$counts = array();

		foreach ( is_array( $rows ) ? $rows : array() as $row ) {
			$counts[ (int) $row->warranty_id ] = (int) $row->total;
		}

		return $counts;
	}

	/**
	 * One customer's warranties, newest purchase first.
	 *
	 * @param int $customer_id User ID.
	 *
	 * @return array<int, object>
	 */
	private function get_customer_warranties( int $customer_id, int $page = 1, int $per_page = 0 ): array {
		global $wpdb;

		if ( $customer_id <= 0 ) {
			return array(
				'warranties' => array(),
				'total'      => 0,
				'pages'      => 0,
			);
		}

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

		/*
		 * The table name is interpolated, not passed as a placeholder.
		 * $wpdb->prepare() quotes %s with single quotes, which MySQL reads as
		 * a string literal rather than an identifier — the mistake that left
		 * this plugin's expiry cron silently failing for its whole life. The
		 * name is built from $wpdb->prefix and a literal, so there is nothing
		 * user-supplied in it.
		 */
		// phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQL.NotPrepared
		$total = (int) $wpdb->get_var(
			$wpdb->prepare(
				"SELECT COUNT(*) FROM {$table_name} WHERE customer_id = %d",
				$customer_id
			)
		);

		/*
		 * Paged, like the Orders tab beside it.
		 *
		 * This selected every warranty the customer had ever bought and the
		 * template rendered all of them. WooCommerce pages its own Orders tab
		 * at ten, and somebody who has bought cover on twenty appliances got a
		 * page that scrolled for a screen and a half with no way to move
		 * through it.
		 */
		$per_page = $per_page > 0 ? $per_page : 10;
		$page     = max( 1, $page );
		$offset   = ( $page - 1 ) * $per_page;

		$results = $wpdb->get_results(
			$wpdb->prepare(
				"SELECT * FROM {$table_name}
				 WHERE customer_id = %d
				 ORDER BY purchase_date DESC, id DESC
				 LIMIT %d OFFSET %d",
				$customer_id,
				$per_page,
				$offset
			)
		);
		// phpcs:enable WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQL.NotPrepared

		return array(
			'warranties' => is_array( $results ) ? $results : array(),
			'total'      => $total,
			'pages'      => (int) ceil( $total / $per_page ),
		);
	}
}
