<?php
/**
 * Answyr Desk — REST routes.
 *
 * Public:  POST /answyr/v1/desk/tickets   (web-form intake → create ticket)
 * Admin:   GET  /answyr/v1/desk/tickets            (list)
 *          GET  /answyr/v1/desk/tickets/{id}       (thread)
 *          POST /answyr/v1/desk/tickets/{id}/suggest (AI draft — never sends)
 *          POST /answyr/v1/desk/tickets/{id}/reply   (human-vetted send)
 *
 * @package Answyr
 */

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

final class Answyr_Desk_Rest {

	private const NS = 'answyr/v1';

	public function register(): void {
		add_action( 'rest_api_init', array( $this, 'routes' ) );
	}

	public function routes(): void {
		// Public intake (web form).
		register_rest_route(
			self::NS,
			'/desk/tickets',
			array(
				'methods'             => 'POST',
				'callback'            => array( $this, 'create_ticket' ),
				'permission_callback' => '__return_true',
			)
		);

		// Admin: list.
		register_rest_route(
			self::NS,
			'/desk/tickets',
			array(
				'methods'             => 'GET',
				'callback'            => array( $this, 'list_tickets' ),
				'permission_callback' => array( $this, 'can_manage' ),
			)
		);

		register_rest_route(
			self::NS,
			'/desk/tickets/(?P<id>\d+)/suggest',
			array(
				'methods'             => 'POST',
				'callback'            => array( $this, 'suggest' ),
				'permission_callback' => array( $this, 'can_manage' ),
			)
		);

		register_rest_route(
			self::NS,
			'/desk/tickets/(?P<id>\d+)/reply',
			array(
				'methods'             => 'POST',
				'callback'            => array( $this, 'reply' ),
				'permission_callback' => array( $this, 'can_manage' ),
			)
		);

		register_rest_route(
			self::NS,
			'/desk/tickets/(?P<id>\d+)/status',
			array(
				'methods'             => 'POST',
				'callback'            => array( $this, 'set_status' ),
				'permission_callback' => array( $this, 'can_manage' ),
			)
		);

		register_rest_route(
			self::NS,
			'/desk/tickets/(?P<id>\d+)/priority',
			array(
				'methods'             => 'POST',
				'callback'            => array( $this, 'set_priority' ),
				'permission_callback' => array( $this, 'can_manage' ),
			)
		);

		register_rest_route(
			self::NS,
			'/desk/tickets/(?P<id>\d+)/tags',
			array(
				'methods'             => 'POST',
				'callback'            => array( $this, 'set_tags' ),
				'permission_callback' => array( $this, 'can_manage' ),
			)
		);

		register_rest_route(
			self::NS,
			'/desk/tickets/(?P<id>\d+)/note',
			array(
				'methods'             => 'POST',
				'callback'            => array( $this, 'add_note' ),
				'permission_callback' => array( $this, 'can_manage' ),
			)
		);

		// ── Customer portal (logged-in EDD customers see their own tickets) ──
		register_rest_route(
			self::NS,
			'/desk/portal/tickets',
			array(
				array(
					'methods'             => 'GET',
					'callback'            => array( $this, 'portal_list' ),
					'permission_callback' => 'is_user_logged_in',
				),
				array(
					'methods'             => 'POST',
					'callback'            => array( $this, 'portal_create' ),
					'permission_callback' => 'is_user_logged_in',
				),
			)
		);
		register_rest_route(
			self::NS,
			'/desk/portal/tickets/(?P<id>\d+)',
			array(
				'methods'             => 'GET',
				'callback'            => array( $this, 'portal_thread' ),
				'permission_callback' => 'is_user_logged_in',
			)
		);
		register_rest_route(
			self::NS,
			'/desk/portal/tickets/(?P<id>\d+)/reply',
			array(
				'methods'             => 'POST',
				'callback'            => array( $this, 'portal_reply' ),
				'permission_callback' => 'is_user_logged_in',
			)
		);
		register_rest_route(
			self::NS,
			'/desk/portal/tickets/(?P<id>\d+)/status',
			array(
				'methods'             => 'POST',
				'callback'            => array( $this, 'portal_set_status' ),
				'permission_callback' => 'is_user_logged_in',
			)
		);
		register_rest_route(
			self::NS,
			'/desk/portal/tickets/(?P<id>\d+)/rate',
			array(
				'methods'             => 'POST',
				'callback'            => array( $this, 'portal_rate' ),
				'permission_callback' => 'is_user_logged_in',
			)
		);

		// ── AI power-ups (Pro) — routes exist only when the premium class is present ──
		if ( class_exists( 'Answyr_Desk_AI' ) ) {
			foreach ( array( 'summarize', 'triage', 'kb-draft', 'polish' ) as $ss_ai_route ) {
				register_rest_route(
					self::NS,
					'/desk/tickets/(?P<id>\d+)/' . $ss_ai_route,
					array(
						'methods'             => 'POST',
						'callback'            => array( $this, 'ai_' . str_replace( '-', '_', $ss_ai_route ) ),
						'permission_callback' => array( $this, 'can_manage' ),
					)
				);
			}
		}

		// ── Attachments (Pro) — routes exist only when the premium store is present ──
		if ( class_exists( 'Answyr_Attachments' ) ) {
			register_rest_route(
				self::NS,
				'/desk/tickets/(?P<id>\d+)/attachments',
				array(
					'methods'             => 'POST',
					'callback'            => array( $this, 'upload_attachment' ),
					'permission_callback' => array( $this, 'can_manage' ),
				)
			);
			register_rest_route(
				self::NS,
				'/desk/portal/tickets/(?P<id>\d+)/attachments',
				array(
					'methods'             => 'POST',
					'callback'            => array( $this, 'portal_upload_attachment' ),
					'permission_callback' => 'is_user_logged_in',
				)
			);
		}

		// Logged-out sign-in (AJAX) so failures stay on the portal page.
		register_rest_route(
			self::NS,
			'/desk/portal/login',
			array(
				'methods'             => 'POST',
				'callback'            => array( $this, 'portal_login' ),
				'permission_callback' => '__return_true',
			)
		);
	}

