<?php

namespace auctionforge\inc\import;

use auctionforge\inc\abs\Importer;
use auctionforge\inc\BidSpiritRequest;
use auctionforge\inc\controllers\MultiLanguageSupport;
use auctionforge\inc\controllers\Settings;
use auctionforge\inc\Debug;
use auctionforge\inc\helpers\SettingsHelper;
use auctionforge\inc\InstanceDB;
use auctionforge\inc\model\Auction;
use auctionforge\inc\model\Lot;
use auctionforge\inc\model\LotMetaData;
use auctionforge\inc\model\Shop;

/**
 * Imports a BidSpirit Shop into the local lot pipeline.
 *
 * Mirrors {@see LotList} but reads shop/getShopItems instead of
 * items/getAuctionItems, and stages everything against the single synthetic
 * shop term from {@see Shop}. Once staged, InsertLotsBS drains the rows into WP
 * `lot` posts with no changes at all — which is the whole point of modelling
 * the shop as a term.
 *
 * Two API-shape differences drive the mapping below:
 *
 *  1. Shop items have NO `itemIndex`. Auction lots are keyed and ordered on it
 *     throughout AuctionForge, so one is synthesised from the item's `id`
 *     (stable, unique, and already the value shop/purchaseShopItem wants).
 *  2. Shop items carry `shopPrice`, not a start price + buyout price. It is
 *     mapped onto `buyoutPrice` because that is the field the existing buy-now
 *     presentation already renders from, server-side and client-side.
 */
class ShopList extends Importer
{
    /** Page size for shop/getShopItems. The API allows up to 10000. */
    const PAGE_SIZE = 500;

    /** Safety stop so a misbehaving API can never spin forever. */
    const MAX_PAGES = 200;

    /** @var int|string */
    private $shopId;

    /** @var string */
    private $currency = '';

    /** @var \WP_Term|false */
    private $shopTerm = false;

    /** @var array */
    private $categories = array();

    /** @var string */
    private $createDatetime;

    /**
     * Fetch the shop and stage it. Safe to call on every sync: it no-ops when
     * the feature is off, when the house has no shop, or when the API errors.
     *
     * @return bool True when items were staged.
     */
    public function init()
    {
        if (!Shop::isEnabled()) {
            return false;
        }

        $items = $this->fetchAllItems();

        if ($items === false) {
            return false;
        }

        // A shop that exists but has been emptied is a legitimate state — carry
        // on so the local lots get drafted rather than left orphaned on sale.
        $this->shopTerm = Shop::ensureTerm($this->shopId, $this->currency);
        if (!$this->shopTerm) {
            Debug::logData($this->shopId, 'Shop term could not be created');
            return false;
        }

        return $this->stageItems($items);
    }

    /**
     * Page through shop/getShopItems.
     *
     * @return array|false Items, or false when there is no usable shop.
     */
    private function fetchAllItems()
    {
        $language = MultiLanguageSupport::getDefaultLangCode();
        $items = array();
        $offset = 0;
        $page = 0;
        $count = null;

        do {
            $response = BidSpiritRequest::send('shop/getShopItems.api', array(
                self::FIELD_LANGUAGE => $language,
                'offset' => $offset,
                'size' => self::PAGE_SIZE,
            ));

            $decoded = json_decode($response['response']);

            if (empty($decoded->response) || !empty($decoded->response->errorCode)) {
                // No shop configured for this house, or the call failed. Either
                // way there is nothing to import — stay quiet, this runs on
                // every sync for sites that may never have a shop.
                if (!empty($decoded->response->errorCode)) {
                    Debug::logData($decoded->response, 'shop/getShopItems error');
                }
                return false;
            }

            $payload = $decoded->response;

            if ($count === null) {
                $count = isset($payload->count) ? (int) $payload->count : 0;
                $this->shopId = isset($payload->shopId) ? $payload->shopId : null;
                $this->currency = isset($payload->currency) ? $payload->currency : '';

                if ($this->shopId === null) {
                    return false;
                }

                if (!empty($payload->categories)) {
                    $this->categories = $payload->categories;
                }
            }

            $batch = !empty($payload->items) ? $payload->items : array();
            $items = array_merge($items, $batch);

            $offset += self::PAGE_SIZE;
            $page++;

            // Stop when this page came back short — that is the last page even
            // if `count` disagrees, which protects against an endless loop if
            // the total shifts mid-walk.
            if (count($batch) < self::PAGE_SIZE) {
                break;
            }
        } while (count($items) < $count && $page < self::MAX_PAGES);

        return $items;
    }

