<?php
/**
 * REST controller for support mailboxes — the channels (departments) tickets
 * are routed through.
 *
 * Routes registered (namespace `doublescale/v1`):
 *
 *   GET    /support/mailboxes           List mailboxes (paginated).
 *   POST   /support/mailboxes           Create a mailbox.
 *   GET    /support/mailboxes/{id}      Read one (with `data` JSON decoded).
 *   PUT    /support/mailboxes/{id}      Update mutable fields.
 *   DELETE /support/mailboxes/{id}      Delete (requires fallback_id; re-points tickets first).
 *   POST   /support/mailboxes/{id}/move-tickets  Bulk-move tickets to another mailbox.
 *
 * {@see MailboxModel::boot()} enforces the model invariants: unique slug
 * auto-generated from name on create, the "only one default at a time" rule,
 * and deriving the inbound `email` from the sending identity for web boxes. So
 * the controller stays thin: parse, validate, persist, shape — with the
 * exception of delete/move, which must re-point tickets (mailbox_id is NOT
 * NULL) before a row can be removed.
 *
 * The `data` LONGTEXT blob carries the display name and the sending-identity
 * `from_email`. Accessor/mutator on the model handle encode/decode, so the
 * controller receives/returns plain associative arrays.
 *
 * @since 1.0.0
 * @package DoubleScale\Modules\Support
 */

namespace DoubleScale\Modules\Support\Rest\Controllers;

defined( 'ABSPATH' ) || exit;

use DoubleScale\Core\Abstracts\RestController;
use DoubleScale\Core\UserRoles\Permissions;
use DoubleScale\Modules\Support\Models\MailboxModel;
use DoubleScale\Modules\Support\Models\TicketModel;
use DoubleScale\Modules\Support\Services\EmailNotifications;
use WP_Error;
use WP_REST_Request;
use WP_REST_Response;
use WP_REST_Server;

/**
 * RestMailboxController class.
 */
class RestMailboxController extends RestController {

	/**
	 * Route base. Combined with parent's `$namespace` gives
	 * `doublescale/v1/support/mailboxes`.
	 *
	 * @var string
	 */
	protected $rest_base = 'support/mailboxes';

	/**
	 * Columns the client may set / change directly. `slug` is generated by the
	 * model boot event when omitted, but the operator can supply one explicitly
	 * (validated client-side as URL-safe — the model retries on UNIQUE collision).
	 *
	 * `email` is intentionally NOT writable: a mailbox IS its SMTP connection, so
	 * the model mirrors `email` from `data.identity.from_email` on every
	 * save (see {@see MailboxModel::populate_email_from_identity()}). The client
	 * never sends it for any box type.
	 *
	 * @var string[]
	 */
	private const WRITABLE_FIELDS = array( 'slug', 'box_type', 'is_default', 'data' );

	/**
	 * Allowed `box_type` values. `web` accepts tickets via portal/forms only;
	 * `email` is the "web + email" superset that additionally accepts inbound
	 * email. Inbound is polled per-mailbox over IMAP (Pro's MailboxImapPoller)
	 * using either the From address's Gmail/Outlook OAuth credentials OR the
	 * mailbox's own custom-IMAP block; forwarded mail into the CRM inbox
	 * (InboundTicketRouter matching `to_email` against `email`) remains a fallback.
	 *
	 * @var string[]
	 */
	private const BOX_TYPES = array( 'web', 'email' );

	/**
	 * Register routes.
	 *
	 * @return void
	 */
	public function register_routes() {
		register_rest_route(
			$this->namespace,
			'/' . $this->rest_base,
			array(
				array(
					'methods'             => WP_REST_Server::READABLE,
					'callback'            => array( $this, 'get_items' ),
					'permission_callback' => array( $this, 'get_items_permissions_check' ),
					'args'                => $this->get_collection_params(),
				),
				array(
					'methods'             => WP_REST_Server::CREATABLE,
					'callback'            => array( $this, 'create_item' ),
					'permission_callback' => array( $this, 'create_item_permissions_check' ),
				),
			)
		);

		register_rest_route(
			$this->namespace,
			'/' . $this->rest_base . '/(?P<id>[\d]+)',
			array(
				array(
					'methods'             => WP_REST_Server::READABLE,
					'callback'            => array( $this, 'get_item' ),
					'permission_callback' => array( $this, 'get_item_permissions_check' ),
				),
				array(
					'methods'             => WP_REST_Server::EDITABLE,
					'callback'            => array( $this, 'update_item' ),
					'permission_callback' => array( $this, 'update_item_permissions_check' ),
				),
				array(
					'methods'             => WP_REST_Server::DELETABLE,
					'callback'            => array( $this, 'delete_item' ),
					'permission_callback' => array( $this, 'delete_item_permissions_check' ),
				),
			)
		);

		register_rest_route(
			$this->namespace,
			'/' . $this->rest_base . '/(?P<id>[\d]+)/move-tickets',
			array(
				array(
					'methods'             => WP_REST_Server::CREATABLE,
					'callback'            => array( $this, 'move_tickets' ),
					'permission_callback' => array( $this, 'delete_item_permissions_check' ),
				),
			)
		);

		// Custom-IMAP connection test for the email-channel editor. No {id} in the
		// path: the editor tests credentials being typed BEFORE the mailbox is
		// saved (an `id` may be supplied in the body to resolve a masked password).
		register_rest_route(
			$this->namespace,
			'/' . $this->rest_base . '/test-imap',
			array(
				array(
					'methods'             => WP_REST_Server::CREATABLE,
					'callback'            => array( $this, 'test_imap_connection' ),
					'permission_callback' => array( $this, 'create_item_permissions_check' ),
				),
			)
		);

		// Save the attachment limits (max file size + max file count). The current
		// values are returned with the mailbox list `meta.attachment_limits`, so a
		// read-only route is unnecessary.
		register_rest_route(
			$this->namespace,
			'/' . $this->rest_base . '/attachment-settings',
			array(
				array(
					'methods'             => WP_REST_Server::CREATABLE,
					'callback'            => array( $this, 'save_attachment_settings' ),
					'permission_callback' => array( $this, 'create_item_permissions_check' ),
					'args'                => array(
						'max_file_size_mb' => array(
							'type'    => 'integer',
							'minimum' => 1,
						),
						'max_file_count'   => array(
							'type'    => 'integer',
							'minimum' => 1,
						),
					),
				),
			)
		);
	}