	public function can_manage(): bool {
		return current_user_can( 'manage_options' );
	}

	/** Human-friendly "2 hours ago" for a MySQL datetime (falls back to raw). */
	private function human_date( ?string $mysql ): string {
		$ts = $mysql ? strtotime( $mysql ) : false;
		if ( ! $ts ) {
			return (string) $mysql;
		}
		/* translators: %s: human time difference, e.g. "2 hours". */
		return sprintf( __( '%s ago', 'answyr' ), human_time_diff( $ts ) );
	}

	/** Lowercased email of the current user, for ticket ownership checks. */
	private function current_email(): string {
		$user = wp_get_current_user();
		return $user ? strtolower( (string) $user->user_email ) : '';
	}

	private function owns( ?object $ticket ): bool {
		return $ticket && '' !== $this->current_email()
			&& strtolower( (string) $ticket->customer_email ) === $this->current_email();
	}

	public function create_ticket( WP_REST_Request $request ) {
		// Soft CSRF guard: the public form sends a wp_rest nonce. A present nonce
		// must be valid (blocks forged cross-site posts); a missing one is tolerated
		// so cache-served pages still work — the rate limiter below is the hard cap.
		$nonce = (string) $request->get_header( 'X-WP-Nonce' );
		if ( '' !== $nonce && false === wp_verify_nonce( $nonce, 'wp_rest' ) ) {
			return new WP_REST_Response( array( 'error' => __( 'Security check failed. Please refresh and try again.', 'answyr' ) ), 403 );
		}

		$key = Answyr_Rate_Limiter::client_key( 'desk_intake' );
		if ( ! Answyr_Rate_Limiter::allow( $key, 10, HOUR_IN_SECONDS ) ) {
			return new WP_REST_Response( array( 'error' => __( 'Too many submissions. Try again later.', 'answyr' ) ), 429 );
		}

		$email   = sanitize_email( (string) $request->get_param( 'email' ) );
		$message = trim( wp_strip_all_tags( (string) $request->get_param( 'message' ) ) );

		if ( ! is_email( $email ) || '' === $message ) {
			return new WP_REST_Response( array( 'error' => __( 'A valid email and message are required.', 'answyr' ) ), 400 );
		}

		$context = Answyr_Intake_Fields::collect( (array) ( $request->get_param( 'fields' ) ?? array() ) );
		if ( is_wp_error( $context ) ) {
			return new WP_REST_Response( array( 'error' => $context->get_error_message() ), 400 );
		}

		$ticket_id = Answyr_Desk_Store::create_ticket(
			array(
				'subject'        => wp_trim_words( $message, 10, '…' ),
				'customer_email' => $email,
				'customer_name'  => sanitize_text_field( (string) $request->get_param( 'name' ) ),
				'channel'        => 'form',
				'context'        => $context,
			)
		);
		Answyr_Desk_Store::add_message( $ticket_id, 'customer', $message );

		do_action( 'answyr_desk_ticket_created', $ticket_id );

		return new WP_REST_Response(
			array(
				'ok'        => true,
				'ticket_id' => $ticket_id,
			),
			201
		);
	}

