import re
import uuid

import json

import mimetypes

from sqlalchemy import Column, Integer, BigInteger, Boolean, Unicode, UnicodeText

from sqlalchemy.orm import relationship

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

from sqlalchemy.ext.associationproxy import association_proxy

from .user_product import UserProduct
from .price import Price

from make_post_sell.lib.render import markdown_to_html
from make_post_sell.lib.time_funcs import timestamp_to_datetime, timestamp_to_ago_string

from slugify import slugify

from ..lib.currency import (
    dollars_to_cents,
    cents_to_dollars,
)

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


DEFAULT_FILE_METADATA = unicode(
    """
{
  "originals":{},
  "extensions":{}
}
"""
)

DEFAULT_BUNDLE_METADATA = unicode(
    """
{
  "bundle-ids":[],
  "product-ids":[]
}
"""
)

# Media type detection for pop-out player
VIDEO_EXTENSIONS = ["mp4", "webm", "mkv", "avi", "mov", "wmv", "flv", "m4v", "ogv"]
AUDIO_EXTENSIONS = ["mp3", "wav", "ogg", "m4a", "flac", "aac", "wma", "opus"]
IMAGE_EXTENSIONS = ["jpg", "jpeg", "png", "gif", "webp", "bmp", "svg"]
DOCUMENT_EXTENSIONS = ["pdf"]


def get_media_type(extension):
    """Returns 'video', 'audio', 'image', 'document', or None based on file extension."""
    if not extension:
        return None
    ext = extension.lower()
    if ext in VIDEO_EXTENSIONS:
        return "video"
    elif ext in AUDIO_EXTENSIONS:
        return "audio"
    elif ext in IMAGE_EXTENSIONS:
        return "image"
    elif ext in DOCUMENT_EXTENSIONS:
        return "document"
    return None


def sizeof_fmt(num, suffix="B"):
    for unit in ["", "Ki", "Mi", "Gi", "Ti", "Pi", "Ei", "Zi"]:
        if abs(num) < 1024.0:
            return f"{num:3.1f}{unit}{suffix}"
        num /= 1024.0
    return f"{num:.1f}Yi{suffix}"