	/**
	 * Standard collection params schema; called by core REST validation.
	 *
	 * @return array
	 */
	public function get_collection_params() {
		return array(
			'box_type' => array(
				'description' => __( 'Filter by mailbox channel type.', 'doublescale' ),
				'type'        => 'string',
				'enum'        => self::BOX_TYPES,
			),
			'search'   => array(
				'description' => __( 'Match on slug or email.', 'doublescale' ),
				'type'        => 'string',
			),
			'per_page' => array(
				'description' => __( 'Items per page (1-100).', 'doublescale' ),
				'type'        => 'integer',
				'default'     => 50,
				'minimum'     => 1,
				'maximum'     => 100,
			),
			'page'     => array(
				'description' => __( 'Page number.', 'doublescale' ),
				'type'        => 'integer',
				'default'     => 1,
				'minimum'     => 1,
			),
		);
	}

	// ---------------------------------------------------------------------
	// Endpoints
	// ---------------------------------------------------------------------

	/**
	 * List mailboxes.
	 *
	 * @param WP_REST_Request $request Request.
	 * @return WP_REST_Response|WP_Error
	 */
	public function get_items( $request ) {
		$disabled = $this->require_module( 'support' );
		if ( $disabled ) {
			return $disabled;
		}

		// withCount adds a `tickets_count` subquery per row (one query, no N+1) so
		// the list can show how many tickets each mailbox holds.
		$query = MailboxModel::query()->withCount( 'tickets' );

		$box_type = $request->get_param( 'box_type' );
		if ( is_string( $box_type ) && in_array( $box_type, self::BOX_TYPES, true ) ) {
			$query->where( 'box_type', $box_type );
		}

		$search = $request->get_param( 'search' );
		if ( is_string( $search ) && '' !== trim( $search ) ) {
			$needle = '%' . str_replace( array( '%', '_' ), array( '\\%', '\\_' ), $search ) . '%';
			$query->where(
				function ( $q ) use ( $needle ) {
					$q->where( 'slug', 'LIKE', $needle )
						->orWhere( 'email', 'LIKE', $needle );
				}
			);
		}

		// Default first, then alpha by slug — gives admin UIs a stable order.
		$query->orderBy( 'is_default', 'desc' )->orderBy( 'slug', 'asc' );

		$per_page_raw = (int) $request->get_param( 'per_page' );
		$per_page     = max( 1, min( 100, $per_page_raw > 0 ? $per_page_raw : 50 ) );
		$page_raw     = (int) $request->get_param( 'page' );
		$page         = max( 1, $page_raw > 0 ? $page_raw : 1 );

		$paginator = $query->paginate( $per_page, array( '*' ), 'page', $page );

		$data = array();
		foreach ( $paginator->items() as $mailbox ) {
			$data[] = $this->shape_mailbox( $mailbox );
		}

		return new WP_REST_Response(
			array(
				'data' => $data,
				'meta' => array(
					'total'                 => $paginator->total(),
					'per_page'              => $per_page,
					'current_page'          => $page,
					'last_page'             => max( 1, (int) ceil( $paginator->total() / $per_page ) ),
					// Editor metadata, sent with the list so the mailbox editor needs no
					// extra requests: `smtp_senders` is every SMTP connection's From address
					// for the sending-identity picker (admin-tier, NOT per-user scoped),
					// `receivable_emails` is the subset that can receive over IMAP (drives
					// the email-box gate), and `notification_defaults` is the built-in
					// template copy the per-mailbox editor pre-fills. The chosen address is
					// re-validated on save (validate_identity()).
					'smtp_senders'          => $this->smtp_senders(),
					'receivable_emails'     => $this->receivable_emails(),
					'notification_defaults' => EmailNotifications::default_templates(),
					// Attachment limits (admin-configurable) so the Mailboxes page can
					// render the "Attachment limits" card and the ticket composer can
					// pre-validate uploads without an extra request.
					'attachment_limits'     => \DoubleScale\Modules\Support\Services\AttachmentSettings::to_payload(),
				),
			),
			200
		);
	}

	/**
	 * SMTP connections offered in the From-address combobox. No ownership gate:
	 * support mailbox config is admin-tier ({@see \DoubleScale\Core\UserRoles\Permissions::can_access_support_settings()}),
	 * so every org/shared/personal connection with a From address is selectable.
	 * Shaped for the frontend `FromEmailSelector` (connection_id / email / name).
	 *
	 * @return array<int, array{connection_id:string, email:string, name:string}>
	 */
	private function smtp_senders(): array {
		if ( ! class_exists( '\DoubleScale\Modules\Smtp\Settings' ) ) {
			return array();
		}
		$out = array();
		foreach ( \DoubleScale\Modules\Smtp\Settings::get_connections_for_support() as $conn ) {
			$out[] = array(
				'connection_id' => (string) $conn['connection_id'],
				'email'         => (string) $conn['from_email'],
				'name'          => (string) $conn['from_name'],
			);
		}
		return $out;
	}

	/**
	 * Lower-cased From addresses that can RECEIVE over IMAP (Gmail/Outlook OAuth):
	 * the subset of {@see smtp_senders()} an `email` (inbound) box may bind. The
	 * editor cross-references the chosen From address against this list to gate the
	 * IMAP toggle; the same rule is enforced server-side in {@see validate_receivability()}.
	 *
	 * @return array<int, string>
	 */
	private function receivable_emails(): array {
		if ( ! class_exists( '\DoubleScale\Modules\Smtp\Settings' ) ) {
			return array();
		}
		$out = array();
		foreach ( \DoubleScale\Modules\Smtp\Settings::get_connections_for_support() as $conn ) {
			if ( ! empty( $conn['is_receivable'] ) ) {
				$out[] = strtolower( trim( (string) $conn['from_email'] ) );
			}
		}
		return array_values( array_unique( $out ) );
	}