    /**
     * Reshape shop items into staged Lot rows.
     *
     * @param array $items
     * @return bool
     */
    private function stageItems($items)
    {
        $termId = $this->shopTerm->term_id;
        $auctionId = Shop::auctionIdFor($this->shopId);
        $language = MultiLanguageSupport::getDefaultLangCode();

        $existing = Auction::getAllPosts($termId, array(
            SettingsHelper::STATUS_PUBLISH,
            SettingsHelper::STATUS_FUTURE,
        ));
        $staleIndexes = $this->collectionLotIndexes($existing);

        $db = InstanceDB::getInstanceDB();
        $autoCommit = SettingsHelper::isChecked(Settings::AUTO_COMMIT_DISABLED, 'off');
        wp_defer_comment_counting(true);
        InstanceDB::unlockTable();
        if ($autoCommit) {
            $db->query('SET autocommit = 0;');
        }

        $lots = array();
        $this->createDatetime = date(Importer::DATE_FORMAT);

        foreach ($items as $item) {
            $model = $this->mapItem($item, $auctionId, $language);
            if ($model === null) {
                continue;
            }

            $lot = apply_filters('auctionforge_lot_instance', new Lot());
            $lot->loadAttributes($model);
            $lots[] = $lot;

            if ($key = array_search($lot->getItemIndex(), $staleIndexes)) {
                unset($staleIndexes[$key]);
            }
        }

        $staged = false;
        if (!empty($lots)) {
            $staged = (bool) Lot::bulkSave($lots);
        }

        wp_defer_comment_counting(false);
        if ($autoCommit) {
            // Restore autocommit as well as committing — leaving the connection
            // at autocommit=0 is what silently rolled back sync-job completion
            // before 3.7.1.20.
            $db->query('COMMIT;');
            $db->query('SET autocommit = 1;');
        }

        // Drop this shop's rows from earlier runs. Every sync re-stages the
        // whole shop, so without this import_history grows without bound —
        // LotList does the equivalent via deleteOldestRecords(). Rows created by
        // THIS run are kept, and they supersede anything an earlier run left
        // undrained, so nothing is lost.
        $db->query($db->prepare(
            "DELETE FROM {$db->prefix}" . Importer::TABLE_NAME . "
             WHERE auctionId = %d AND createDatetime < %s",
            $auctionId, $this->createDatetime
        ));

        // Anything that vanished from the shop should stop being offered.
        $this->draftRemoved($staleIndexes);

        if (!empty($this->categories)) {
            $this->recordCategories($termId);
        }

        $this->setLoggedInfo(array(
            'bs_shop_' . $this->shopId => array(
                'staged' => count($lots),
                'drafted' => count($staleIndexes),
            ),
        ));

        return $staged;
    }