class Product(RBase, Base):
    """This class represents a product."""

    error_message = None

    id = Column(UUIDType, primary_key=True, index=True)
    shop_id = Column(UUIDType, foreign_key("Shop", "id"), nullable=False)
    title = Column(Unicode(256), nullable=False)

    # TODO: rename this column to description_raw.
    description = Column(UnicodeText, nullable=False)
    description_html = Column(UnicodeText, nullable=False)

    json_file_metadata = Column(UnicodeText, default=DEFAULT_FILE_METADATA)

    json_file_bytes = Column(UnicodeText, default="{}")

    total_file_bytes = Column(BigInteger, nullable=False, default=0)

    # Optional magnet link for BitTorrent distribution.
    # Only generated when torrent_opt_in=True AND visibility=1 (public).
    torrent_magnet_link = Column(UnicodeText, nullable=True)

    # CDN URL of the .torrent file (set alongside torrent_magnet_link)
    torrent_file_url = Column(UnicodeText, nullable=True)

    # Explicit per-product opt-in to BitTorrent seeding.
    # False by default — owner must consciously enable per product.
    # Once seeded, files persist in the swarm even if this is later disabled.
    torrent_opt_in = Column(Boolean, nullable=False, server_default="0", default=False)

    # a denormalized json field to relate products to bundles, and vice versa.
    json_bundle_metadata = Column(
        UnicodeText, default=DEFAULT_BUNDLE_METADATA, nullable=False
    )

    # 0=private, 1=public, 2=unlisted
    visibility = Column(Integer, nullable=False, default=1)

    price_in_cents = Column(BigInteger, nullable=False, default=0)

    # MPS-20 / MPS-21: how this product's price is determined.
    # 0=fixed (price_in_cents), 1=auction, 2=auction+buy_now,
    # 3=offer, 4=offer+buy_now.
    pricing_mode = Column(
        Integer, nullable=False, default=0, server_default="0"
    )

    # MPS-21: per-product override of shop's offer setting.
    # NULL = inherit shop, True/False = override.
    allow_offers = Column(Boolean, nullable=True)

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

    view_count = Column(BigInteger, nullable=False, default=0, server_default="0")

    # this value should be treated as immutable after object creation.
    is_bundle = Column(Boolean, default=False, nullable=False)

    # this value should be treated as immutable after object creation.
    is_sellable = Column(Boolean, default=False, nullable=False)

    # this value should be treated as immutable after object creation.
    is_physical = Column(Boolean, default=False, nullable=False)

    # Add relationship to inventories
    inventories = relationship("Inventory", lazy="joined", back_populates="product")

    # lazy="joined" performs a left join to reduce queries, it's magic.
    # set uselist=False creates one-to-one relationship.
    shop = relationship(argument="Shop", uselist=False, lazy="joined")

    # lazy="joined" performs a left join to reduce queries, it's magic.
    price_history = relationship(
        argument="Price",
        lazy="dynamic",
        order_by="desc(Price.created_timestamp)",
        back_populates="product",
    )

    # lazy="dynamic" returns a query object for all comments
    comments = relationship(
        argument="Comment",
        lazy="dynamic",
        order_by="Comment.created_timestamp",
        back_populates="product",
    )

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

    file_keys = [
        "product",
        "preview",
        "thumbnail1",
        "thumbnail2",
        "thumbnail3",
        "thumbnail4",
        "instrumentals",
        "vocals",
    ]

    file_public_keys = [
        "preview",
        "thumbnail1",
        "thumbnail2",
        "thumbnail3",
        "thumbnail4",
    ]

    file_thumbnail_keys = ["thumbnail1", "thumbnail2", "thumbnail3", "thumbnail4"]

    def __init__(self, title, description):
        self.id = uuid.uuid1()
        self.title = title
        self.set_description(description)
        self.created_timestamp = now_timestamp()
        self.updated_timestamp = now_timestamp()

    @property
    def is_fixed_price(self):
        """pricing_mode=0 — buy now at price_in_cents (today's behavior)."""
        return self.pricing_mode == 0

    @property
    def is_auction(self):
        """pricing_mode=1 or 2 — auction (with or without buy_now)."""
        return self.pricing_mode in (1, 2)

    @property
    def is_buy_now_allowed(self):
        """pricing_mode in {0, 2, 4} permits direct purchase at listed price."""
        return self.pricing_mode in (0, 2, 4)

    @property
    def is_offer_mode(self):
        """pricing_mode=3 or 4 — make-an-offer enabled (with or without buy_now)."""
        return self.pricing_mode in (3, 4)

    @property
    def offers_allowed(self):
        """Resolves shop default + per-product override.
        allow_offers=True/False overrides; NULL inherits shop.offer_enabled."""
        if not self.is_offer_mode:
            return False
        if self.allow_offers is not None:
            return self.allow_offers
        return bool(getattr(self.shop, "offer_enabled", False))

    @property
    def human_updated_timestamp(self):
        return timestamp_to_ago_string(self.updated_timestamp)

    @property
    def human_created_timestamp(self):
        return timestamp_to_ago_string(self.created_timestamp)

    @property
    def human_view_count(self):
        n = self.view_count
        if not n:
            return ""
        if n < 1000:
            label = "view" if n == 1 else "views"
            return f"{n} {label}"
        if n < 1_000_000:
            k = n / 1000
            s = f"{k:.1f}".rstrip("0").rstrip(".")
            return f"{s}K views"
        m = n / 1_000_000
        s = f"{m:.1f}".rstrip("0").rstrip(".")
        return f"{s}M views"

    def get_userproduct_for_user(self, user):
        """Given a User object, return the UserProduct object or None."""
        for up in self.product_users:
            if up.user == user:
                return up

    def unlock_for_user(self, user):
        """
        Unlock this product for user so they may download.
        Adds User object to UserProduct.
        """
        up = self.get_userproduct_for_user(user)
        if up is None:
            up = UserProduct(user=user, product=self)
            self.product_users.append(up)
        return up

    def set_description(self, description):
        self.updated_timestamp = now_timestamp()
        # TODO: rename this column to description_raw.
        self.description = description
        self.description_html = markdown_to_html(description, self.shop)

    def set_price(self, price):
        self.updated_timestamp = now_timestamp()
        try:
            self.price_in_cents = dollars_to_cents(price)
        except ValueError:
            self.error_message = "Please use only numbers for price."
            return None
        # create and return a price history object.
        return Price(self, self.price_in_cents)

    @property
    def price(self):
        return cents_to_dollars(self.price_in_cents)

    @property
    def current_price(self):
        """return the most current Price object for this Product from history."""
        return self.price_history.limit(1).one()

    @property
    def has_product_file(self):
        """returns True if product has a product file, else False"""
        return True if self.extensions.get("product") else False

    @property
    def is_ready(self):
        """Internal safety checks prior to allowing the sale of this product."""
        if self.is_sellable:
            if self.is_bundle:
                return True if len(self.bundle_metadata["product-ids"]) >= 1 else False
            if self.is_physical:
                return "thumbnail1" in self.extensions
            return self.has_product_file

        # Content is always ready, for example a blog post only needs a title & description.
        return True

    @property
    def is_not_ready(self):
        return not self.is_ready

    @property
    def is_not_sellable(self):
        return not self.is_sellable

    @property
    def is_public(self):
        return True if self.visibility == 1 else False

    @property
    def is_unlisted(self):
        return True if self.visibility == 2 else False

    @property
    def is_private(self):
        return True if self.visibility == 0 else False

    @property
    def human_visibility(self):
        return VISIBILITY_INT_TO_HUMAN[self.visibility]

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

    @property
    def total_inventory_quantity(self):
        if not self.is_physical:
            return None
        return sum(inventory.quantity for inventory in self.inventories)

    @property
    def s3_path(self):
        """return the S3 path shop.id/product.id"""
        return f"{self.shop.id}/{self.id}"

    @property
    def s3_key(self):
        """return the product S3 key"""
        return f"{self.s3_path}/product"

    @property
    def s3_key_preview(self):
        """return the product preview S3 key"""
        return f"{self.s3_path}/preview"

    def s3_key_thumbnail(self, file_key):
        """return the thumbail S3 key"""
        return f"{self.s3_path}/{file_key}"

    @property
    def s3_key_thumbnails(self):
        thumbnails = {}
        for thumbnail_key in self.file_thumbnail_keys:
            if thumbnail_key in self.extensions:
                thumbnails[thumbnail_key] = self.s3_key_thumbnail(thumbnail_key)
        return thumbnails

    @property
    def file_metadata(self):
        """memoized dictionary of file_metadata"""
        if self.json_file_metadata:
            if hasattr(self, "_file_metadata") == False:
                self._file_metadata = json.loads(self.json_file_metadata)
            return self._file_metadata
        return {}

    @property
    def file_bytes(self):
        return json.loads(self.json_file_bytes)

    @file_bytes.setter
    def file_bytes(self, new_dict):
        self.json_file_bytes = json.dumps(new_dict)
        self.total_file_bytes = sum(self.file_bytes.values())

    @property
    def human_total_file_bytes(self):
        return sizeof_fmt(self.total_file_bytes)

    @property
    def human_product_file_bytes(self):
        return sizeof_fmt(self.file_bytes.get("product", 0))

    def human_file_bytes(self, file_key):
        """Return human-readable file size for a specific file key."""
        return sizeof_fmt(self.file_bytes.get(file_key, 0))

    @property
    def extensions(self):
        return self.file_metadata.get("extensions", {})

    @property
    def originals(self):
        return self.file_metadata.get("originals", {})

    @property
    def checksums(self):
        return self.file_metadata.get("checksums", {})

    def set_checksum(self, file_key, md5_hex, sha256_hex):
        tmp = self.file_metadata
        if "checksums" not in tmp:
            tmp["checksums"] = {}
        tmp["checksums"][file_key] = {"md5": md5_hex, "sha256": sha256_hex}
        self._file_metadata = tmp
        self.json_file_metadata = json.dumps(tmp)

    def set_file_metadata(self, file_key, extension, original_filename):
        tmp = self.file_metadata
        tmp["extensions"][file_key] = extension
        tmp["originals"][file_key] = original_filename
        self._file_metadata = tmp
        self.json_file_metadata = json.dumps(tmp)

    def store_file_metadata(self, s3_key):
        # example s3_key: "uuid/uuid/product.MyTaco.Beans.pdf"
        # example parts: ["product", "MyTaco", "Beans", "pdf"]
        parts = s3_key.split("/")[-1].split(".")
        # product
        file_key = parts[0]
        # pdf
        file_ext = parts[-1]
        # MyTaco.Beans.pdf
        original_filename = ".".join(parts[1:])
        self.set_file_metadata(file_key, file_ext, original_filename)
        return file_key

    def get_content_type(self, file_key):
        """Return the content type string for the S3 object, or None.

        Uses mimetypes.guess_type which initializes from the OS mime
        database — types_map alone only knows Python's built-in table
        (no .flv, .mkv, .opus, etc.) and returns None for those.
        """
        extension = self.extensions.get(file_key)
        if not extension:
            return None
        content_type, _ = mimetypes.guess_type(f"x.{extension}")
        return content_type

    def get_content_disposition(self, file_key):
        """
        Return the content disposition string for the S3 object, or None.

        The content disposition string determines whether this is:

         * an inline image (thumbnails)
         * an attachment (file download)
        """
        file_prefix = "" if file_key == "product" else f"{file_key}-"
        extension = self.extensions.get(file_key, "")
        filename = f"{file_prefix}{self.slug}.{extension}"
        content_disposition = f"attachment; filename={filename}"

        if file_key in self.file_thumbnail_keys:
            content_disposition = f"inline; filename={filename}"
        elif self.is_not_sellable:
            content_disposition = f"inline; filename={filename}"

        return content_disposition

    @property
    def content_disposition(self):
        """return the content_disposition for the product."""
        return self.get_content_disposition("product")

    @property
    def preview_content_disposition(self):
        """return the content_disposition  for the preview."""
        return self.get_content_disposition("preview")

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

        For example:

          https://my.makepostsell.com/p/product-uuid
        """
        p_or_c = "p" if self.is_sellable else "c"
        if slug:
            return f"{request.host_url}/{p_or_c}/{self.id}/{self.slug}"
        return f"{request.host_url}/{p_or_c}/{self.id}"

    def absolute_edit_url(self, request, subject=""):
        """
        The absolute URI to the product edit page.

        For example:

          https://makepostsell.com/p/product-uuid/edit
        """
        if subject:
            return f"{self.absolute_url(request, slug=False)}/{subject}/edit"
        return f"{self.absolute_url(request, slug=False)}/edit"

    @property
    def shop_uuid_str(self):
        return self.id_to_uuid_str(self.shop_id)

    @property
    def bundle_metadata(self):
        """memoized dictionary of bundle_metadata"""
        if hasattr(self, "_bundle_metadata") == False:
            self._bundle_metadata = json.loads(self.json_bundle_metadata)
        return self._bundle_metadata

    def add_product_to_bundle(self, product):
        if self.is_bundle == False:
            self.error_message = "This product is not a bundle."
        elif self.shop_id != product.shop_id:
            self.error_message = "The product and bundle must share the same shop."
        elif product.is_physical or self.is_physical:
            self.error_message = (
                f"Refusing to bundle {product.title}, it is a physical item."
            )
        elif product in self.products_in_this_bundle:
            self.error_message = (
                f"Refusing to bundle {product.title}, it is already in the bundle."
            )

        if self.error_message:
            return False

        # denormalized bundle relationship metadata for bundle.
        self.bundle_metadata["product-ids"].append(str(product.id))
        self.json_bundle_metadata = json.dumps(self.bundle_metadata)

        # denormalized bundle relationship metadata for product in bundle.
        product.bundle_metadata["bundle-ids"].append(str(self.id))
        product.json_bundle_metadata = json.dumps(product.bundle_metadata)

        return True

    def remove_product_from_bundle(self, product):
        if self.is_bundle == False:
            self.error_message = "This product is not a bundle."
        if self.error_message:
            return False

        # denormalized bundle relationship metadata for bundle.
        self.bundle_metadata["product-ids"].remove(str(product.id))
        self.json_bundle_metadata = json.dumps(self.bundle_metadata)

        # denormalized bundle relationship metadata for product in bundle.
        product.bundle_metadata["bundle-ids"].remove(str(self.id))
        product.json_bundle_metadata = json.dumps(product.bundle_metadata)

        return True

    @property
    def products_in_this_bundle(self):
        """if this product is a bundle return a list of Product objects in this bundle."""
        if self.is_bundle == False:
            return []
        if hasattr(self, "_products_in_bundle") == False:
            self._products_in_this_bundle = (
                get_products_by_ids(self.dbsession, self.bundle_metadata["product-ids"])
                or []
            )
        return self._products_in_this_bundle

    @property
    def bundles_with_this_product(self):
        """
        Return a list of zero or many bundle Product objects.
        All the bundles out there which if purchased unlock this product.
        """
        if self.is_bundle:
            return []
        if hasattr(self, "_bundles_with_this_product") == False:
            self._bundles_with_this_product = (
                get_products_by_ids(self.dbsession, self.bundle_metadata["bundle-ids"])
                or []
            )
        return self._bundles_with_this_product

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

    def get_s3_acl_for_file_key(self, file_key):
        """Return appropriate S3 ACL based on product visibility and file type."""
        # Public files (thumbnails, previews) are always public-read for public/unlisted products
        if file_key in self.file_public_keys:
            if self.is_private:
                return "private"
            else:
                return "public-read"

        # Product files are always private regardless of visibility
        # Access is controlled via presigned URLs
        return "private"

    def update_s3_acls(self, s3_client, bucket_name):
        """Update S3 ACLs for all product files based on current visibility."""

        # Update ACLs for all file types
        for file_key in self.file_keys:
            if file_key in self.extensions:
                s3_key = getattr(self, f"s3_key_{file_key}", None)
                if s3_key is None and file_key == "product":
                    s3_key = self.s3_key
                elif s3_key is None and file_key == "preview":
                    s3_key = self.s3_key_preview
                elif s3_key is None and file_key.startswith("thumbnail"):
                    s3_key = self.s3_key_thumbnail(file_key)
                elif s3_key is None:
                    s3_key = f"{self.s3_path}/{file_key}"

                if s3_key:
                    try:
                        acl = self.get_s3_acl_for_file_key(file_key)
                        s3_client.put_object_acl(
                            Bucket=bucket_name, Key=s3_key, ACL=acl
                        )
                    except Exception as e:
                        # Log error but don't fail the visibility change
                        print(f"Failed to update S3 ACL for {s3_key}: {e}")

    def set_visibility(self, new_visibility, s3_client=None, bucket_name=None):
        """Set product visibility and update S3 ACLs accordingly."""
        old_visibility = self.visibility
        self.visibility = new_visibility

        # Update S3 ACLs if client provided and visibility changed
        if s3_client and bucket_name and old_visibility != new_visibility:
            self.update_s3_acls(s3_client, bucket_name)

    def has_user_purchased_product(self, user_id):
        """Check if user has purchased this product (for purchase-required commenting)."""
        # Check if user has purchased this product
        user_product = (
            self.dbsession.query(UserProduct)
            .filter(UserProduct.user_id == user_id, UserProduct.product_id == self.id)
            .first()
        )

        return user_product is not None

    def can_user_comment(self, user, shop):
        """Check if user can comment on this product based on shop settings."""
        if not shop.comments_enabled:
            return False, "Comments are disabled for this shop"

        if shop.comments_require_purchase and user:
            if not self.has_user_purchased_product(user.id):
                return False, "You must purchase this product to leave a comment"

        return True, None

    @property
    def public_comments(self):
        """Return only approved, non-disabled comments for public display."""
        from .comment import Comment

        return self.comments.filter(Comment.approved == True, Comment.disabled == False)

    @property
    def public_comment_count(self):
        """Return count of approved, non-disabled comments."""
        return self.public_comments.count()


def get_all_products(dbsession):
    """
    Return all Product objects descending order by created timestamp (newest first).
    """
    return dbsession.query(Product).order_by(Product.created_timestamp.desc())


def get_all_products_by_visibility(dbsession, visibility=1):
    """
    Return all Product objects with the given visibility integer in descending order by created timestamp (newest first).
    """
    # 0=private, 1=public, 2=unlisted
    return (
        dbsession.query(Product)
        .filter(Product.visibility == visibility)
        .order_by(Product.created_timestamp.desc())
    )


def get_product_by_id(dbsession, product_id):
    """Try to get Product object by id or return None."""
    return get_object_by_id(dbsession, product_id, Product)


def get_products_by_ids(dbsession, product_ids):
    """Try to get Product objects by ids or return None."""
    return get_objects_by_ids(dbsession, product_ids, Product)


def get_all_products_from_a_shop(shop):
    return shop.products.order_by(Product.updated_timestamp.desc())


def get_products_from_a_shop(shop, visibility=1):
    """
    given a Shop object, return all products,
    ordered by descending updated_timestamp.
    """
    return shop.products.filter(Product.visibility == visibility).order_by(
        Product.updated_timestamp.desc()
    )


def get_products_by_keywords(dbsession, keywords, shop=None):
    scores = {}
    hits = {}

    if shop:
        product_query = shop.products
    else:
        product_query = dbsession.query(Product)

    for keyword in keywords:
        keyword_filter = Product.title.ilike(f"%{keyword}%")
        # the product _must_ be public (1).
        query = product_query.filter(keyword_filter).filter(Product.visibility == 1)
        # Exclude non-production shops from search results (MPS-14)
        if not shop:
            from .shop import Shop
            query = query.join(Shop, Product.shop_id == Shop.id).filter(Shop.environment == 0)
        # Limit per-keyword results to prevent memory exhaustion (CWE-407).
        products = query.limit(200).all()

        for product in products:
            if product.id not in scores:
                scores[product.id] = 0
            scores[product.id] += 1

            if product.id not in hits:
                hits[product.id] = product

    # sort the scores dictionary by value (score),
    # but get the key (object id),
    # reversed (highest score first)
    score_sorted_ids = sorted(scores, key=scores.get, reverse=True)

    # return a list of objects in sorted order by score.
    return [hits[_id] for _id in score_sorted_ids]


_STEM_SUFFIXES = [
    'tion', 'ment', 'ness', 'able', 'ible', 'less',
    'ful', 'ous', 'ive', 'ing', 'ed', 'ly', 'er', 'est', 'es', 's',
]


def stem_word(word):
    """Strip common English suffixes for fuzzy matching."""
    word = word.lower()
    for suffix in _STEM_SUFFIXES:
        if word.endswith(suffix) and len(word) - len(suffix) >= 3:
            return word[:-len(suffix)]
    return word


def tokenize_and_stem(text):
    """Split text into a set of stemmed words."""
    words = re.findall(r'[a-zA-Z]{3,}', text.lower())
    return {stem_word(w) for w in words}


def get_ring_related_products(product, ring, forward=42, backward=3):
    """Get products from the ring: backward items, then forward items.

    Returns a list of dicts: {"product": Product, "offset": int}
    where offset is negative for previous, positive for next.
    Falls back to get_related_products if product not in ring.

    Walks past deleted or non-public ring entries to fill the requested
    backward/forward counts with contiguous offsets. Prevents "pocket"
    gaps where most neighbors in the raw ring are stale.
    """
    product_id_str = str(product.id)
    if product_id_str not in ring:
        fallback = get_related_products(product, limit=forward)
        return [{"product": p, "offset": i + 1} for i, p in enumerate(fallback)]

    idx = ring.index(product_id_str)
    ring_len = len(ring)
    if ring_len < 2:
        return []

    # Fetch every ring product in one query; keep only visibility==1.
    # Ring is built from public products, but items can become unlisted
    # or be deleted after the fact — skip those so Up Next stays a
    # dense list of valid discovery targets.
    products = get_products_by_ids(product.dbsession, list(set(ring)))
    product_map = {str(p.id): p for p in products if p.visibility == 1}

    # Walk backward from current, collect up to `backward` valid neighbors
    # (nearest first). Then reverse so sidebar reads -N, ..., -1.
    backward_ids = []
    step = 0
    while len(backward_ids) < backward and step < ring_len - 1:
        step += 1
        pos = (idx - step + ring_len) % ring_len
        pid = ring[pos]
        if pid == product_id_str:
            break
        if pid in product_map:
            backward_ids.append(pid)
    backward_ids.reverse()

    # Walk forward from current, collect up to `forward` valid neighbors.
    forward_ids = []
    step = 0
    while len(forward_ids) < forward and step < ring_len - 1:
        step += 1
        pos = (idx + step) % ring_len
        pid = ring[pos]
        if pid == product_id_str:
            break
        if pid in product_map:
            forward_ids.append(pid)

    items = []
    backward_offset_start = -len(backward_ids)
    for i, pid in enumerate(backward_ids):
        items.append({"product": product_map[pid], "offset": backward_offset_start + i})
    for i, pid in enumerate(forward_ids):
        items.append({"product": product_map[pid], "offset": i + 1})
    return items


def get_related_products(product, limit=8):
    """Find related products in same shop using tiered fallback:
    1. Stem overlap (highest score first)
    2. Same media type (newest first)
    3. Any remaining public product (newest first)
    """
    current_stems = tokenize_and_stem(
        (product.title or '') + ' ' + (product.description or '')
    )
    current_ext = product.extensions.get("product", "")
    current_media = get_media_type(current_ext)

    scored = []
    for other in product.shop.products:
        if other.id == product.id:
            continue
        if other.visibility != 1:
            continue
        other_stems = tokenize_and_stem(
            (other.title or '') + ' ' + (other.description or '')
        )
        union = current_stems | other_stems
        score = len(current_stems & other_stems) / len(union) if union else 0
        scored.append((score, other))

    # Deterministic sort: highest score, newest, then id tiebreak
    scored.sort(key=lambda x: (-x[0], -x[1].created_timestamp, str(x[1].id)))

    results = []
    result_ids = set()

    # Tier 1: stem matches (score > 0)
    for s, p in scored:
        if s > 0 and len(results) < limit:
            results.append(p)
            result_ids.add(p.id)

    # Tier 2: same media type
    if current_media and len(results) < limit:
        for _, p in scored:
            if p.id not in result_ids and get_media_type(p.extensions.get("product", "")) == current_media:
                results.append(p)
                result_ids.add(p.id)
                if len(results) >= limit:
                    break

    # Tier 3: any remaining public product
    for _, p in scored:
        if p.id not in result_ids:
            results.append(p)
            result_ids.add(p.id)
            if len(results) >= limit:
                break

    return results[:limit]