	/**
	 * Get one mailbox by id.
	 *
	 * @param WP_REST_Request $request Request.
	 * @return WP_REST_Response|WP_Error
	 */
	public function get_item( $request ) {
		$disabled = $this->require_module( 'support' );
		if ( $disabled ) {
			return $disabled;
		}

		$mailbox = MailboxModel::find( (int) $request->get_param( 'id' ) );
		if ( ! $mailbox ) {
			return new WP_Error( 'not_found', __( 'Mailbox not found.', 'doublescale' ), array( 'status' => 404 ) );
		}

		return new WP_REST_Response( $this->shape_mailbox( $mailbox ), 200 );
	}

	/**
	 * Create a mailbox.
	 *
	 * A sending identity (`data.identity.from_email`) is required. `email` is
	 * required only for `email` boxes (the inbound address); for `web` boxes the
	 * model derives it from the identity. Slug auto-generates from `data.name`
	 * via the model's `creating` boot event.
	 *
	 * @param WP_REST_Request $request Request.
	 * @return WP_REST_Response|WP_Error
	 */
	public function create_item( $request ) {
		$disabled = $this->require_module( 'support' );
		if ( $disabled ) {
			return $disabled;
		}

		$params = $request->get_json_params();
		if ( ! is_array( $params ) ) {
			$params = $request->get_params();
		}

		$validation = $this->validate( $params, true );
		if ( is_wp_error( $validation ) ) {
			return $validation;
		}

		$attributes = $this->extract_writable( $params, null );

		try {
			$mailbox = new MailboxModel();
			foreach ( $attributes as $key => $value ) {
				$mailbox->{$key} = $value;
			}
			$mailbox->save();
		} catch ( \Throwable $e ) {
			return new WP_Error( 'mailbox_create_failed', $e->getMessage(), array( 'status' => 500 ) );
		}

		return new WP_REST_Response( $this->shape_mailbox( $mailbox ), 201 );
	}

	/**
	 * Update a mailbox.
	 *
	 * Partial-update semantics — only the keys present in the request body are
	 * touched; everything else is left alone.
	 *
	 * @param WP_REST_Request $request Request.
	 * @return WP_REST_Response|WP_Error
	 */
	public function update_item( $request ) {
		$disabled = $this->require_module( 'support' );
		if ( $disabled ) {
			return $disabled;
		}

		$mailbox = MailboxModel::find( (int) $request->get_param( 'id' ) );
		if ( ! $mailbox ) {
			return new WP_Error( 'not_found', __( 'Mailbox not found.', 'doublescale' ), array( 'status' => 404 ) );
		}

		$params = $request->get_json_params();
		if ( ! is_array( $params ) ) {
			$params = $request->get_params();
		}
		unset( $params['id'] );

		$validation = $this->validate( $params, false, $mailbox );
		if ( is_wp_error( $validation ) ) {
			return $validation;
		}

		$attributes = $this->extract_writable( $params, $mailbox );

		try {
			foreach ( $attributes as $key => $value ) {
				$mailbox->{$key} = $value;
			}
			$mailbox->save();
		} catch ( \Throwable $e ) {
			return new WP_Error( 'mailbox_update_failed', $e->getMessage(), array( 'status' => 500 ) );
		}

		return new WP_REST_Response( $this->shape_mailbox( $mailbox ), 200 );
	}

	/**
	 * Delete a mailbox, re-pointing its tickets to an operator-chosen fallback.
	 *
	 * `mailbox_id` is NOT NULL, so a mailbox can only be removed once nothing
	 * references it. The caller MUST supply a `fallback_id`: we
	 * move every ticket on the deleted mailbox to the fallback FIRST, then
	 * delete. The last remaining mailbox cannot be deleted (a ticket must always
	 * have a home).
	 *
	 * The re-point uses a bulk `Builder::update()`, which writes straight to the
	 * DB and bypasses {@see \DoubleScale\Modules\Support\Services\TicketService::update_ticket()}
	 * — and therefore the `doublescale_support_ticket_updated` action — so no
	 * per-ticket `mailbox_changed` audit row is logged for what is an
	 * administrative bulk move (do NOT route this through TicketService).
	 *
	 * @param WP_REST_Request $request Request.
	 * @return WP_REST_Response|WP_Error
	 */
	public function delete_item( $request ) {
		$disabled = $this->require_module( 'support' );
		if ( $disabled ) {
			return $disabled;
		}

		$id      = (int) $request->get_param( 'id' );
		$mailbox = MailboxModel::find( $id );
		if ( ! $mailbox ) {
			return new WP_Error( 'not_found', __( 'Mailbox not found.', 'doublescale' ), array( 'status' => 404 ) );
		}

		// A ticket must always have a mailbox — refuse to delete the last one.
		if ( MailboxModel::query()->count() <= 1 ) {
			return new WP_Error(
				'cannot_delete_last_mailbox',
				__( 'You cannot delete the only mailbox. Create another mailbox first.', 'doublescale' ),
				array( 'status' => 409 )
			);
		}

		if ( $mailbox->is_default ) {
			return new WP_Error(
				'cannot_delete_default_mailbox',
				__( 'You cannot delete the default mailbox. Set another mailbox as default first.', 'doublescale' ),
				array( 'status' => 409 )
			);
		}

		$params      = $request->get_json_params();
		$fallback_id = is_array( $params ) && isset( $params['fallback_id'] ) ? (int) $params['fallback_id'] : 0;
		if ( $fallback_id <= 0 ) {
			return new WP_Error(
				'fallback_required',
				__( 'A fallback mailbox is required so existing tickets can be re-routed before deletion.', 'doublescale' ),
				array( 'status' => 400 )
			);
		}
		if ( $fallback_id === $id ) {
			return new WP_Error(
				'invalid_fallback',
				__( 'The fallback mailbox must be different from the one being deleted.', 'doublescale' ),
				array( 'status' => 400 )
			);
		}

		$fallback = MailboxModel::find( $fallback_id );
		if ( ! $fallback ) {
			return new WP_Error( 'invalid_fallback', __( 'The fallback mailbox does not exist.', 'doublescale' ), array( 'status' => 400 ) );
		}

		$was_default = (bool) $mailbox->is_default;

		try {
			// Re-point tickets BEFORE delete (bulk update — see method docblock).
			$moved = TicketModel::where( 'mailbox_id', $id )->update( array( 'mailbox_id' => $fallback_id ) );

			// The deleted mailbox was the default — promote the fallback so the
			// "default mailbox for new tickets" invariant survives.
			if ( $was_default ) {
				$fallback->is_default = true;
				$fallback->save();
			}

			$mailbox->delete();
		} catch ( \Throwable $e ) {
			return new WP_Error( 'mailbox_delete_failed', $e->getMessage(), array( 'status' => 500 ) );
		}

		/**
		 * Fires after a mailbox is deleted and its tickets re-pointed.
		 *
		 * @param int          $id       The id of the deleted mailbox.
		 * @param MailboxModel $fallback The mailbox tickets were moved to.
		 */
		do_action( 'doublescale_support_mailbox_deleted', $id, $fallback );

		doublescale_get_logger()->info(
			'Support mailbox deleted and tickets re-pointed.',
			array(
				'source'      => 'support-mailbox-rest',
				'mailbox_id'  => $id,
				'fallback_id' => $fallback_id,
				'moved'       => (int) $moved,
			)
		);

		return new WP_REST_Response( null, 204 );
	}

