import json
import logging
import threading
import time
import uuid
from collections import Counter

from sqlalchemy import Column, BigInteger, Boolean, Unicode, UnicodeText, func
from sqlalchemy import and_
from sqlalchemy.orm import Session as SASession

log = logging.getLogger(__name__)

from .meta import (
    Base,
    RBase,
    UUIDType,
    now_timestamp,
    foreign_key,
    get_object_by_id,
    get_objects_by_ids,
)

from sqlalchemy.ext.associationproxy import association_proxy

from sqlalchemy.orm import relationship, object_session

from .user_shop import UserShop

from .stripe_user_shop import StripeUserShop

from slugify import slugify

from ..lib.hex_color import color_scale

from ..lib.render import markdown_to_html

try:
    unicode("")
except:
    from six import u as unicode


class Shop(RBase, Base):
    """This class represents a shop."""

    id = Column(UUIDType, primary_key=True, index=True)
    name = Column(Unicode(64), unique=True, nullable=False)
    domain_name = Column(Unicode(256), unique=True)

    # todo: rename to description_raw.
    description = Column(UnicodeText, nullable=True)
    description_html = Column(UnicodeText, nullable=True)

    terms_of_service_raw = Column(UnicodeText, nullable=True)
    terms_of_service_html = Column(UnicodeText, nullable=True)

    privacy_policy_raw = Column(UnicodeText, nullable=True)
    privacy_policy_html = Column(UnicodeText, nullable=True)

    billing_address = Column(UnicodeText, nullable=False)
    phone_number = Column(Unicode(32), nullable=False)

    # The text at the very top of the shop's pages.
    ribbon_text = Column(UnicodeText, nullable=True, default="")
    ribbon_text_color = Column(Unicode(32), nullable=True, default="")
    ribbon_color_1 = Column(Unicode(32), nullable=True, default="")
    ribbon_color_2 = Column(Unicode(32), nullable=True, default="")

    # CSS Grid Lanes (masonry layout) - enabled by default
    grid_lanes_enabled = Column(Boolean, default=True)

    # example: "UA-XX31XX60-3".
    google_analytics_id = Column(Unicode(32), nullable=True, default="")
    plausible_domain_name = Column(Unicode(256), nullable=True, default="")

    # Google Search Console site verification code
    google_site_verification = Column(Unicode(128), nullable=True, default="")

    # example: "cus_12345678AbCdEF" but may be null.
    # this is how the shop pays for make_post_sell.
    stripe_id = Column(Unicode(32), unique=True, nullable=True)

    # these keys allow make_post_sell to charge
    # cards on behalf of the shop's stripe account.
    # stripe_secret_api_key = Column(Unicode(64), nullable=True)
    # stripe_public_api_key = Column(Unicode(64), nullable=True)
    stripe_secret_api_key = Column(Unicode(128), nullable=True)
    stripe_public_api_key = Column(Unicode(128), nullable=True)
    stripe_enabled = Column(Boolean, default=True)

    # PayPal API credentials for accepting payments
    paypal_client_id = Column(Unicode(128), nullable=True)
    paypal_secret = Column(Unicode(128), nullable=True)
    paypal_enabled = Column(Boolean, default=True)

    # Adyen API credentials for accepting payments
    adyen_api_key = Column(Unicode(128), nullable=True)
    adyen_merchant_account = Column(Unicode(128), nullable=True)
    adyen_client_key = Column(Unicode(128), nullable=True)
    adyen_hmac_key = Column(Unicode(128), nullable=True)
    adyen_enabled = Column(Boolean, default=True)

    # Unsandbox API credentials for karaoke vocal isolation
    unsandbox_public_key = Column(Unicode(128), nullable=True)
    unsandbox_secret_key = Column(Unicode(128), nullable=True)

    # S3-compatible bucket for mirroring all shop uploads
    mirror_s3_endpoint = Column(Unicode(256), nullable=True)
    mirror_s3_region = Column(Unicode(64), nullable=True)
    mirror_s3_bucket = Column(Unicode(128), nullable=True)
    mirror_s3_access_key = Column(Unicode(128), nullable=True)
    mirror_s3_secret_key = Column(Unicode(128), nullable=True)
    mirror_s3_enabled = Column(Boolean, default=False)

    created_timestamp = Column(BigInteger, nullable=False)
    updated_timestamp = Column(BigInteger, nullable=False)

    disabled = Column(Boolean, default=False)

    maint_mode = Column(Boolean, default=False)
    favicon = Column(Boolean, default=False)
    logo_banner = Column(Boolean, default=False)

    # Comment/Review system settings
    comments_enabled = Column(Boolean, default=True)
    comments_require_purchase = Column(Boolean, default=False)
    comments_require_approval = Column(Boolean, default=False)

    # Payment risk thresholds (in cents)
    payment_risk_threshold_mid_cents = Column(
        BigInteger, nullable=False, default=1000
    )  # Risk threshold between petty and mid tier (default $10)
    payment_risk_threshold_high_cents = Column(
        BigInteger, nullable=False, default=10000
    )  # Risk threshold between mid and high tier (default $100)

    # Cryptocurrency quote expiry time in seconds
    crypto_quote_expiry_seconds = Column(
        BigInteger, nullable=False, default=3600
    )  # Default 60 minutes

    # Default theme for shop visitors (0=dark, 1=light)
    default_theme = Column(BigInteger, nullable=False, default=1)  # Default light mode

    # Show created/updated dates on product and content pages
    show_dates = Column(Boolean, default=True)

    # Watch mode: sticky video, autoplay, related content sidebar
    watch_mode_enabled = Column(Boolean, default=False)

    # Grayscale mode: render entire site in black and white
    grayscale_mode = Column(Boolean, default=False)

    # Public sales stats: show sales counts and revenue on the shop page (off by default)
    public_sales_stats = Column(Boolean, default=False)

    # Color filter: 0=off, 1=grayscale, 2=red, 3=green, 4=blue,
    #               5=red+green, 6=red+blue, 7=green+blue
    color_filter = Column(BigInteger, default=0)

    # Email digest subscriptions
    subscriptions_enabled = Column(Boolean, default=True)

    # Sandbox mode: floating creative filter toolbar for visitors
    sandbox_mode = Column(Boolean, default=False)

    # Gift card settings
    gift_card_enabled = Column(Boolean, default=False)
    gift_card_min_in_cents = Column(BigInteger, nullable=False, default=500)
    gift_card_max_in_cents = Column(BigInteger, nullable=False, default=25000)

    # Shop environment: 0=production, 1=staging, 2=development
    environment = Column(BigInteger, nullable=False, default=0)

    # Trial and billing
    trial_started_timestamp = Column(BigInteger, nullable=True)
    trial_ended = Column(Boolean, default=False)
    plan_active = Column(Boolean, default=False)

    # Primary S3 bucket (Bring Your Own Bucket)
    primary_s3_endpoint = Column(Unicode(256), nullable=True)
    primary_s3_region = Column(Unicode(64), nullable=True)
    primary_s3_bucket = Column(Unicode(128), nullable=True)
    primary_s3_access_key = Column(Unicode(128), nullable=True)
    primary_s3_secret_key = Column(Unicode(128), nullable=True)
    primary_s3_cdn_endpoint = Column(Unicode(256), nullable=True)
    primary_s3_enabled = Column(Boolean, default=False)

    # Torrent / magnet link distribution (off by default)
    torrent_enabled = Column(Boolean, default=False)

    # MPS-21: make-an-offer settings (all off / sane defaults)
    offer_enabled = Column(Boolean, default=False)
    offer_min_in_cents = Column(BigInteger, nullable=True)
    offer_auto_accept_threshold_pct = Column(
        BigInteger, nullable=False, default=95, server_default="95"
    )
    offer_auto_decline_threshold_pct = Column(
        BigInteger, nullable=False, default=50, server_default="50"
    )
    offer_expiration_hours = Column(
        BigInteger, nullable=False, default=168, server_default="168"
    )
    offer_max_rounds = Column(
        BigInteger, nullable=False, default=3, server_default="3"
    )
    offer_acceptance_payment_hours = Column(
        BigInteger, nullable=False, default=24, server_default="24"
    )
    offer_min_buyer_account_age_hours = Column(
        BigInteger, nullable=False, default=0, server_default="0"
    )

    # Precomputed discovery ring: circular ordering of all public products
    json_discovery_ring = Column(UnicodeText, nullable=True)

    # many to many uses association_proxy.
    users = association_proxy("shop_users", "user", creator=lambda u: UserShop(user=u))

    carts = relationship(argument="Cart", lazy="dynamic", back_populates="shop")

    # many to many uses association_proxy.
    stripe_users = association_proxy(
        "stripe_users", "user", creator=lambda u: StripeUserShop(user=u)
    )

    # lazy='dynamic' returns a query object instead of collection.
    # Reference: http://docs.sqlalchemy.org/en/latest/orm/collections.html
    products = relationship(argument="Product", lazy="dynamic", back_populates="shop")

    # lazy='dynamic' returns a query object instead of collection.
    # Reference: http://docs.sqlalchemy.org/en/latest/orm/collections.html
    coupons = relationship(
        argument="Coupon",
        back_populates="shop",
        lazy="dynamic",
        order_by="desc(Coupon.created_timestamp)",
    )

    # lazy='dynamic' returns a query object instead of collection.
    # Reference: http://docs.sqlalchemy.org/en/latest/orm/collections.html
    invoices = relationship("Invoice", back_populates="shop", lazy="dynamic")

    # lazy='dynamic' returns a query object instead of collection.
    gift_cards = relationship(
        argument="GiftCard",
        back_populates="shop",
        lazy="dynamic",
        order_by="desc(GiftCard.created_timestamp)",
    )

    # lazy='dynamic' returns a query object instead of collection.
    # Reference: http://docs.sqlalchemy.org/en/latest/orm/collections.html
    shop_locations = relationship(
        argument="ShopLocation", lazy="dynamic", back_populates="shop"
    )

    # lazy='dynamic' returns a query object instead of collection.
    # Reference: http://docs.sqlalchemy.org/en/latest/orm/collections.html
    search_requests = relationship(
        argument="ShopSearchRequest", lazy="dynamic", back_populates="shop"
    )

    api_keys = relationship(
        argument="MpsApiKey", lazy="dynamic", back_populates="shop"
    )

    def __init__(self, name, phone_number, billing_address, description):
        self.id = uuid.uuid1()
        self.name = name
        self.phone_number = phone_number
        self.billing_address = billing_address
        self.description = description
        self.updated_timestamp = now_timestamp()
        self.created_timestamp = now_timestamp()

    def get_usershop_for_user(self, user):
        """Given a User object, return the UserShop object or None."""
        if self.dbsession is not None:
            return (
                self.dbsession.query(UserShop)
                .filter(
                    UserShop.shop == self,
                    UserShop.user == user,
                )
                .one_or_none()
            )

    def add_user_to_shop(self, user, role_id=0):
        """Add User object to UserShop."""
        role_id = int(role_id)
        us = self.get_usershop_for_user(user)
        if us is None:
            us = UserShop(user=user, shop=self, role_id=role_id)
            self.shop_users.append(us)
        else:
            us.role_id = role_id
        return us

    # --- Environment properties (MPS-14) ---

    @property
    def is_production(self):
        return self.environment == 0

    @property
    def is_staging(self):
        return self.environment == 1

    @property
    def is_development(self):
        return self.environment == 2

    @property
    def is_non_production(self):
        return self.environment != 0

    @property
    def environment_label(self):
        return {0: "Production", 1: "Staging", 2: "Development"}.get(
            self.environment, "Production"
        )

    # --- Trial properties (MPS-15) ---

    TRIAL_DURATION_MS = 21 * 24 * 60 * 60 * 1000  # 21 days

    @property
    def trial_expiry_timestamp(self):
        if self.trial_started_timestamp is None:
            return None
        return self.trial_started_timestamp + self.TRIAL_DURATION_MS

    @property
    def is_trial_active(self):
        if self.plan_active:
            return False
        if self.trial_started_timestamp is None:
            return False
        if self.trial_ended:
            return False
        import time
        now = int(time.time() * 1000)
        return now < self.trial_expiry_timestamp

    @property
    def is_trial_expired(self):
        if self.plan_active or self.trial_started_timestamp is None:
            return False
        if self.trial_ended:
            return True
        import time
        now = int(time.time() * 1000)
        return now >= self.trial_expiry_timestamp

    @property
    def trial_days_remaining(self):
        if not self.is_trial_active:
            return 0
        import time
        remaining_ms = self.trial_expiry_timestamp - int(time.time() * 1000)
        return max(0, remaining_ms // (24 * 60 * 60 * 1000))

    @property
    def is_active(self):
        """Shop can operate: either paid plan, active trial, or pre-trial (existing)."""
        if self.plan_active:
            return True
        if self.trial_started_timestamp is None:
            return True  # grandfathered pre-trial shop
        return self.is_trial_active

    # --- BYOB properties (MPS-16) ---

    @property
    def has_primary_s3(self):
        return bool(
            self.primary_s3_enabled
            and self.primary_s3_endpoint
            and self.primary_s3_region
            and self.primary_s3_bucket
            and self.primary_s3_access_key
            and self.primary_s3_secret_key
            and self.primary_s3_cdn_endpoint
        )

    @property
    def media_cdn_endpoint(self):
        if self.has_primary_s3:
            return self.primary_s3_cdn_endpoint
        return None

    @property
    def has_s3_mirror(self):
        """Return True if shop has S3 mirror bucket configured and enabled."""
        return bool(
            self.mirror_s3_enabled
            and self.mirror_s3_endpoint
            and self.mirror_s3_bucket
            and self.mirror_s3_access_key
            and self.mirror_s3_secret_key
        )

    @property
    def owners(self):
        """Returns a list of user objects who have the owner role on this shop."""
        # us is a user_shop object.
        return [us.user for us in self.shop_users if us.is_owner]

    @property
    def editors(self):
        """Returns a list of user objects who have the editor role on this shop."""
        return self.owners + [us.user for us in self.shop_users if us.is_editor]

    @property
    def members(self):
        """Returns a list of user objects who have the member role on this shop."""
        return self.editors + [us.user for us in self.shop_users if us.is_member]

    def is_owner(self, user):
        """Check if the given user is an owner of this shop."""
        if not user:
            return False
        return user in self.owners

    def is_editor(self, user):
        """Check if the given user is an editor of this shop."""
        if not user:
            return False
        return user in self.editors

    def is_member(self, user):
        """Check if the given user is a member of this shop."""
        if not user:
            return False
        return user in self.members

    @property
    def slug(self):
        """return slug from name"""
        return slugify(self.name)

    @property
    def is_stripe_ready(self):
        """Check if shop has Stripe API keys configured."""
        if self.stripe_secret_api_key and self.stripe_public_api_key:
            return True
        return False

    @property
    def is_stripe_not_ready(self):
        return not self.is_stripe_ready

    @property
    def is_paypal_ready(self):
        """Check if shop has PayPal API credentials configured."""
        if self.paypal_client_id and self.paypal_secret:
            return True
        return False

    @property
    def is_paypal_not_ready(self):
        return not self.is_paypal_ready

    @property
    def is_adyen_ready(self):
        """Check if shop has Adyen API credentials configured."""
        if self.adyen_api_key and self.adyen_merchant_account:
            return True
        return False

    @property
    def is_adyen_not_ready(self):
        return not self.is_adyen_ready

    @property
    def has_auction_products(self):
        """MPS-20: True when this shop has at least one product in an
        auction pricing_mode (1 or 2). Used to gate buyer-side bid UI —
        auctions have no shop-level toggle, they're enabled per product.
        """
        from .product import Product
        return (
            object_session(self)
            .query(Product.id)
            .filter(Product.shop_id == self.id, Product.pricing_mode.in_([1, 2]))
            .first()
            is not None
        )

    def is_ready_for_payment(self, request):
        """Check if shop is ready based on enabled payment methods."""
        # If Stripe is enabled, shop needs Stripe API keys
        if request.stripe_enabled:
            if self.is_stripe_ready:
                return True

        # If PayPal is enabled, shop needs PayPal API credentials
        if request.paypal_enabled:
            if self.is_paypal_ready:
                return True

        # If Adyen is enabled, shop needs Adyen API credentials
        if getattr(request, "adyen_enabled", False):
            if self.is_adyen_ready:
                return True

        # If Monero is enabled, check if shop has configured processor and RPC is available
        if request.monero_enabled and request.monero_rpc_available:
            from .crypto_processor import CryptoProcessor

            processor = (
                request.dbsession.query(CryptoProcessor)
                .filter(
                    CryptoProcessor.shop_id == self.id,
                    CryptoProcessor.coin_type == "XMR",
                    CryptoProcessor.enabled == True,
                )
                .first()
            )
            if processor:
                return True

        # If Dogecoin is enabled, check if shop has configured processor and RPC is available
        if request.dogecoin_enabled and request.dogecoin_rpc_available:
            from .crypto_processor import CryptoProcessor

            processor = (
                request.dbsession.query(CryptoProcessor)
                .filter(
                    CryptoProcessor.shop_id == self.id,
                    CryptoProcessor.coin_type == "DOGE",
                    CryptoProcessor.enabled == True,
                )
                .first()
            )
            if processor:
                return True

        # No payment methods available or properly configured
        return False

    @property
    def stripe(self):
        """Return a stripe object using this shop's secret api key."""
        if hasattr(self, "_stripe") == False:
            if self.stripe_secret_api_key:
                import stripe

                stripe.api_key = self.stripe_secret_api_key
                self._stripe = stripe
            else:
                self._stripe = None
        return self._stripe

    def stripe_user_shop(self, user):
        """Return the stripe_user_shop object from our database for this user, or None."""
        return (
            self.dbsession.query(StripeUserShop)
            .filter(
                StripeUserShop.user == user,
                StripeUserShop.shop == self,
            )
            .one_or_none()
        )

    def stripe_customer_id(self, user):
        """Return the stripe_customer id from our database for this user, or None."""
        stripe_user_shop = self.stripe_user_shop(user)

        if stripe_user_shop:
            return stripe_user_shop.cus_id

    def stripe_customer(self, user, create=False):
        """
        Return the stripe customer object for this shop for a given user.
        If missing, create one and return, if the `create` parameter is True
        else return None.
        """
        stripe_customer_id = self.stripe_customer_id(user)

        if stripe_customer_id is None:
            if create == False:
                return None

            # create a new stripe customer.
            customer = self.stripe.Customer.create(email=user.email, expand=["sources"])

            # create a new many-to-many user to shop stripe customer.
            stripe_user_shop = StripeUserShop(user, self)
            stripe_user_shop.cus_id = customer.id
            stripe_customer_id = customer.id

            # flush relationship to database.
            self.dbsession.add(stripe_user_shop)
            self.dbsession.flush()
            return customer

        return self.stripe.Customer.retrieve(stripe_customer_id, expand=["sources"])

    def list_stripe_charges(self, stripe_customer=None):
        """
        Return a list of all charges for this shop.
        Optionally pass a stripe Customer object to filter.
        """
        if stripe_customer is not None:
            return self.stripe.Charge.list(customer=stripe_customer)
        return self.stripe.Charge.list()

    @property
    def paypal(self):
        """Return a PayPal SDK API instance using this shop's credentials."""
        if hasattr(self, "_paypal") == False:
            if self.paypal_client_id and self.paypal_secret:
                import paypalrestsdk

                # Get sandbox mode from request/config if available
                # Default to sandbox for safety
                mode = "sandbox"
                if hasattr(self, "dbsession") and self.dbsession:
                    try:
                        from pyramid.threadlocal import get_current_request
                        request = get_current_request()
                        if request and hasattr(request, "app"):
                            sandbox_mode = request.app.get("paypal.sandbox_mode", True)
                            if isinstance(sandbox_mode, str):
                                sandbox_mode = sandbox_mode.strip().lower() in ("1", "true", "yes", "on")
                            if not sandbox_mode:
                                mode = "live"
                    except:
                        pass

                api = paypalrestsdk.Api({
                    'mode': mode,
                    'client_id': self.paypal_client_id,
                    'client_secret': self.paypal_secret
                })
                self._paypal = api
            else:
                self._paypal = None
        return self._paypal

    def paypal_user_shop(self, user):
        """Return the paypal_user_shop object from our database for this user, or None."""
        from .paypal_user_shop import PayPalUserShop

        return (
            self.dbsession.query(PayPalUserShop)
            .filter(
                PayPalUserShop.user == user,
                PayPalUserShop.shop == self,
            )
            .one_or_none()
        )

    @property
    def adyen(self):
        """Return an Adyen SDK instance using this shop's credentials."""
        if hasattr(self, "_adyen") == False:
            if self.adyen_api_key and self.adyen_merchant_account:
                import Adyen

                adyen = Adyen.Adyen()
                adyen.client.xapikey = self.adyen_api_key

                # Get test/live mode from request/config if available
                # Default to test for safety
                platform = "test"
                if hasattr(self, "dbsession") and self.dbsession:
                    try:
                        from pyramid.threadlocal import get_current_request
                        request = get_current_request()
                        if request and hasattr(request, "app"):
                            test_mode = request.app.get("adyen.test_mode", True)
                            if isinstance(test_mode, str):
                                test_mode = test_mode.strip().lower() in ("1", "true", "yes", "on")
                            if not test_mode:
                                platform = "live"
                    except:
                        pass

                adyen.client.platform = platform
                self._adyen = adyen
            else:
                self._adyen = None
        return self._adyen

    @property
    def theme_base_color(self):
        # return the user defined base for shop or default.
        return self.ribbon_color_1 or "#5871ad"

    @property
    def theme_link_color(self):
        return color_scale(self.theme_base_color, 0.75)

    def absolute_url(self, request, slug=True):
        """
        The absolute URI to the shop's page.

        For example:

          https://my.makepostsell.com/s/<shop-uuid>
        """
        if slug:
            return f"{request.host_url}/s/{self.id}/{self.slug}"
        return f"{request.host_url}/s/{self.id}"

    def absolute_settings_url(self, request):
        """
        The absolute URI to the shop's settings page.

        For example:

          https://my.makepostsell.com/s/<shop-uuid>/settings
        """
        return f"{self.absolute_url(request, slug=False)}/settings"

    def absolute_about_url(self, request):
        """
        The absolute URI to the shop's about page.

        For example:

          https://my.makepostsell.com/s/<shop-uuid>/about
        """
        # why ure slug for this URL, is it for SEO?
        return f"{self.absolute_url(request)}/about"

    def absolute_terms_url(self, request):
        return f"{self.absolute_url(request, slug=False)}/terms"

    def absolute_privacy_policy_url(self, request):
        return f"{self.absolute_url(request, slug=False)}/privacy-policy"

    def absolute_sales_url(self, request):
        return f"{self.absolute_url(request, slug=False)}/sales"

    def set_description(self, new_description):
        self.description = new_description
        self.description_html = markdown_to_html(self.description, self)

    @property
    def privacy_policy(self):
        return self.privacy_policy_html

    @privacy_policy.setter
    def privacy_policy(self, new_privacy_policy_raw):
        self.privacy_policy_raw = new_privacy_policy_raw
        self.privacy_policy_html = markdown_to_html(self.privacy_policy_raw, self)

    @property
    def terms_of_service(self):
        return self.terms_of_service_html

    @terms_of_service.setter
    def terms_of_service(self, new_terms_of_service_raw):
        self.terms_of_service_raw = new_terms_of_service_raw
        self.terms_of_service_html = markdown_to_html(self.terms_of_service_raw, self)

    def get_carts_for_user(self, user):
        """return all of this Shop's Cart objects for the given User."""
        from .cart import Cart

        return self.carts.filter(and_(Cart.shop == self, Cart.user == user))

    def make_cart_active_for_user(self, user, cart):
        for c in self.get_carts_for_user(user):
            if cart != c:
                c.active = False
            else:
                c.active = True
            self.dbsession.add(c)
        self.dbsession.flush()

    def get_active_cart_for_user(self, user):
        carts = self.get_carts_for_user(user)
        for cart in carts:
            if cart.active == True:
                return cart

    def create_new_cart_for_user(self, user):
        from .cart import Cart

        cart = Cart()
        cart.user = user
        cart.shop = self
        self.dbsession.add(cart)
        self.dbsession.flush()
        self.make_cart_active_for_user(user, cart)
        return cart

    def stamp_updated_timestamp(self):
        self.updated_timestamp = now_timestamp()

    @property
    def discovery_ring(self):
        """Return the discovery ring as a list of product ID strings, or []."""
        if not hasattr(self, "_discovery_ring"):
            if self.json_discovery_ring:
                self._discovery_ring = json.loads(self.json_discovery_ring)
            else:
                self._discovery_ring = []
        return self._discovery_ring

    @discovery_ring.setter
    def discovery_ring(self, ring_list):
        """Set the discovery ring from a list of product ID strings."""
        self._discovery_ring = ring_list
        self.json_discovery_ring = json.dumps(ring_list)


def compute_discovery_ring(shop):
    """Compute a circular ordering of all public products using greedy nearest-neighbor.

    Algorithm:
    1. Start from the newest public product
    2. Accumulate its stems into a running set
    3. Find unvisited product with highest Jaccard similarity to accumulated stems
    4. Add to ring, merge stems, repeat until all public products are ordered

    Returns a list of product ID strings.
    """
    from .product import tokenize_and_stem

    # Gather all public products, sorted newest-first for deterministic seed
    candidates = []
    for p in shop.products:
        if p.visibility == 1:
            candidates.append(p)

    if not candidates:
        return []

    # Sort newest-first; tiebreak by ID for determinism
    candidates.sort(key=lambda p: (-p.created_timestamp, str(p.id)))

    if len(candidates) == 1:
        return [str(candidates[0].id)]

    # Pre-compute stems for all candidates
    stems_map = {}
    for p in candidates:
        stems_map[p.id] = tokenize_and_stem(
            (p.title or '') + ' ' + (p.description or '')
        )

    # Greedy walk
    ring = []
    visited = set()
    accumulated_stems = set()

    # Start with newest product
    current = candidates[0]
    ring.append(str(current.id))
    visited.add(current.id)
    accumulated_stems |= stems_map[current.id]

    while len(ring) < len(candidates):
        best_score = -1
        best_candidate = None

        for p in candidates:
            if p.id in visited:
                continue
            p_stems = stems_map[p.id]
            union = accumulated_stems | p_stems
            if union:
                score = len(accumulated_stems & p_stems) / len(union)
            else:
                score = 0
            # Yield GIL so uwsgi workers can serve requests
            time.sleep(0)

            # Tiebreak: highest score, then newest, then ID
            if (score > best_score or
                (score == best_score and best_candidate is not None and
                 (p.created_timestamp > best_candidate.created_timestamp or
                  (p.created_timestamp == best_candidate.created_timestamp and
                   str(p.id) < str(best_candidate.id))))):
                best_score = score
                best_candidate = p

        if best_candidate is None:
            break

        ring.append(str(best_candidate.id))
        visited.add(best_candidate.id)
        accumulated_stems |= stems_map[best_candidate.id]

    return ring


def reforge_discovery_ring(shop):
    """Compute and store the discovery ring on the shop (synchronous).

    Non-production shops (MPS-14) get an empty ring — they are excluded
    from public discovery.
    """
    if shop.environment is not None and shop.is_non_production:
        shop.discovery_ring = []
        return []
    ring = compute_discovery_ring(shop)
    shop.discovery_ring = ring
    return ring


def validate_discovery_ring(shop):
    """Check ring topology health against the shop's public products.

    A healthy ring: one entry per visibility==1 product, no duplicates,
    no stale IDs, no orphaned products. Any violation surfaces as a
    populated list in the returned dict.

    Returns:
        {
            "valid": bool,
            "ring_length": int,
            "public_count": int,
            "duplicates": list[str],      # IDs appearing more than once
            "orphans": list[str],         # public product IDs missing from ring
            "stale": list[str],           # ring IDs no longer public/present
            "length_mismatch": bool,      # ring_length != public_count
        }
    """
    ring = shop.discovery_ring or []
    public_ids = {str(p.id) for p in shop.products if p.visibility == 1}
    ring_ids = set(ring)

    counts = Counter(ring)
    duplicates = sorted(pid for pid, c in counts.items() if c > 1)
    stale = sorted(ring_ids - public_ids)
    orphans = sorted(public_ids - ring_ids)
    length_mismatch = len(ring) != len(public_ids)

    return {
        "valid": not (duplicates or stale or orphans or length_mismatch),
        "ring_length": len(ring),
        "public_count": len(public_ids),
        "duplicates": duplicates,
        "orphans": orphans,
        "stale": stale,
        "length_mismatch": length_mismatch,
    }


# Tracks which shops have a background reforge in progress and whether
# another reforge was requested while one was already running (dirty bit).
_reforge_running = {}   # shop_id_str -> True while a thread is active
_reforge_dirty = {}     # shop_id_str -> True if another trigger arrived mid-run
_reforge_guard = threading.Lock()


def reforge_discovery_ring_async(shop_id, session_factory):
    """Reforge the discovery ring in a background thread.

    Uses the old ring until the new one is ready. If a reforge is already
    running for this shop, sets a dirty bit so the thread runs once more
    after it finishes (picking up the latest product state). Never queues
    more than one pending reforge.
    """
    shop_id_str = str(shop_id)

    with _reforge_guard:
        if shop_id_str in _reforge_running:
            _reforge_dirty[shop_id_str] = True
            return
        _reforge_running[shop_id_str] = True

    def _reforge():
        try:
            session = SASession(bind=session_factory().get_bind())
            try:
                while True:
                    shop = session.get(Shop, shop_id)
                    if shop is None:
                        return
                    if shop.environment is not None and shop.is_non_production:
                        shop.discovery_ring = []
                        session.commit()
                        log.info("Ring cleared for non-production shop %s", shop.name)
                        return
                    ring = compute_discovery_ring(shop)
                    shop.discovery_ring = ring
                    session.commit()
                    log.info("Ring reforged for shop %s (%d products)", shop.name, len(ring))

                    health = validate_discovery_ring(shop)
                    if not health["valid"]:
                        log.warning(
                            "Ring topology anomaly for shop %s: "
                            "duplicates=%d orphans=%d stale=%d length_mismatch=%s",
                            shop.name,
                            len(health["duplicates"]),
                            len(health["orphans"]),
                            len(health["stale"]),
                            health["length_mismatch"],
                        )

                    with _reforge_guard:
                        if _reforge_dirty.pop(shop_id_str, False):
                            session.expire_all()
                            continue  # dirty bit set, run again
                        break
            except Exception:
                session.rollback()
                log.exception("Background ring reforge failed for shop %s", shop_id_str)
            finally:
                session.close()
        finally:
            with _reforge_guard:
                _reforge_running.pop(shop_id_str, None)
                _reforge_dirty.pop(shop_id_str, None)

    t = threading.Thread(target=_reforge, daemon=True)
    t.start()
    return t


def is_shop_name_available(dbsession, name):
    return not _shop_by_name_query(dbsession, unicode(name)).count()


def is_shop_name_valid(name):
    """Test if shop name meets our criteria for validity."""
    # Choices here are subject to change.
    # allow dashes.
    name = name.replace("-", "")
    # allow period.
    name = name.replace(".", "")
    # allow spaces.
    name = name.replace(" ", "")
    # allow ' single quote.
    name = name.replace("'", "")
    return name.isalnum()


def _shop_by_name_query(dbsession, name):
    """query Shop by case insensitive name."""
    return dbsession.query(Shop).filter(func.lower(Shop.name) == unicode(name.lower()))


def _shop_by_domain_name_query(dbsession, domain_name):
    """query Shop by case insensitive domain name."""
    return dbsession.query(Shop).filter(
        func.lower(Shop.domain_name) == unicode(domain_name.lower())
    )


def get_shop_by_name(dbsession, name):
    """Try to get Shop object by name or return None"""
    if name:
        return _shop_by_name_query(dbsession, name).one_or_none()


def get_shop_by_domain_name(dbsession, domain_name):
    """Try to get Shop object by name or return None"""
    if domain_name:
        return _shop_by_domain_name_query(dbsession, domain_name).one_or_none()


def get_shop_by_id(dbsession, shop_id):
    """Try to get Shop object by id or return None."""
    return get_object_by_id(dbsession, shop_id, Shop)


def get_shops_by_ids(dbsession, shop_ids):
    """Try to get Shop objects by ids or return None."""
    return get_objects_by_ids(dbsession, shop_ids, Shop)
