<?php
/**
 * TitanCart Notification Manager
 *
 * Self-contained admin-notification model + event source. Notifications are
 * generated from TitanCart's own `tc_*` / marketplace `tcmp_*` action hooks
 * (the thin WordPress event layer the engine already fires) and persisted to
 * the `tc_notifications` table via the TitanCart ORM. No WordPress post types,
 * taxonomies, or meta — Rule 4 clean. The admin SPA bell consumes this through
 * the `/notifications` REST endpoints.
 *
 * v1 sources (S304, KI-26):
 *   - titancart_order_committed → "New order …"            (genuine paid commit,
 *                              fired once per order — NOT titancart_order_placed,
 *                              which fires before payment is known / on declined
 *                              orders: the codebase's KI-03/04 convention)
 *   - tcmp_vendor_created    → "New vendor application" (marketplace platform only;
 *                              the action simply never fires where the marketplace
 *                              extension is inactive, so the listener self-disables)
 *
 * The source set is intentionally an extensible registry: add a listener +
 * a record_* helper to surface low-stock, RMA requests, payout requests, etc.
 *
 * @package TitanCart\Modules\Notifications
 */

namespace TitanCart\Modules\Notifications;

defined( 'ABSPATH' ) || exit;

class NotificationManager {

	/** @var \TitanCart\DB\Database */
	private $db;

	public function __construct() {
		$this->db = titancart_db();
	}

	// ── Event listeners (registered by NotificationsModule::init) ──────────────

	/**
	 * New order committed (genuine paid commit). Fired by core as
	 * do_action( 'titancart_order_committed', $order_id, $order_data, $totals ),
	 * exactly once per order — chosen over titancart_order_placed (which fires
	 * before the payment outcome is known, on declined orders too) so an admin
	 * "New order" notification always represents a real order.
	 *
	 * @param int $order_id
	 */
	public static function on_order_committed( int $order_id ): void {
		if ( $order_id <= 0 ) {
			return;
		}
		try {
			$mgr   = new self();
			$order = $mgr->db->getRow(
				'SELECT order_number, total, currency, billing_first_name, billing_last_name
				 FROM ' . $mgr->db->table( 'orders' ) . ' WHERE id = ?i',
				$order_id
			);
			if ( ! $order ) {
				return;
			}

			$number = $order['order_number'] !== '' ? $order['order_number'] : ( '#' . $order_id );
			$name   = trim( ( $order['billing_first_name'] ?? '' ) . ' ' . ( $order['billing_last_name'] ?? '' ) );
			$amount = class_exists( '\TitanCart\Services\CurrencyService' )
				? \TitanCart\Services\CurrencyService::format( (float) ( $order['total'] ?? 0 ) )
				: ( ( $order['currency'] ?? '' ) . ' ' . number_format( (float) ( $order['total'] ?? 0 ), 2 ) );

			$body = $name !== ''
				? sprintf( '%s · %s', $name, $amount )
				: $amount;

			$mgr->create( [
				'type'        => 'order',
				'title'       => sprintf( 'New order %s', $number ),
				'body'        => $body,
				'link'        => 'orders/' . $order_id,
				'object_type' => 'order',
				'object_id'   => $order_id,
			] );
		} catch ( \Throwable $e ) {
			titancart_log( '[TitanCart Notifications] on_order_created failed: ' . $e->getMessage() );
		}
	}

	/**
	 * New vendor row created. Fired by the marketplace extension as
	 * do_action( 'tcmp_vendor_created', $vendor_id, $customer_id, $data ).
	 *
	 * Only the self-signup application path (status = 'pending') is an "attention"
	 * item — admin-initiated creates land at 'new'/'active' and are no-ops, mirroring
	 * VendorOnboardingMailer's own gating.
	 *
	 * @param int   $vendor_id
	 * @param int   $customer_id
	 * @param array $data        Cleaned create payload (carries 'status', 'display_name').
	 */
	public static function on_vendor_created( int $vendor_id, int $customer_id = 0, array $data = [] ): void {
		if ( $vendor_id <= 0 ) {
			return;
		}
		if ( ( $data['status'] ?? '' ) !== 'pending' ) {
			return; // admin-initiated create, not an application — no notification.
		}
		try {
			$mgr  = new self();
			$name = trim( (string) ( $data['display_name'] ?? '' ) );

			$mgr->create( [
				'type'        => 'vendor_application',
				'title'       => 'New vendor application',
				'body'        => $name !== '' ? $name . ' applied to sell' : 'A new vendor application is awaiting review',
				'link'        => 'vendors',
				'object_type' => 'vendor',
				'object_id'   => $vendor_id,
			] );
		} catch ( \Throwable $e ) {
			titancart_log( '[TitanCart Notifications] on_vendor_created failed: ' . $e->getMessage() );
		}
	}