	/**
	 * Move tickets off a mailbox onto another, without deleting the source.
	 *
	 * Intent is derived from `ticket_ids`: present and non-empty moves only that
	 * subset (scoped with `where('mailbox_id', $id)` so a caller cannot move
	 * another mailbox's tickets); absent moves ALL of the source mailbox's
	 * tickets. Same bulk-`Builder::update()` reasoning as {@see delete_item()} —
	 * no per-ticket `mailbox_changed` audit rows for an administrative move.
	 *
	 * @param WP_REST_Request $request Request.
	 * @return WP_REST_Response|WP_Error
	 */
	public function move_tickets( $request ) {
		$disabled = $this->require_module( 'support' );
		if ( $disabled ) {
			return $disabled;
		}

		$id      = (int) $request->get_param( 'id' );
		$mailbox = MailboxModel::find( $id );
		if ( ! $mailbox ) {
			return new WP_Error( 'not_found', __( 'Mailbox not found.', 'doublescale' ), array( 'status' => 404 ) );
		}

		$params     = $request->get_json_params();
		$params     = is_array( $params ) ? $params : array();
		$new_box_id = isset( $params['new_box_id'] ) ? (int) $params['new_box_id'] : 0;
		if ( $new_box_id <= 0 || $new_box_id === $id ) {
			return new WP_Error(
				'invalid_destination',
				__( 'A destination mailbox different from the source is required.', 'doublescale' ),
				array( 'status' => 400 )
			);
		}
		if ( ! MailboxModel::find( $new_box_id ) ) {
			return new WP_Error( 'invalid_destination', __( 'The destination mailbox does not exist.', 'doublescale' ), array( 'status' => 400 ) );
		}

		$query = TicketModel::where( 'mailbox_id', $id );

		// Subset move: only the named tickets, and only if they live on this box.
		$ticket_ids = isset( $params['ticket_ids'] ) && is_array( $params['ticket_ids'] )
			? array_values( array_filter( array_map( 'intval', $params['ticket_ids'] ) ) )
			: array();
		if ( ! empty( $ticket_ids ) ) {
			$query->whereIn( 'id', $ticket_ids );
		}

		try {
			$moved = $query->update( array( 'mailbox_id' => $new_box_id ) );
		} catch ( \Throwable $e ) {
			return new WP_Error( 'mailbox_move_failed', $e->getMessage(), array( 'status' => 500 ) );
		}

		/**
		 * Fires after tickets are bulk-moved between mailboxes.
		 *
		 * @param int $id         Source mailbox id.
		 * @param int $new_box_id Destination mailbox id.
		 * @param int $moved      Number of tickets moved.
		 */
		do_action( 'doublescale_support_tickets_moved', $id, $new_box_id, (int) $moved );

		return new WP_REST_Response( array( 'moved' => (int) $moved ), 200 );
	}

	/**
	 * Test a custom-IMAP connection for the email-channel editor.
	 *
	 * Mirrors {@see \DoubleScale\Core\Settings\Rest\RestSettingsControllerPro::test_custom_imap_connection()}:
	 * opens a `login`-auth {@see \DoubleScale\Modules\Tracking\ImapClient}, counts
	 * recent unseen mail (today onward — a full UNSEEN count is misleading on inboxes
	 * with a large read-but-unflagged backlog), and disconnects. Always returns HTTP
	 * 200 with `{ success, message, unseen_count }` so the editor can render the
	 * outcome inline (a failed connection is a 200 with `success:false`, not a 5xx).
	 *
	 * The password follows the masked-credential convention: '********' with an `id`
	 * in the body resolves (and decrypts) the stored password for that mailbox, so
	 * the operator can re-test a saved box without re-typing the secret.
	 *
	 * @param WP_REST_Request $request Request.
	 * @return WP_REST_Response|WP_Error
	 */
	public function test_imap_connection( $request ) {
		$disabled = $this->require_module( 'support' );
		if ( $disabled ) {
			return $disabled;
		}

		if ( ! class_exists( '\DoubleScale\Modules\Tracking\ImapClient' ) ) {
			// ImapClient ships in the Tracking module (bundled with Pro). Without it
			// there is no transport to test against.
			return new WP_Error(
				'imap_client_unavailable',
				__( 'IMAP testing requires DoubleScale Pro.', 'doublescale' ),
				array( 'status' => 501 )
			);
		}

		$params = $request->get_json_params();
		if ( ! is_array( $params ) ) {
			$params = $request->get_params();
		}

		$host       = sanitize_text_field( (string) ( $params['host'] ?? '' ) );
		$username   = sanitize_text_field( (string) ( $params['username'] ?? '' ) );
		$encryption = strtolower( trim( (string) ( $params['encryption'] ?? 'ssl' ) ) );
		if ( ! in_array( $encryption, array( 'ssl', 'tls', 'none' ), true ) ) {
			$encryption = 'ssl';
		}
		$port = (int) ( $params['port'] ?? 0 );
		if ( $port <= 0 ) {
			$port = 993;
		}

		// Resolve the password: '********' + id → decrypt the stored value; otherwise
		// use the submitted plaintext as-is.
		$password = (string) ( $params['password'] ?? '' );
		if ( '********' === $password ) {
			$password = $this->stored_imap_password( isset( $params['id'] ) ? (int) $params['id'] : 0 );
		}

		if ( '' === $host || '' === $username || '' === $password ) {
			return new WP_REST_Response(
				array(
					'success' => false,
					'message' => __( 'Please provide host, username, and password.', 'doublescale' ),
				),
				200
			);
		}

		try {
			$client = new \DoubleScale\Modules\Tracking\ImapClient( $host, $port, $username, $password, $encryption, 'login' );
			$client->connect();
			$unseen_count = $client->count_unseen( gmdate( 'Y-m-d' ) );
			$client->disconnect();

			return new WP_REST_Response(
				array(
					'success'      => true,
					'message'      => sprintf(
						/* translators: %d: number of recent unseen emails */
						__( 'Connected successfully. Found %d new unseen email(s) today.', 'doublescale' ),
						$unseen_count
					),
					'unseen_count' => $unseen_count,
				),
				200
			);
		} catch ( \Throwable $e ) {
			return new WP_REST_Response(
				array(
					'success' => false,
					'message' => $e->getMessage(),
				),
				200
			);
		}
	}