	public function list_tickets( WP_REST_Request $request ) {
		$status = sanitize_key( (string) ( $request->get_param( 'status' ) ?: 'open' ) );
		return new WP_REST_Response( Answyr_Desk_Store::list_tickets( $status ), 200 );
	}

	public function suggest( WP_REST_Request $request ) {
		$ticket_id = (int) $request->get_param( 'id' );
		try {
			$service = new Answyr_Desk_Suggestion_Service();
			$result  = $service->suggest( $ticket_id );

			// Persist the draft so the agent can edit it; is_draft = never auto-sent.
			Answyr_Desk_Store::add_message(
				$ticket_id,
				'ai',
				$result['draft'],
				true,
				array( 'sources' => $result['sources'] )
			);

			return new WP_REST_Response( $result, 200 );
		} catch ( \Throwable $e ) {
			return new WP_REST_Response( array( 'error' => $e->getMessage() ), 500 );
		}
	}

	/** Human-vetted send. The body is whatever the agent approved/edited. */
	public function reply( WP_REST_Request $request ) {
		$ticket_id = (int) $request->get_param( 'id' );
		$body      = trim( (string) $request->get_param( 'body' ) );
		if ( '' === $body ) {
			return new WP_REST_Response( array( 'error' => __( 'Reply body is empty.', 'answyr' ) ), 400 );
		}

		$ticket = Answyr_Desk_Store::get_ticket( $ticket_id );
		if ( ! $ticket ) {
			return new WP_REST_Response( array( 'error' => __( 'Ticket not found.', 'answyr' ) ), 404 );
		}

		Answyr_Desk_Store::add_message( $ticket_id, 'agent', $body, false, array( 'sent_by' => get_current_user_id() ) );

		// First agent reply stamps the response time (for insights); then it's pending.
		Answyr_Desk_Store::mark_first_response( $ticket_id );
		Answyr_Desk_Store::set_status( $ticket_id, 'pending' );

		// Notify the customer of the reply (body + a link back to the portal). The
		// reply can carry rich formatting from the editor, so send it as HTML.
		$subject = sprintf(
			/* translators: 1: ticket subject, 2: ticket id */
			__( 'Re: %1$s (#%2$d)', 'answyr' ),
			$ticket->subject,
			$ticket_id
		);
		$footer_html = class_exists( 'Answyr_Desk_Module' ) ? Answyr_Desk_Module::email_footer_html() : '';
		$html        = wpautop( wp_kses_post( $body ) ) . $footer_html;
		$as_html     = static function () {
			return 'text/html';
		};
		add_filter( 'wp_mail_content_type', $as_html );
		$sent = wp_mail( $ticket->customer_email, $subject, $html );
		remove_filter( 'wp_mail_content_type', $as_html );

		do_action( 'answyr_desk_reply_sent', $ticket_id, $sent );

		return new WP_REST_Response(
			array(
				'ok'        => true,
				'delivered' => (bool) $sent,
			),
			200
		);
	}

