<?php

namespace SoftminalB2B\Models;

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

/**
 * A buyer<->shop message thread ([[project-messaging-feature]]).
 *
 * Optionally anchored to an order, product, or quote (anchor_type + anchor_id;
 * empty anchor = a general question). Read state is two cursors —
 * buyer_last_read_id and shop_last_read_id — each holding the id of the last
 * message that side has seen, so "unread" is an id comparison, never a
 * per-message flag. The denormalised last_message_* columns keep the inbox
 * sortable and the unread test cheap without scanning the messages table.
 *
 * company_id (Pro) makes the thread readable by every member of that company;
 * Free always leaves it NULL.
 *
 * @property int         $id
 * @property string      $subject
 * @property int         $buyer_id
 * @property int|null    $company_id
 * @property string      $anchor_type       order|product|quote|''
 * @property int         $anchor_id
 * @property string      $status            open|resolved
 * @property int         $last_message_id
 * @property string      $last_author_type  buyer|shop|system
 * @property string|null $last_message_at
 * @property int         $buyer_last_read_id
 * @property int         $shop_last_read_id
 * @property string      $created_at
 * @property string      $updated_at
 */
class Conversation extends Model
{
    protected $table = 'conversations';
    protected $timestamps = true;

    protected $fillable = [
        'subject',
        'buyer_id',
        'company_id',
        'anchor_type',
        'anchor_id',
        'status',
        'last_message_id',
        'last_author_type',
        'last_message_at',
        'buyer_last_read_id',
        'shop_last_read_id',
    ];

    public const STATUS_OPEN     = 'open';
    public const STATUS_RESOLVED = 'resolved';

    /** Anchor types a conversation may attach to (empty string = general). */
    public const ANCHORS = ['order', 'product', 'quote'];

    /**
     * Append a message and roll the denormalised summary + the author's own
     * read cursor forward in one place, so the inbox counters can never drift
     * from the actual thread. Returns the created Message.
     */
    public function addMessage(int $userId, string $authorType, string $body): Message
    {
        $message = Message::create([
            'conversation_id' => (int) $this->id,
            'user_id'         => $userId,
            'author_type'     => $authorType,
            'body'            => $body,
        ]);

        $messageId = (int) $message->id;

        $this->last_message_id   = $messageId;
        $this->last_author_type  = $authorType;
        $this->last_message_at   = current_time('mysql');

        // The side that just wrote has, by definition, read up to its own
        // message — advance its cursor so it never counts its own line as unread.
        if ($authorType === Message::AUTHOR_SHOP) {
            $this->shop_last_read_id = $messageId;
        } elseif ($authorType === Message::AUTHOR_BUYER) {
            $this->buyer_last_read_id = $messageId;
        }

        $this->save();
        self::flushShopUnreadCache();

        return $message;
    }

    /**
     * Whether the given side ('buyer'|'shop') has messages it has not read —
     * the latest line is from the other party and sits beyond this side's
     * cursor. System lines count toward the buyer (they are shop-side events).
     */
    public function isUnreadFor(string $side): bool
    {
        if ($side === Message::AUTHOR_SHOP) {
            return $this->last_author_type === Message::AUTHOR_BUYER
                && (int) $this->last_message_id > (int) $this->shop_last_read_id;
        }

        return in_array($this->last_author_type, [Message::AUTHOR_SHOP, Message::AUTHOR_SYSTEM], true)
            && (int) $this->last_message_id > (int) $this->buyer_last_read_id;
    }

    /**
     * Advance a side's read cursor to the newest message. Idempotent.
     */
    public function markRead(string $side): void
    {
        $column = $side === Message::AUTHOR_SHOP ? 'shop_last_read_id' : 'buyer_last_read_id';

        if ((int) $this->$column >= (int) $this->last_message_id) {
            return;
        }

        $this->$column = (int) $this->last_message_id;
        $this->save();

        if ($side === Message::AUTHOR_SHOP) {
            self::flushShopUnreadCache();
        }
    }