	/**
	 * Save the attachment limits (max file size + max file count).
	 *
	 * Values are sanitized/clamped in {@see AttachmentSettings::save()}; the
	 * stored, clamped values are echoed back (alongside the derived byte cap) so
	 * the UI reflects any clamping immediately.
	 *
	 * @param WP_REST_Request $request Request.
	 * @return WP_REST_Response|WP_Error
	 */
	public function save_attachment_settings( $request ) {
		$disabled = $this->require_module( 'support' );
		if ( $disabled ) {
			return $disabled;
		}

		$params = $request->get_json_params();
		if ( ! is_array( $params ) ) {
			$params = $request->get_params();
		}

		\DoubleScale\Modules\Support\Services\AttachmentSettings::save(
			array(
				'max_file_size_mb' => $params['max_file_size_mb'] ?? null,
				'max_file_count'   => $params['max_file_count'] ?? null,
			)
		);

		return new WP_REST_Response(
			array(
				'success'           => true,
				'message'           => __( 'Attachment limits saved.', 'doublescale' ),
				'attachment_limits' => \DoubleScale\Modules\Support\Services\AttachmentSettings::to_payload(),
			),
			200
		);
	}

	/**
	 * Resolve and decrypt a mailbox's stored custom-IMAP password (for masked
	 * re-tests). Returns '' when the id is unknown or no password is stored.
	 *
	 * @param int $mailbox_id Mailbox id from the test request body.
	 * @return string Decrypted password, or ''.
	 */
	private function stored_imap_password( int $mailbox_id ): string {
		if ( $mailbox_id <= 0 ) {
			return '';
		}
		$mailbox = MailboxModel::find( $mailbox_id );
		if ( ! $mailbox || ! is_array( $mailbox->data ) || ! isset( $mailbox->data['imap']['password'] ) ) {
			return '';
		}
		$stored = (string) $mailbox->data['imap']['password'];
		if ( '' === $stored || ! class_exists( '\DoubleScale\Core\Settings\Settings' ) ) {
			return $stored;
		}
		return \DoubleScale\Core\Settings\Settings::decrypt_value( $stored );
	}

	// ---------------------------------------------------------------------
	// Permission checks
	// ---------------------------------------------------------------------

	/**
	 * Mailbox configuration is manager-tier: Administrators, CRM Managers, and
	 * Support Managers only. Support Agents are excluded — they work their
	 * assigned tickets, not channel configuration.
	 *
	 * @param WP_REST_Request $request Unused — present for the WP REST contract.
	 * @return bool|WP_Error
	 */
	public function get_items_permissions_check( $request ) { // phpcs:ignore VariableAnalysis.CodeAnalysis.VariableAnalysis.UnusedVariable
		return $this->require_support_settings_access();
	}

	/**
	 * @param WP_REST_Request $request Unused — see {@see get_items_permissions_check()}.
	 * @return bool|WP_Error
	 */
	public function get_item_permissions_check( $request ) { // phpcs:ignore VariableAnalysis.CodeAnalysis.VariableAnalysis.UnusedVariable
		return $this->require_support_settings_access();
	}

	/**
	 * @param WP_REST_Request $request Unused — see {@see get_items_permissions_check()}.
	 * @return bool|WP_Error
	 */
	public function create_item_permissions_check( $request ) { // phpcs:ignore VariableAnalysis.CodeAnalysis.VariableAnalysis.UnusedVariable
		return $this->require_support_settings_access();
	}

	/**
	 * @param WP_REST_Request $request Unused — see {@see get_items_permissions_check()}.
	 * @return bool|WP_Error
	 */
	public function update_item_permissions_check( $request ) { // phpcs:ignore VariableAnalysis.CodeAnalysis.VariableAnalysis.UnusedVariable
		return $this->require_support_settings_access();
	}

	/**
	 * @param WP_REST_Request $request Unused — see {@see get_items_permissions_check()}.
	 * @return bool|WP_Error
	 */
	public function delete_item_permissions_check( $request ) { // phpcs:ignore VariableAnalysis.CodeAnalysis.VariableAnalysis.UnusedVariable
		return $this->require_support_settings_access();
	}

	// ---------------------------------------------------------------------
	// Internals
	// ---------------------------------------------------------------------