	/** Agent sets a ticket's status (open/pending/resolved/closed). */
	public function set_status( WP_REST_Request $request ) {
		$ticket_id = (int) $request->get_param( 'id' );
		$status    = sanitize_key( (string) $request->get_param( 'status' ) );

		if ( ! Answyr_Desk_Store::is_status( $status ) ) {
			return new WP_REST_Response( array( 'error' => __( 'Unknown status.', 'answyr' ) ), 400 );
		}
		if ( ! Answyr_Desk_Store::get_ticket( $ticket_id ) ) {
			return new WP_REST_Response( array( 'error' => __( 'Ticket not found.', 'answyr' ) ), 404 );
		}

		Answyr_Desk_Store::set_status( $ticket_id, $status );
		return new WP_REST_Response(
			array(
				'ok'     => true,
				'status' => $status,
			),
			200
		);
	}

	/** Agent sets ticket priority (low/normal/high/urgent). */
	public function set_priority( WP_REST_Request $request ) {
		$ticket_id = (int) $request->get_param( 'id' );
		$priority  = sanitize_key( (string) $request->get_param( 'priority' ) );

		if ( ! Answyr_Desk_Store::is_priority( $priority ) ) {
			return new WP_REST_Response( array( 'error' => __( 'Unknown priority.', 'answyr' ) ), 400 );
		}
		if ( ! Answyr_Desk_Store::get_ticket( $ticket_id ) ) {
			return new WP_REST_Response( array( 'error' => __( 'Ticket not found.', 'answyr' ) ), 404 );
		}

		Answyr_Desk_Store::set_priority( $ticket_id, $priority );
		return new WP_REST_Response(
			array(
				'ok'       => true,
				'priority' => $priority,
			),
			200
		);
	}

	/** Agent sets the ticket's tags (free-form, comma-separated → slugs). */
	public function set_tags( WP_REST_Request $request ) {
		$ticket_id = (int) $request->get_param( 'id' );
		if ( ! Answyr_Desk_Store::get_ticket( $ticket_id ) ) {
			return new WP_REST_Response( array( 'error' => __( 'Ticket not found.', 'answyr' ) ), 404 );
		}

		$tags = Answyr_Desk_Store::parse_tags( (string) $request->get_param( 'tags' ) );
		Answyr_Desk_Store::set_tags( $ticket_id, $tags );
		return new WP_REST_Response(
			array(
				'ok'   => true,
				'tags' => $tags,
			),
			200
		);
	}

	/** Agent adds an internal note — stored on the thread, never emailed or shown to the customer. */
	public function add_note( WP_REST_Request $request ) {
		$ticket_id = (int) $request->get_param( 'id' );
		$body      = trim( (string) $request->get_param( 'body' ) );
		if ( '' === $body ) {
			return new WP_REST_Response( array( 'error' => __( 'Note is empty.', 'answyr' ) ), 400 );
		}
		if ( ! Answyr_Desk_Store::get_ticket( $ticket_id ) ) {
			return new WP_REST_Response( array( 'error' => __( 'Ticket not found.', 'answyr' ) ), 404 );
		}

		$id = Answyr_Desk_Store::add_message( $ticket_id, 'note', $body, false, array( 'author_id' => get_current_user_id() ) );
		return new WP_REST_Response(
			array(
				'ok'         => true,
				'message_id' => $id,
			),
			201
		);
	}

	// ── Customer portal ──────────────────────────────────────────────────────────

	/** The current user's own tickets (slim list). */
	public function portal_list() {
		$tickets = Answyr_Desk_Store::list_by_email( $this->current_email() );
		$out     = array_map(
			function ( $t ) {
				return array(
					'id'         => (int) $t->id,
					'subject'    => $t->subject,
					'status'     => $t->status,
					'updated_at' => $this->human_date( $t->updated_at ),
				);
			},
			$tickets
		);
		return new WP_REST_Response( $out, 200 );
	}