    /**
     * Number of conversations with unread messages for a side, and the per-row
     * unread counts for a set of conversation ids — both computed against the
     * stored cursors in one raw query each (the ORM has no JOIN). Kept here so
     * the "unread" definition lives in exactly one place.
     */
    /**
     * The menu-bubble count runs on EVERY wp-admin page load, so it is cached
     * behind a short transient and busted the moment a message is sent or the
     * shop reads a thread (see addMessage()/markRead()). Without this it was a
     * full COUNT(*) scan of the conversations table on every admin page.
     */
    private const SHOP_UNREAD_CACHE = 'smb2b_shop_unread_count';

    public static function unreadThreadCountForShop(): int
    {
        $cached = get_transient(self::SHOP_UNREAD_CACHE);
        if ($cached !== false) {
            return (int) $cached;
        }

        $count = self::countUnreadForShop();
        set_transient(self::SHOP_UNREAD_CACHE, $count, MINUTE_IN_SECONDS);

        return $count;
    }

    public static function flushShopUnreadCache(): void
    {
        delete_transient(self::SHOP_UNREAD_CACHE);
    }

    private static function countUnreadForShop(): int
    {
        // Unread for the shop = latest line is the buyer's and sits beyond the
        // shop cursor. The cursor test is a column-to-column comparison, so it
        // goes through the builder's whereColumn().
        return self::query()
            ->andWhere('last_author_type', '=', Message::AUTHOR_BUYER)
            ->whereColumn('last_message_id', '>', 'shop_last_read_id')
            ->count();
    }

    /**
     * Number of a buyer's own threads that have unread messages for them — the
     * latest line is shop- or system-authored and sits beyond the buyer cursor.
     */
    public static function unreadThreadCountForBuyer(int $buyerId): int
    {
        if ($buyerId <= 0) {
            return 0;
        }

        return self::query()
            ->andWhere('buyer_id', '=', $buyerId)
            ->whereIn('last_author_type', [Message::AUTHOR_SHOP, Message::AUTHOR_SYSTEM])
            ->whereColumn('last_message_id', '>', 'buyer_last_read_id')
            ->count();
    }

    /**
     * Per-conversation unread message counts for the shop side, keyed by
     * conversation id. One grouped query correlating messages to each thread's
     * stored shop cursor.
     *
     * @param int[] $conversationIds
     * @return array<int, int>
     */
    public static function shopUnreadCounts(array $conversationIds): array
    {
        $conversationIds = array_values(array_filter(array_map('intval', $conversationIds)));

        if (empty($conversationIds)) {
            return [];
        }

        global $wpdb;

        $conv     = self::table();
        $messages = Message::table();
        $in       = implode(',', array_fill(0, count($conversationIds), '%d'));

        $params   = array_merge([$messages, $conv], $conversationIds, [Message::AUTHOR_BUYER]);

        // {$in} is a run of %d placeholders (one per id), so the query IS fully
        // prepared — the sniffs just can't count a dynamically-built placeholder
        // list; table names are %i identifiers. disable/enable spans the whole
        // multi-line prepare() so the interpolation line is covered too.
        // phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.NotPrepared, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare, WordPress.DB.PreparedSQLPlaceholders.ReplacementsWrongNumber, PluginCheck.Security.DirectDB.UnescapedDBParameter
        $rows = $wpdb->get_results($wpdb->prepare(
            "SELECT m.conversation_id AS cid, COUNT(*) AS c
             FROM %i m
             INNER JOIN %i c ON c.id = m.conversation_id
             WHERE m.conversation_id IN ({$in})
               AND m.author_type = %s
               AND m.id > c.shop_last_read_id
             GROUP BY m.conversation_id",
            $params
        ), ARRAY_A);
        // phpcs:enable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.NotPrepared, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare, WordPress.DB.PreparedSQLPlaceholders.ReplacementsWrongNumber, PluginCheck.Security.DirectDB.UnescapedDBParameter

        $counts = [];

        foreach ($rows ?: [] as $row) {
            $counts[(int) $row['cid']] = (int) $row['c'];
        }

        return $counts;
    }
}