	/**
	 * Validate a request payload.
	 *
	 * A support mailbox IS an SMTP connection, so validation is about the bound
	 * identity, not a typed address:
	 *   - A sending identity (`data.identity.from_email`) is REQUIRED on
	 *     create and may not be cleared on update — it is the source of both the
	 *     outbound From and the derived `email`. The client never sends `email`;
	 *     the model derives it on save (see {@see MailboxModel}).
	 *   - `box_type='email'` (inbound IMAP) additionally requires a
	 *     *receive-capable* connection (Gmail/Outlook OAuth); a send-only
	 *     connection is rejected with `connection_not_receivable`.
	 *
	 * Updates allow partial bodies — only keys actually present are validated; the
	 * effective box_type / connection for the receivability gate fall back to the
	 * existing row when the payload omits them.
	 *
	 * @param array             $params    Request body.
	 * @param bool              $is_create True for POST, false for PUT.
	 * @param MailboxModel|null $existing  Row being updated (PUT), for effective-value fallback.
	 * @return true|WP_Error
	 */
	private function validate( array $params, bool $is_create, ?MailboxModel $existing = null ) {
		if ( isset( $params['box_type'] ) && ! in_array( $params['box_type'], self::BOX_TYPES, true ) ) {
			return new WP_Error(
				'invalid_box_type',
				/* translators: %s — allowed comma-separated values. */
				sprintf( __( 'Invalid box_type. Allowed: %s.', 'doublescale' ), implode( ', ', self::BOX_TYPES ) ),
				array( 'status' => 400 )
			);
		}

		if ( isset( $params['data'] ) && ! is_array( $params['data'] ) ) {
			return new WP_Error( 'invalid_data', __( 'The data field must be an object.', 'doublescale' ), array( 'status' => 400 ) );
		}

		$identity_error = $this->validate_identity( $params, $is_create );
		if ( is_wp_error( $identity_error ) ) {
			return $identity_error;
		}

		// Inbound (email) boxes require a receive-capable (Gmail/Outlook OAuth)
		// connection — there is no IMAP credential to poll otherwise.
		$receivable_error = $this->validate_receivability( $params, $existing );
		if ( is_wp_error( $receivable_error ) ) {
			return $receivable_error;
		}

		if (
			$existing
			&& $existing->is_default
			&& isset( $params['is_default'] )
			&& ! (bool) $params['is_default']
		) {
			return new WP_Error(
				'cannot_unset_default_mailbox',
				__( 'The default mailbox cannot be unset. Set another mailbox as default first.', 'doublescale' ),
				array( 'status' => 400 )
			);
		}

		return true;
	}

	/**
	 * Reject a `box_type='email'` mailbox that has no way to receive.
	 *
	 * Inbound is polled over IMAP (Pro's MailboxImapPoller) by one of two routes,
	 * either is enough to make a box receive-capable:
	 *   1. the From address is a Gmail/Outlook OAuth account (polled via its own
	 *      OAuth credentials); OR
	 *   2. the mailbox carries a complete custom-IMAP block
	 *      (`data.imap.host`/`username`/`password`) polled with basic `login` auth.
	 *
	 * Effective box_type and the custom-IMAP block fall back to the existing row
	 * for partial updates, so flipping a web box to email — or clearing the IMAP
	 * block on an email box bound to a non-OAuth address — is caught even with a
	 * partial body. The merged `data` (existing overlaid with the payload) is what
	 * we test, so a PUT that only sends `data.imap.password` still validates against
	 * the host/username already stored.
	 *
	 * @param array             $params   Request body.
	 * @param MailboxModel|null $existing Row being updated (null on create).
	 * @return true|WP_Error
	 */
	private function validate_receivability( array $params, ?MailboxModel $existing ) {
		$box_type = isset( $params['box_type'] )
			? (string) $params['box_type']
			: ( $existing ? (string) $existing->box_type : 'web' );

		if ( 'email' !== $box_type ) {
			return true;
		}

		$from_email = '';
		if ( isset( $params['data']['identity']['from_email'] ) ) {
			$from_email = (string) $params['data']['identity']['from_email'];
		} elseif ( $existing ) {
			$existing_data = is_array( $existing->data ) ? $existing->data : array();
			$from_email    = isset( $existing_data['identity']['from_email'] )
				? (string) $existing_data['identity']['from_email']
				: '';
		}

		// Route 1: a receive-capable (Gmail/Outlook OAuth) From address.
		if ( '' !== $from_email && $this->from_email_is_receivable( $from_email ) ) {
			return true;
		}

		// Route 2: a complete custom-IMAP block. Merge the existing row's data with
		// the payload so a partial update is judged on the EFFECTIVE block.
		if ( $this->effective_data_has_custom_imap( $params, $existing ) ) {
			return true;
		}

		return new WP_Error(
			'connection_not_receivable',
			__( 'Email channels receive over IMAP. Use a Gmail or Outlook–connected From address, enter custom IMAP details below, or use a Web channel instead.', 'doublescale' ),
			array( 'status' => 400 )
		);
	}

	/**
	 * Whether the EFFECTIVE mailbox data (existing row overlaid with the request
	 * body) carries a usable custom-IMAP block. Lets a partial PUT that only
	 * touches one IMAP sub-field still validate against the rest of the stored
	 * block. Delegates the presence rule to the single source of truth in
	 * {@see \DoubleScale\Modules\Smtp\Settings::mailbox_has_custom_imap()}.
	 *
	 * @param array             $params   Request body.
	 * @param MailboxModel|null $existing Row being updated (null on create).
	 * @return bool
	 */
	private function effective_data_has_custom_imap( array $params, ?MailboxModel $existing ): bool {
		if ( ! class_exists( '\DoubleScale\Modules\Smtp\Settings' ) ) {
			return false;
		}

		$existing_data = ( $existing && is_array( $existing->data ) ) ? $existing->data : array();
		$payload_data  = isset( $params['data'] ) && is_array( $params['data'] ) ? $params['data'] : array();

		$existing_imap = isset( $existing_data['imap'] ) && is_array( $existing_data['imap'] ) ? $existing_data['imap'] : array();
		$payload_imap  = isset( $payload_data['imap'] ) && is_array( $payload_data['imap'] ) ? $payload_data['imap'] : array();

		// A masked password in the payload means "keep the stored one" — substitute
		// a non-empty marker so the presence check passes (the real value is resolved
		// at save time in sanitize_imap()).
		if ( isset( $payload_imap['password'] ) && '********' === (string) $payload_imap['password'] ) {
			$payload_imap['password'] = isset( $existing_imap['password'] ) ? $existing_imap['password'] : '';
		}

		$effective = array( 'imap' => array_merge( $existing_imap, $payload_imap ) );

		return \DoubleScale\Modules\Smtp\Settings::mailbox_has_custom_imap( $effective );
	}