	/** Create a new ticket for the logged-in customer (portal "New request"). */
	public function portal_create( WP_REST_Request $request ) {
		$message = trim( wp_strip_all_tags( (string) $request->get_param( 'message' ) ) );
		if ( '' === $message ) {
			return new WP_REST_Response( array( 'error' => __( 'Please describe your request.', 'answyr' ) ), 400 );
		}

		$subject = trim( wp_strip_all_tags( (string) $request->get_param( 'subject' ) ) );
		if ( '' === $subject ) {
			$subject = wp_trim_words( $message, 10, '…' );
		}

		$context = Answyr_Intake_Fields::collect( (array) ( $request->get_param( 'fields' ) ?? array() ) );
		if ( is_wp_error( $context ) ) {
			return new WP_REST_Response( array( 'error' => $context->get_error_message() ), 400 );
		}

		$user      = wp_get_current_user();
		$ticket_id = Answyr_Desk_Store::create_ticket(
			array(
				'subject'        => $subject,
				'customer_email' => $user->user_email,
				'customer_name'  => $user->display_name,
				'channel'        => 'portal',
				'context'        => $context,
			)
		);
		Answyr_Desk_Store::add_message( $ticket_id, 'customer', $message );
		do_action( 'answyr_desk_ticket_created', $ticket_id );

		return new WP_REST_Response(
			array(
				'ok'        => true,
				'ticket_id' => $ticket_id,
			),
			201
		);
	}

	/** One ticket's customer-visible thread (AI drafts hidden). */
	public function portal_thread( WP_REST_Request $request ) {
		$ticket = Answyr_Desk_Store::get_ticket( (int) $request->get_param( 'id' ) );
		if ( ! $this->owns( $ticket ) ) {
			return new WP_REST_Response( array( 'error' => __( 'Not found.', 'answyr' ) ), 404 );
		}

		$messages = array();
		foreach ( Answyr_Desk_Store::thread( (int) $ticket->id ) as $m ) {
			// Only customer + agent messages are customer-visible. Drafts, AI
			// suggestions, and internal notes are never exposed.
			if ( ! in_array( $m->author_type, array( 'customer', 'agent' ), true ) || 1 === (int) $m->is_draft ) {
				continue;
			}
			$is_agent   = 'agent' === $m->author_type;
			$messages[] = array(
				'author'     => $is_agent ? __( 'Support', 'answyr' ) : __( 'You', 'answyr' ),
				// Agent replies can carry editor formatting; send them as already-
				// sanitized HTML (wp_kses_post on store) so the portal renders it
				// directly. Customer messages stay plain and are rendered client-side.
				'body'       => $is_agent ? wpautop( wp_kses_post( $m->body ) ) : $m->body,
				'html'       => $is_agent,
				'created_at' => $this->human_date( $m->created_at ),
			);
		}

		// Pro: ticket attachments (agents + this customer), with signed download URLs.
		$attachments = array();
		if ( class_exists( 'Answyr_Attachments' ) ) {
			foreach ( Answyr_Attachments::for_ticket( (int) $ticket->id ) as $a ) {
				$attachments[] = array(
					'name' => $a->orig_name,
					'size' => Answyr_Attachments::human_size( (int) $a->size ),
					'url'  => Answyr_Attachments::download_url( $a ),
				);
			}
		}

		return new WP_REST_Response(
			array(
				'id'          => (int) $ticket->id,
				'subject'     => $ticket->subject,
				'status'      => $ticket->status,
				'rating'      => isset( $ticket->rating ) && null !== $ticket->rating ? (int) $ticket->rating : null,
				'messages'    => $messages,
				'attachments' => $attachments,
			),
			200
		);
	}

	/** Customer adds a reply to their own ticket (re-opens it). */
	public function portal_reply( WP_REST_Request $request ) {
		$ticket = Answyr_Desk_Store::get_ticket( (int) $request->get_param( 'id' ) );
		if ( ! $this->owns( $ticket ) ) {
			return new WP_REST_Response( array( 'error' => __( 'Not found.', 'answyr' ) ), 404 );
		}

		$body = trim( wp_strip_all_tags( (string) $request->get_param( 'body' ) ) );
		if ( '' === $body ) {
			return new WP_REST_Response( array( 'error' => __( 'Message is empty.', 'answyr' ) ), 400 );
		}

		Answyr_Desk_Store::add_message( (int) $ticket->id, 'customer', $body );
		Answyr_Desk_Store::set_status( (int) $ticket->id, 'open' );
		do_action( 'answyr_desk_customer_replied', (int) $ticket->id );

		return new WP_REST_Response( array( 'ok' => true ), 201 );
	}