	// ── Model ──────────────────────────────────────────────────────────────────

	/**
	 * Insert a notification. Idempotent on (type, object_type, object_id) when an
	 * object is referenced, so a double-fired hook doesn't create duplicates.
	 *
	 * @param  array $data type,title,body,link,object_type,object_id
	 * @return int New (or existing) notification id, 0 on failure.
	 */
	public function create( array $data ): int {
		$type        = (string) ( $data['type'] ?? 'general' );
		$object_type = (string) ( $data['object_type'] ?? '' );
		$object_id   = (int) ( $data['object_id'] ?? 0 );

		// Dedupe only when this notification points at a concrete record.
		if ( $object_type !== '' && $object_id > 0 ) {
			$existing = (int) $this->db->getField(
				'SELECT id FROM ' . $this->db->table( 'notifications' ) . '
				 WHERE type = ?s AND object_type = ?s AND object_id = ?i LIMIT 1',
				$type, $object_type, $object_id
			);
			if ( $existing > 0 ) {
				return $existing;
			}
		}

		// created_at is auto-stamped UTC by the ORM (KI-24 contract).
		return $this->db->insert( 'notifications', [
			'type'        => $type,
			'title'       => (string) ( $data['title'] ?? '' ),
			'body'        => (string) ( $data['body'] ?? '' ),
			'link'        => (string) ( $data['link'] ?? '' ),
			'object_type' => $object_type,
			'object_id'   => $object_id,
			'is_read'     => 0,
		] );
	}

	/**
	 * Recent notifications, newest first.
	 *
	 * @param  array $params unread_only (bool), limit (int, default 20)
	 * @return array<int, array<string,mixed>>
	 */
	public function get_list( array $params = [] ): array {
		$limit = (int) ( $params['limit'] ?? 20 );
		if ( $limit < 1 )   { $limit = 20; }
		if ( $limit > 100 ) { $limit = 100; }

		$where = '';
		if ( ! empty( $params['unread_only'] ) ) {
			$where = 'WHERE is_read = 0';
		}

		$rows = $this->db->getArray(
			'SELECT id, type, title, body, link, object_type, object_id, is_read, read_at, created_at
			 FROM ' . $this->db->table( 'notifications' ) . "
			 {$where}
			 ORDER BY created_at DESC, id DESC
			 LIMIT ?i",
			$limit
		);

		foreach ( $rows as &$r ) {
			$r['id']        = (int) $r['id'];
			$r['object_id'] = (int) $r['object_id'];
			$r['is_read']   = (int) $r['is_read'];
		}
		unset( $r );

		return $rows;
	}

	/**
	 * Count of unread notifications.
	 */
	public function get_unread_count(): int {
		return (int) $this->db->getField(
			'SELECT COUNT(*) FROM ' . $this->db->table( 'notifications' ) . ' WHERE is_read = 0'
		);
	}

	/**
	 * Convenience payload for the SPA bell's single fetch: unread count + recent list.
	 *
	 * @param  int $limit
	 * @return array{unread:int, items:array}
	 */
	public function summary( int $limit = 20 ): array {
		return [
			'unread' => $this->get_unread_count(),
			'items'  => $this->get_list( [ 'limit' => $limit ] ),
		];
	}

	/**
	 * Mark one notification read.
	 */
	public function mark_read( int $id ): bool {
		if ( $id <= 0 ) {
			return false;
		}
		$this->db->update(
			'notifications',
			[ 'is_read' => 1, 'read_at' => titancart_now_utc() ],
			[ 'id' => $id ]
		);
		return true;
	}

	/**
	 * Mark every unread notification read.
	 *
	 * @return int Rows affected.
	 */
	public function mark_all_read(): int {
		$affected = $this->db->query(
			'UPDATE ' . $this->db->table( 'notifications' ) . '
			 SET is_read = 1, read_at = ?s
			 WHERE is_read = 0',
			titancart_now_utc()
		);
		return (int) $affected;
	}
}