	/**
	 * Whether a From address can receive over IMAP (cheap, no network I/O).
	 * Single source of truth shared with the editor's email-box gate.
	 *
	 * @param string $from_email Mailbox sending-identity address.
	 * @return bool
	 */
	private function from_email_is_receivable( string $from_email ): bool {
		if ( ! class_exists( '\DoubleScale\Modules\Smtp\Settings' ) ) {
			return false;
		}
		return \DoubleScale\Modules\Smtp\Settings::is_from_email_receivable( $from_email );
	}

	/**
	 * Validate the submitted sending identity — the mailbox's From address
	 * (`data.identity.from_email`).
	 *
	 * Identity is REQUIRED on create (it is the only From source) and may not be
	 * cleared on update: a PUT that doesn't touch identity is fine, but one that
	 * submits an empty/invalid `from_email` is rejected. Any syntactically valid
	 * email is accepted (the combobox suggests configured SMTP senders, but a
	 * free-typed address is allowed); whether it can RECEIVE is a separate,
	 * box_type='email'-only check ({@see validate_receivability()}).
	 *
	 * @param array $params    Request body.
	 * @param bool  $is_create True for POST, false for PUT.
	 * @return true|WP_Error
	 */
	private function validate_identity( array $params, bool $is_create ) {
		$has_data = isset( $params['data'] ) && is_array( $params['data'] );
		$identity = $has_data ? ( $params['data']['identity'] ?? null ) : null;

		// On update, a body that doesn't mention identity at all leaves the
		// existing one untouched — allowed. (A `data` blob WITHOUT an identity
		// key is treated the same way, so partial PUTs that only change e.g. the
		// name don't have to re-send the identity.)
		$identity_present = is_array( $identity ) && array_key_exists( 'from_email', $identity );
		if ( ! $identity_present ) {
			if ( $is_create ) {
				return new WP_Error(
					'identity_required',
					__( 'A sending identity is required. Choose a From address in the Sending identity field.', 'doublescale' ),
					array( 'status' => 400 )
				);
			}
			return true; // Update with no identity change — leave it as-is.
		}

		$from_email = sanitize_email( (string) $identity['from_email'] );
		if ( '' === $from_email || ! is_email( $from_email ) ) {
			// Identity submitted but empty/invalid — never allowed: a mailbox must
			// keep a valid From address (create) and may not have it cleared (update).
			return new WP_Error(
				'identity_required',
				__( 'A valid From address is required for the sending identity.', 'doublescale' ),
				array( 'status' => 400 )
			);
		}

		return true;
	}

	/**
	 * Filter a request body down to writable columns. Anything else is dropped
	 * — defends against mass-assignment of timestamps / id / etc.
	 *
	 * @param array             $params   Raw request body.
	 * @param MailboxModel|null $existing Row being updated (null on create), so the
	 *                                    `data.imap` sanitiser can preserve a masked password.
	 * @return array
	 */
	private function extract_writable( array $params, ?MailboxModel $existing ): array {
		$out = array();
		foreach ( self::WRITABLE_FIELDS as $field ) {
			if ( array_key_exists( $field, $params ) ) {
				$out[ $field ] = $params[ $field ];
			}
		}

		if ( isset( $out['is_default'] ) ) {
			$out['is_default'] = (bool) $out['is_default'] ? 1 : 0;
		}
		if ( isset( $out['slug'] ) ) {
			$out['slug'] = sanitize_title( (string) $out['slug'] );
		}
		// `data` is a JSON blob — sanitise its known keys (name, identity,
		// per-event notification templates, custom IMAP). Unknown/Pro keys pass
		// through untouched. `email` is intentionally absent: the model mirrors it
		// from `identity.from_email`, it is never client-supplied.
		if ( isset( $out['data'] ) && is_array( $out['data'] ) ) {
			$out['data'] = $this->sanitize_data( $out['data'], $existing );
		}

		return $out;
	}

	/**
	 * Sanitise the `data` JSON blob. Known keys are cleaned to their expected
	 * types; any other top-level key (e.g. Pro-added channel detail) passes
	 * through untouched, so this never strips forward/Pro data:
	 *
	 *   - name                  → text
	 *   - identity.from_email   → email (the only identity sub-key kept; From
	 *                             Name / Reply-To are derived at send, not stored)
	 *   - notifications.<event> → { enabled:bool, subject:text, body:wp_kses_post }
	 *   - imap                  → { host, port, encryption, username, password }
	 *                             password ENCRYPTED at rest (see sanitize_imap())
	 *
	 * @param array             $data     Decoded `data` from the request.
	 * @param MailboxModel|null $existing Row being updated (null on create).
	 * @return array
	 */
	private function sanitize_data( array $data, ?MailboxModel $existing = null ): array {
		$out = $data;

		if ( isset( $out['name'] ) ) {
			$out['name'] = sanitize_text_field( (string) $out['name'] );
		}

		if ( isset( $out['identity'] ) && is_array( $out['identity'] ) ) {
			$out['identity'] = array(
				'from_email' => isset( $out['identity']['from_email'] )
					? sanitize_email( (string) $out['identity']['from_email'] )
					: '',
			);
		}

		if ( isset( $out['notifications'] ) && is_array( $out['notifications'] ) ) {
			$out['notifications'] = $this->sanitize_notifications( $out['notifications'] );
		}

		// Custom-IMAP credentials. Explicitly sanitised (not left to the
		// pass-through rule) because the blob carries a password: it must be
		// type-cleaned, encrypted at rest, and never echoed back (see
		// shape_mailbox()). An empty/blank block is dropped so a web box doesn't
		// keep dangling IMAP keys.
		if ( array_key_exists( 'imap', $out ) ) {
			$clean_imap = $this->sanitize_imap( is_array( $out['imap'] ) ? $out['imap'] : array(), $existing );
			if ( array() === $clean_imap ) {
				unset( $out['imap'] );
			} else {
				$out['imap'] = $clean_imap;
			}
		}

		return $out;
	}