    /**
     * Map one shop item onto the field names LotModel expects.
     *
     * @param object $item
     * @param int    $auctionId
     * @param string $language
     * @return object|null
     */
    private function mapItem($item, $auctionId, $language)
    {
        if (empty($item->id)) {
            return null;
        }

        $model = new \stdClass();

        // Identity. The BidSpirit item id doubles as the local item index —
        // shop items have no index of their own — and it is also the lotId that
        // shop/purchaseShopItem needs, so the front end can read it straight
        // back off the rendered lot.
        //
        // ⚠ It must be set as `id`, NOT `lotId`: Lot::buildFields() writes the
        // staging `lotId` column from getId(), and InsertLotsBS matches raw rows
        // to existing posts on that column. Left at 0 every shop row collides,
        // `count($datum) <= 1` fails, and the lots are silently skipped.
        $model->id = (int) $item->id;
        $model->itemIndex = (string) $item->id;
        $model->paddedIndex = str_pad((string) $item->id, 12, '0', STR_PAD_LEFT);
        // buildFields() derives auctionSlug from getAuctionId() too, so this one
        // assignment feeds both columns.
        $model->auctionId = $auctionId;
        $model->lang = !empty($item->lang) ? $item->lang : $language;

        // Content.
        $model->name = isset($item->name) ? $item->name : '';
        $model->description = isset($item->description) ? $item->description : '';
        $model->author = isset($item->author) ? $item->author : '';
        $model->details = isset($item->details) ? $item->details : '';
        $model->tags = isset($item->tags) ? $item->tags : '';
        $model->videoUrl = isset($item->videoUrl) ? $item->videoUrl : '';
        $model->extraInfo = isset($item->extraInfo) ? $item->extraInfo : '';
        $model->categoryId = isset($item->categoryId) ? (int) $item->categoryId : 0;

        // Images — identical handling to auction lots.
        $model->imagesListStr = isset($item->imagesListStr) ? $item->imagesListStr : '';
        $model->imagesBase = isset($item->imagesBase) ? $item->imagesBase : '';
        $model->imagesCloned = !empty($item->imagesCloned);

        // Pricing. shopPrice becomes buyoutPrice so every existing buy-now
        // template renders it with no changes; startPrice stays 0 as the API
        // reports it, so nothing shows a bogus opening bid.
        $model->startPrice = 0;
        $model->buyoutPrice = isset($item->shopPrice) ? (int) $item->shopPrice : 0;
        $model->fullPriceVat = !empty($item->fullPriceVat);

        // A sold shop item keeps its listing but must read as SOLD.
        $model->soldBid = isset($item->soldPrice) ? (int) $item->soldPrice : 0;
        $model->leadingBid = 0;

        // There is no post-sale window on a shop.
        $model->postSaleDisabled = 1;

        // Flags.
        $model->hidden = isset($item->published) ? (empty($item->published) ? 1 : 0) : 0;
        $model->eroticContent = !empty($item->eroticContent);
        $model->criminalContent = !empty($item->criminalContent);
        $model->isGem = !empty($item->isGem);
        $model->lastUpdate = isset($item->lastUpdate) ? (int) $item->lastUpdate : 0;

        // Carried through buildParams() into the lot's `params` blob.
        foreach (array('itemCondition', 'conditionReport', 'lengthUnit', 'weightUnit',
                     'width', 'height', 'weight', 'depth') as $field) {
            if (isset($item->{$field})) {
                $model->{$field} = $item->{$field};
            }
        }

        // Staging bookkeeping, same as LotList.
        $model->syncTimes = 0;
        $model->priority = 0;
        $model->createDatetime = date(Importer::DATE_FORMAT);

        return apply_filters('auctionforge_shop_item_model', $model, $item);
    }

    /**
     * Item indexes currently published locally, keyed by post id.
     *
     * @param \WP_Post[] $posts
     * @return array
     */
    private function collectionLotIndexes($posts)
    {
        $indexes = array();
        if (!empty($posts)) {
            foreach ($posts as $post) {
                $indexes[$post->ID] = LotMetaData::getMetaField('itemIndex', $post->ID);
            }
        }
        return $indexes;
    }

    /**
     * Draft lots that are no longer in the shop.
     *
     * @param array $indexes post_id => itemIndex
     * @return void
     */
    private function draftRemoved($indexes)
    {
        if (empty($indexes)) {
            return;
        }

        foreach ($indexes as $postId => $itemIndex) {
            wp_update_post(array(
                'ID' => $postId,
                'post_status' => 'draft',
            ));
        }
    }

    /**
     * Store the shop's categories on the term, same shape LotList uses so the
     * category filters work unchanged.
     *
     * @param int $termId
     * @return void
     */
    private function recordCategories($termId)
    {
        $data = array();
        foreach ($this->categories as $category) {
            if (!empty($category->id)) {
                $data[$category->id] = htmlspecialchars(addslashes($category->name));
            }
        }

        if (!empty($data)) {
            update_term_meta($termId, Auction::META_FIELD_TAGS,
                json_encode($data, JSON_UNESCAPED_UNICODE | JSON_HEX_QUOT));
        }
    }

    /**
     * @return \WP_Term|false
     */
    public function getShopTerm()
    {
        return $this->shopTerm;
    }
}