	/** Customer resolves/closes — or reopens — their own ticket. */
	public function portal_set_status( WP_REST_Request $request ) {
		$ticket = Answyr_Desk_Store::get_ticket( (int) $request->get_param( 'id' ) );
		if ( ! $this->owns( $ticket ) ) {
			return new WP_REST_Response( array( 'error' => __( 'Not found.', 'answyr' ) ), 404 );
		}

		$status = sanitize_key( (string) $request->get_param( 'status' ) );
		// Customers may resolve/close their own request, or reopen it.
		if ( ! in_array( $status, array( 'open', 'resolved', 'closed' ), true ) ) {
			return new WP_REST_Response( array( 'error' => __( 'Not allowed.', 'answyr' ) ), 400 );
		}

		Answyr_Desk_Store::set_status( (int) $ticket->id, $status );
		return new WP_REST_Response(
			array(
				'ok'     => true,
				'status' => $status,
			),
			200
		);
	}

	/** Customer rates their support experience (1–5; thumbs map to 1 and 5). */
	public function portal_rate( WP_REST_Request $request ) {
		$ticket = Answyr_Desk_Store::get_ticket( (int) $request->get_param( 'id' ) );
		if ( ! $this->owns( $ticket ) ) {
			return new WP_REST_Response( array( 'error' => __( 'Not found.', 'answyr' ) ), 404 );
		}

		$rating = (int) $request->get_param( 'rating' );
		if ( $rating < 1 || $rating > 5 ) {
			return new WP_REST_Response( array( 'error' => __( 'Invalid rating.', 'answyr' ) ), 400 );
		}

		Answyr_Desk_Store::set_rating( (int) $ticket->id, $rating, (string) $request->get_param( 'comment' ) );
		return new WP_REST_Response(
			array(
				'ok'     => true,
				'rating' => $rating,
			),
			200
		);
	}

	// ── AI power-ups (Pro) ────────────────────────────────────────────────────────

	/** AI: summarize the ticket thread for the agent. */
	public function ai_summarize( WP_REST_Request $request ) {
		try {
			$summary = ( new Answyr_Desk_AI() )->summarize( (int) $request->get_param( 'id' ) );
			return new WP_REST_Response( array( 'summary' => $summary ), 200 );
		} catch ( \Throwable $e ) {
			return new WP_REST_Response( array( 'error' => $e->getMessage() ), 500 );
		}
	}

	/** AI: suggest tags + priority (the agent applies them via /tags and /priority). */
	public function ai_triage( WP_REST_Request $request ) {
		try {
			return new WP_REST_Response( ( new Answyr_Desk_AI() )->triage( (int) $request->get_param( 'id' ) ), 200 );
		} catch ( \Throwable $e ) {
			return new WP_REST_Response( array( 'error' => $e->getMessage() ), 500 );
		}
	}

	/** AI: polish the agent's rough draft reply (returns the cleaned-up text). */
	public function ai_polish( WP_REST_Request $request ) {
		try {
			$polished = ( new Answyr_Desk_AI() )->polish( (string) $request->get_param( 'body' ) );
			return new WP_REST_Response( array( 'polished' => $polished ), 200 );
		} catch ( \Throwable $e ) {
			return new WP_REST_Response( array( 'error' => $e->getMessage() ), 500 );
		}
	}

	/** AI: draft a KB article from the ticket → a draft answyr_kb post. */
	public function ai_kb_draft( WP_REST_Request $request ) {
		try {
			$post_id = ( new Answyr_Desk_AI() )->draft_kb( (int) $request->get_param( 'id' ) );
			if ( is_wp_error( $post_id ) ) {
				return new WP_REST_Response( array( 'error' => $post_id->get_error_message() ), 400 );
			}
			return new WP_REST_Response(
				array(
					'ok'       => true,
					'post_id'  => (int) $post_id,
					'edit_url' => get_edit_post_link( (int) $post_id, 'raw' ),
				),
				201
			);
		} catch ( \Throwable $e ) {
			return new WP_REST_Response( array( 'error' => $e->getMessage() ), 500 );
		}
	}

	// ── Attachments (Pro) ─────────────────────────────────────────────────────────