	/**
	 * Sanitise the custom-IMAP block and ENCRYPT the password at rest.
	 *
	 *   - host       → text
	 *   - port       → int (default 993)
	 *   - encryption → whitelist ssl|tls|none (default ssl)
	 *   - username   → text
	 *   - password   → encrypted via {@see \DoubleScale\Core\Settings\Settings::encrypt_value()}.
	 *                  The sentinel '********' means "keep the stored value": we copy
	 *                  the existing row's already-encrypted password unchanged. A
	 *                  blank password drops to '' (the box then fails the receivability
	 *                  gate, which is the intended signal that it isn't pollable).
	 *
	 * Returns array() for an entirely empty block so the caller can drop the key.
	 *
	 * @param array             $imap     Raw IMAP block from `data`.
	 * @param MailboxModel|null $existing Row being updated (for masked-password preservation).
	 * @return array{host:string,port:int,encryption:string,username:string,password:string}|array{}
	 */
	private function sanitize_imap( array $imap, ?MailboxModel $existing ): array {
		$host       = sanitize_text_field( (string) ( $imap['host'] ?? '' ) );
		$username   = sanitize_text_field( (string) ( $imap['username'] ?? '' ) );
		$encryption = strtolower( trim( (string) ( $imap['encryption'] ?? 'ssl' ) ) );
		if ( ! in_array( $encryption, array( 'ssl', 'tls', 'none' ), true ) ) {
			$encryption = 'ssl';
		}
		$port = (int) ( $imap['port'] ?? 0 );
		if ( $port <= 0 ) {
			$port = 993;
		}

		// Resolve the password. '********' (or absent) preserves the stored
		// (already-encrypted) value; anything else is new plaintext we encrypt now.
		$existing_encrypted = '';
		if ( $existing && is_array( $existing->data ) && isset( $existing->data['imap']['password'] ) ) {
			$existing_encrypted = (string) $existing->data['imap']['password'];
		}

		$submitted = array_key_exists( 'password', $imap ) ? (string) $imap['password'] : '********';
		if ( '********' === $submitted || '' === $submitted ) {
			$password = '' === $submitted ? '' : $existing_encrypted;
		} elseif ( class_exists( '\DoubleScale\Core\Settings\Settings' ) ) {
			$password = \DoubleScale\Core\Settings\Settings::encrypt_value( $submitted );
		} else {
			$password = $submitted;
		}

		// Entirely empty block (no host/username/password) → drop it.
		if ( '' === $host && '' === $username && '' === $password ) {
			return array();
		}

		return array(
			'host'       => $host,
			'port'       => $port,
			'encryption' => $encryption,
			'username'   => $username,
			'password'   => $password,
		);
	}

	/**
	 * Sanitise the per-mailbox notification template map:
	 * `<event> => { enabled:bool, subject:text, body:wp_kses_post }`.
	 *
	 * @param array $notifications Raw notifications map from `data`.
	 * @return array<string, array{enabled:bool, subject:string, body:string}>
	 */
	private function sanitize_notifications( array $notifications ): array {
		$clean = array();
		foreach ( $notifications as $event => $template ) {
			$event = sanitize_key( (string) $event );
			if ( '' === $event || ! is_array( $template ) ) {
				continue;
			}
			$clean[ $event ] = array(
				'enabled' => ! empty( $template['enabled'] ),
				'subject' => isset( $template['subject'] ) ? sanitize_text_field( (string) $template['subject'] ) : '',
				'body'    => isset( $template['body'] ) ? wp_kses_post( (string) $template['body'] ) : '',
			);
		}
		return $clean;
	}

	/**
	 * Shape a MailboxModel into the REST response shape.
	 *
	 * `data` returns as a JSON object (associative array) — the model's
	 * accessor already handles decoding. An empty mailbox emits `{}`, not `[]`,
	 * by coercing to stdClass when the array is empty so the JSON shape stays
	 * stable for TypeScript consumers.
	 *
	 * @param MailboxModel $mailbox Mailbox row.
	 * @return array
	 */
	private function shape_mailbox( MailboxModel $mailbox ): array {
		$data = $mailbox->data;
		if ( ! is_array( $data ) ) {
			$data = array();
		}

		// Never expose the IMAP password (stored encrypted). Replace any stored
		// value with the '********' sentinel the editor recognises as "unchanged";
		// emit '' when no password is set so the field renders empty. Operate on the
		// copy ($data), not the model, so nothing mutates in memory.
		if ( isset( $data['imap'] ) && is_array( $data['imap'] ) ) {
			$data['imap']['password'] = '' !== (string) ( $data['imap']['password'] ?? '' ) ? '********' : '';
		}

		return array(
			'id'           => (int) $mailbox->id,
			'slug'         => (string) $mailbox->slug,
			'email'        => (string) $mailbox->email,
			'box_type'     => (string) $mailbox->box_type,
			'is_default'   => (bool) $mailbox->is_default,
			'name'         => (string) $mailbox->name,
			// Present when the list query used withCount('tickets'); single-row
			// fetches (get/create/update) fall back to a direct count so the field
			// is always accurate.
			'ticket_count' => (int) ( $mailbox->tickets_count ?? $mailbox->tickets()->count() ),
			'data'         => empty( $data ) ? new \stdClass() : $data,
			'created_at'   => $mailbox->created_at ? (string) $mailbox->created_at : null,
			'updated_at'   => $mailbox->updated_at ? (string) $mailbox->updated_at : null,
		);
	}

	/**
	 * Baseline access guard. Mailboxes are managed from the Support Settings
	 * page, so this mirrors that page's gate: Administrators, CRM Managers, and
	 * Support Managers. Support Agents and Sales Manager / Sales
	 * Rep are excluded — mailbox configuration is not a sales concern.
	 *
	 * @return bool|WP_Error
	 */
	private function require_support_settings_access() {
		if ( Permissions::can_access_support_settings() ) {
			return true;
		}
		return new WP_Error( 'not_allowed', __( 'You do not have permission to access support mailboxes.', 'doublescale' ), array( 'status' => 403 ) );
	}
}