	/** Agent uploads a file to a ticket. */
	public function upload_attachment( WP_REST_Request $request ) {
		$ticket_id = (int) $request->get_param( 'id' );
		if ( ! Answyr_Desk_Store::get_ticket( $ticket_id ) ) {
			return new WP_REST_Response( array( 'error' => __( 'Ticket not found.', 'answyr' ) ), 404 );
		}
		return $this->store_upload( $request, $ticket_id, 'agent' );
	}

	/** Customer uploads a file to their own ticket. */
	public function portal_upload_attachment( WP_REST_Request $request ) {
		$ticket = Answyr_Desk_Store::get_ticket( (int) $request->get_param( 'id' ) );
		if ( ! $this->owns( $ticket ) ) {
			return new WP_REST_Response( array( 'error' => __( 'Not found.', 'answyr' ) ), 404 );
		}
		$key = Answyr_Rate_Limiter::client_key( 'portal_upload' );
		if ( ! Answyr_Rate_Limiter::allow( $key, 30, HOUR_IN_SECONDS ) ) {
			return new WP_REST_Response( array( 'error' => __( 'Too many uploads. Try again later.', 'answyr' ) ), 429 );
		}
		return $this->store_upload( $request, (int) $ticket->id, 'customer' );
	}

	/** Shared: validate + store one uploaded file, return its display data. */
	private function store_upload( WP_REST_Request $request, int $ticket_id, string $author_type ) {
		$files = $request->get_file_params();
		if ( empty( $files['file'] ) ) {
			return new WP_REST_Response( array( 'error' => __( 'No file received.', 'answyr' ) ), 400 );
		}
		$id = Answyr_Attachments::save_upload( $ticket_id, $files['file'], $author_type, get_current_user_id() );
		if ( is_wp_error( $id ) ) {
			return new WP_REST_Response( array( 'error' => $id->get_error_message() ), 400 );
		}
		$att = Answyr_Attachments::get( (int) $id );
		return new WP_REST_Response(
			array(
				'ok'   => true,
				'id'   => (int) $id,
				'name' => $att->orig_name,
				'size' => Answyr_Attachments::human_size( (int) $att->size ),
				'url'  => Answyr_Attachments::download_url( $att ),
			),
			201
		);
	}

	/**
	 * Sign a visitor in from the portal login form. Returns JSON so the form can
	 * show inline errors instead of bouncing to wp-login.php. wp_signon sets the
	 * auth cookie during this request, so the JS reload lands authenticated.
	 */
	public function portal_login( WP_REST_Request $request ) {
		if ( is_user_logged_in() ) {
			return new WP_REST_Response( array( 'ok' => true ), 200 );
		}

		if ( ! wp_verify_nonce( (string) $request->get_param( 'nonce' ), 'answyr_portal_login' ) ) {
			return new WP_REST_Response( array( 'error' => __( 'Your session expired. Please refresh and try again.', 'answyr' ) ), 403 );
		}

		// Throttle brute-force attempts per client.
		$key = Answyr_Rate_Limiter::client_key( 'portal_login' );
		if ( ! Answyr_Rate_Limiter::allow( $key, 10, 15 * MINUTE_IN_SECONDS ) ) {
			return new WP_REST_Response( array( 'error' => __( 'Too many attempts. Please wait a few minutes and try again.', 'answyr' ) ), 429 );
		}

		$login = trim( (string) $request->get_param( 'log' ) );
		$pwd   = (string) $request->get_param( 'pwd' );
		if ( '' === $login || '' === $pwd ) {
			return new WP_REST_Response( array( 'error' => __( 'Enter your username and password.', 'answyr' ) ), 400 );
		}

		$user = wp_signon(
			array(
				'user_login'    => $login,
				'user_password' => $pwd,
				'remember'      => (bool) $request->get_param( 'remember' ),
			),
			is_ssl()
		);

		if ( is_wp_error( $user ) ) {
			// Generic message — don't reveal whether the username exists.
			return new WP_REST_Response( array( 'error' => __( 'Invalid username or password.', 'answyr' ) ), 401 );
		}

		wp_set_current_user( $user->ID );

		return new WP_REST_Response( array( 'ok' => true ), 200 );
	}
}
