from pyramid.view import view_config

from pyramid.httpexceptions import HTTPFound

from . import (
    user_required,
    shop_editor_required,
    trial_active_required,
    get_referer_or_home,
)

from ..models.product import Product
from ..models.shop import reforge_discovery_ring_async
from ..models.shop_location import ShopLocation
from ..models.inventory import Inventory

from ..lib.currency import validate_float, cents_to_dollars
from ..lib.time_funcs import timestamp_to_ago_string, timestamp_to_datetime
from ..lib.cache_version import compute_cache_version, apply_no_store_headers


def checkbox_to_bool(checkbox):
    return checkbox == "on"


@view_config(route_name="product", renderer="product.j2")
@view_config(route_name="product_slug", renderer="product.j2")
def product(request):
    product = request.product

    if not request.is_saas_domain and request.domain != request.shop.domain_name:
        # The product uuid in the URI matchdict is mismatched with request shop.
        request.session.flash(("Refusing to display another shop's product.", "error"))
        return HTTPFound(get_referer_or_home(request))

    if product.is_private:
        if request.user is None or request.user.can_not_edit_product(product):
            request.session.flash(("That product is private.", "error"))
            return HTTPFound(get_referer_or_home(request))

    if product.is_not_sellable:
        return HTTPFound(f"/c/{product.id}/{product.slug}")

    if "slug" not in request.matchdict:
        return HTTPFound(f"/p/{product.id}/{product.slug}")

    signed_get_object_url = None

    bucket_name = request.shop_bucket_name

    if (
        request.user
        and request.user.authenticated
        and request.user.can_download_product(product)
    ):
        # Params: Bucket, IfMatch, IfModifiedSince, IfNoneMatch, IfUnmodifiedSince,
        # Key, Range, ResponseCacheControl, ResponseContentDisposition, ResponseContentEncoding,
        # ResponseContentLanguage, ResponseContentType, ResponseExpires, VersionId,
        # SSECustomerAlgorithm, SSECustomerKey, SSECustomerKeyMD5, RequestPayer, PartNumber

        params = {
            "Bucket": bucket_name,
            "Key": product.s3_key,
        }

        # The content disposition string determines whether this is:
        # * an inline image (thumbnails)
        # * an attachment (file download)
        content_disposition = product.get_content_disposition("product")

        # The content type string determines whether this is a pdf, zip, etc.
        content_type = product.get_content_type("product")

        if content_disposition:
            params["ResponseContentDisposition"] = content_disposition

        if content_type:
            params["ResponseContentType"] = content_type

        signed_get_object_url = request.shop_uploads_client.generate_presigned_url(
            ClientMethod="get_object",
            Params=params,
            # 15 minutes.
            ExpiresIn=900,
        )

    product_size = ""
    if product.has_product_file:
        product_size = product.human_product_file_bytes

    # Load comments for the product
    from ..models.comment import get_comments_for_product

    comments = get_comments_for_product(
        request.dbsession, product.id, shop=product.shop, user=request.user
    )

    related_products = []
    if product.shop.watch_mode_enabled:
        from ..models.product import get_ring_related_products, get_related_products
        ring = product.shop.discovery_ring
        if ring:
            related_products = get_ring_related_products(product, ring, forward=42)
        else:
            related_products = get_related_products(product)

    price_history = []
    if (
        product.is_sellable
        and request.user
        and request.user.can_edit_shop(product.shop)
    ):
        history_rows = product.price_history.limit(20).all()
        for i, ph in enumerate(history_rows):
            price_history.append({
                "price_formatted": f"${cents_to_dollars(ph.price_in_cents):.2f}",
                "ago": timestamp_to_ago_string(ph.created_timestamp),
                "datetime": str(timestamp_to_datetime(ph.created_timestamp)),
                "is_current": i == 0,
            })

    # Dynamic product page — never cache HTML at the browser. Same
    # reason as content.py: Up Next sidebar must reflect live ring state.
    apply_no_store_headers(request.response)

    return {
        "product": product,
        "product_size": product_size,
        "signed_get_object_url": signed_get_object_url,
        "comments": comments,
        "shop": product.shop,
        "related_products": related_products,
        "cache_version": compute_cache_version(product.shop),
        "price_history": price_history,
    }


@view_config(route_name="product_new", renderer="product_new.j2")
@view_config(route_name="content_new", renderer="content_new.j2")
@user_required()
@shop_editor_required()
@trial_active_required()
def product_new(request):
    title = request.params.get("title", "").strip()
    description = request.params.get("description", "").strip()
    price = request.params.get("price", "").strip()

    is_bundle_checkbox = request.params.get("is_bundle", "off")
    is_bundle = checkbox_to_bool(is_bundle_checkbox)

    is_sellable_checkbox = request.params.get("is_sellable", "off")
    is_sellable = checkbox_to_bool(is_sellable_checkbox)

    is_physical = bool(request.params.get("is_physical", "false") == "true")

    if "submit" in request.params:
        # check for required fields.
        if not title or not description or (is_sellable and not price):
            msg = ("You must fill out all fields.", "error")
            request.session.flash(msg)
        else:
            # Validate price if the product is sellable
            price_error = False
            if is_sellable:
                try:
                    price = validate_float(price)
                except Exception as e:
                    price_error = True
                    msg = (f"Please enter a valid number for price. {e}", "error")
                    request.session.flash(msg)

            if not price_error:
                # prevent trying to create a bundle and also physical product.
                if is_bundle and is_physical:
                    msg = (
                        "The bundle feature only works with digital goods. Please uncheck the bundle option or mark the product as digital.",
                        "error",
                    )
                    request.session.flash(msg)
                else:
                    product = Product(title, description)
                    product.shop = request.shop
                    product.is_bundle = is_bundle
                    product.is_sellable = is_sellable
                    product.is_physical = is_physical

                    if product.error_message:
                        request.session.flash((product.error_message, "error"))
                    else:
                        if is_sellable and price:
                            product_price = product.set_price(price)
                            request.dbsession.add(product_price)

                        request.dbsession.add(product)
                        request.dbsession.flush()
                        if request.shop.watch_mode_enabled:
                            reforge_discovery_ring_async(
                                request.shop.id, request.registry["dbsession_factory"]
                            )
                        msg = ("Great, next you may upload files.", "success")
                        request.session.flash(msg)
                        return HTTPFound(f"/p/{product.id}/edit")

    return {
        "title": title,
        "description": description,
        "price": price,
        "is_bundle": is_bundle,
        "is_physical": is_physical,
        "create_location_url": request.route_url(
            "shop_location_new", shop_id=request.shop.id
        ),
        "shop_has_locations": len(request.shop.shop_locations.all()) > 0,
    }


@view_config(route_name="product_description_edit", renderer="markup_editor.j2")
@view_config(route_name="content_description_edit", renderer="markup_editor.j2")
@shop_editor_required()
def product_edit_description(request):
    product = request.product

    raw_markup_data = request.params.get(
        "markup-editor-textarea",
        # TODO: rename this column to description_raw.
        product.description,
    ).strip()

    if raw_markup_data and raw_markup_data != product.description:
        product.set_description(raw_markup_data)
        msg = ("You set the Products's Description!", "success")
        request.session.flash(msg)
        request.dbsession.add(product)
        request.dbsession.flush()
        if product.shop.watch_mode_enabled:
            reforge_discovery_ring_async(
                product.shop.id, request.registry["dbsession_factory"]
            )

    return {
        "markup_subject": f"Product Description for {product.title}",
        "markup_rendered": product.description_html,
        "markup_raw": product.description,
        "markup_form_path": f"{product.absolute_edit_url(request, subject='description')}",
    }


@view_config(route_name="product_edit", renderer="product_edit.j2")
@view_config(route_name="product_edit2", renderer="product_edit.j2")
@view_config(route_name="content_edit", renderer="product_edit.j2")
@view_config(route_name="content_edit2", renderer="product_edit.j2")
@shop_editor_required()
@trial_active_required()
def product_edit(request):
    product_modified = False
    product = request.product

    title = request.params.get("title", product.title).strip()
    description = request.params.get("description", product.description).strip()
    price = request.params.get("price", product.price)
    visibility = int(request.params.get("visibility", product.visibility))
    uploaded_file_key = request.params.get("uploaded", "")

    if uploaded_file_key:
        request.session.flash(
            (
                f"You successfully uploaded a new {uploaded_file_key} file!",
                "success",
            )
        )

    try:
        price = validate_float(price)
    except Exception as e:
        request.session.flash((f"Invalid price. {e}", "error"))
        price = product.price

    s3_webhook_key = request.params.get("key")
    s3_webhook_bucket = request.params.get("bucket")
    s3_webhook_etag = request.params.get("etag")

    if title != product.title:
        product_modified = True
        product.title = title
        request.session.flash(("You updated the product's title.", "success"))

    if description != product.description:
        product_modified = True
        product.set_description(description)
        request.session.flash(("You updated the product's description.", "success"))

    if visibility != product.visibility:
        product_modified = True
        product.set_visibility(
            visibility,
            request.shop_uploads_client,
            request.shop_bucket_name,
        )
        request.session.flash(("You updated the product's visibility.", "success"))
        # Warn if switching away from public while a torrent is already seeded.
        if visibility != 1 and product.torrent_magnet_link:
            request.session.flash((
                "Warning: this product has an active torrent. "
                "The magnet link is no longer shown to visitors, "
                "but the file persists in the BitTorrent network and cannot be recalled.",
                "warning",
            ))

    if price != product.price:
        product_modified = True
        request.dbsession.add(product.set_price(price))
        request.session.flash(("You updated the product's price.", "success"))

    # MPS-20 + MPS-21: pricing_mode + allow_offers (per-product overrides).
    # Gated on the pricing_mode field being present — it only appears on
    # the product-title-and-description edit form, so other forms on the
    # page (uploads, inventory, bundle) won't trigger this block.
    # NOTE: do NOT gate on "submit" in request.params — the edit form's
    # onsubmit handler disables the submit button before the browser
    # collects form data, so "submit" is never sent.
    if "pricing_mode" in request.params:
        try:
            new_mode = int(request.params["pricing_mode"])
        except (TypeError, ValueError):
            new_mode = product.pricing_mode
        if new_mode in (0, 1, 2, 3, 4) and new_mode != product.pricing_mode:
            product_modified = True
            product.pricing_mode = new_mode
            request.dbsession.add(product)
            request.dbsession.flush()
            mode_label = {
                0: "fixed price", 1: "auction", 2: "auction + buy now",
                3: "make an offer", 4: "make an offer + buy now",
            }[new_mode]
            request.session.flash(
                (f"Pricing mode set to {mode_label}.", "success")
            )
            # If switching INTO auction mode and no auction exists, create one
            # in DRAFT state with sane defaults from the product price.
            if new_mode in (1, 2) and product.auction is None:
                from ..models.auction import (
                    MpsAuction, DEFAULT_BID_INCREMENT_IN_CENTS,
                    DEFAULT_SOFT_CLOSE_SECONDS,
                )
                auction = MpsAuction(
                    product=product,
                    shop=product.shop,
                    start_price_in_cents=product.price_in_cents or 100,
                    bid_increment_in_cents=DEFAULT_BID_INCREMENT_IN_CENTS,
                    soft_close_seconds=DEFAULT_SOFT_CLOSE_SECONDS,
                )
                request.dbsession.add(auction)
                request.session.flash((
                    "Auction created in draft state. Configure start/end "
                    "times and reserve price below before scheduling.",
                    "success",
                ))

        # allow_offers — three-state radio: inherit / yes / no. Only
        # process when the radio is actually present in the form.
        if "allow_offers" in request.params:
            new_allow = {
                "inherit": None, "yes": True, "no": False,
            }.get(request.params["allow_offers"], None)
            if new_allow != product.allow_offers:
                product_modified = True
                product.allow_offers = new_allow

        # MPS-20: auction config — only meaningful when product has an
        # auction and the auction is still in DRAFT (locked once SCHEDULED).
        if product.auction is not None and product.auction.is_draft:
            from ..models.auction import (
                AUCTION_STATE_DRAFT, AUCTION_STATE_SCHEDULED, AUCTION_STATE_ACTIVE,
                now_timestamp as auction_now_ms,
            )
            from datetime import datetime, timezone
            auction = product.auction

            def _input_to_ms(raw):
                """datetime-local 'YYYY-MM-DDTHH:MM' → ms since epoch (UTC)."""
                if not raw:
                    return None
                try:
                    dt = datetime.strptime(raw.strip(), "%Y-%m-%dT%H:%M")
                    return int(dt.replace(tzinfo=timezone.utc).timestamp() * 1000)
                except ValueError:
                    return None

            def _dollar_to_cents(raw):
                if raw is None or raw == "":
                    return None
                try:
                    return int(round(float(raw) * 100))
                except (TypeError, ValueError):
                    return None

            # Quantity — physical only; digital forces 1.
            try:
                new_qty = int(request.params.get("auction_quantity", auction.quantity))
            except (TypeError, ValueError):
                new_qty = auction.quantity
            if not product.is_physical:
                new_qty = 1
            new_qty = max(1, new_qty)
            if new_qty != auction.quantity:
                auction.quantity = new_qty

            new_start = _input_to_ms(request.params.get("auction_start", ""))
            new_end = _input_to_ms(request.params.get("auction_end", ""))
            if new_start is not None and new_start != auction.start_timestamp:
                auction.start_timestamp = new_start
            if new_end is not None and new_end != auction.end_timestamp:
                auction.end_timestamp = new_end
                auction.original_end_timestamp = new_end

            new_start_price = _dollar_to_cents(request.params.get("auction_start_price"))
            if new_start_price is not None and new_start_price != auction.start_price_in_cents:
                auction.start_price_in_cents = max(0, new_start_price)

            # Reserve / buy-now: blank input clears.
            reserve_raw = (request.params.get("auction_reserve_price") or "").strip()
            if reserve_raw == "":
                auction.reserve_price_in_cents = None
            else:
                rp = _dollar_to_cents(reserve_raw)
                if rp is not None:
                    auction.reserve_price_in_cents = rp

            if product.pricing_mode in (2, 4):
                buy_now_raw = (request.params.get("auction_buy_now_price") or "").strip()
                if buy_now_raw == "":
                    auction.buy_now_price_in_cents = None
                else:
                    bn = _dollar_to_cents(buy_now_raw)
                    if bn is not None:
                        auction.buy_now_price_in_cents = bn
            else:
                auction.buy_now_price_in_cents = None

            new_inc = _dollar_to_cents(request.params.get("auction_increment"))
            if new_inc is not None and new_inc > 0:
                auction.bid_increment_in_cents = new_inc

            try:
                new_sc = int(request.params.get("auction_soft_close", auction.soft_close_seconds))
                auction.soft_close_seconds = max(0, new_sc)
            except (TypeError, ValueError):
                pass

            auction.updated_timestamp = auction_now_ms()

            # Schedule: flip DRAFT → SCHEDULED. Validate timestamps first.
            if request.params.get("auction_schedule") == "on":
                if not auction.start_timestamp or not auction.end_timestamp:
                    request.session.flash((
                        "Set both start and end times before scheduling the auction.",
                        "error",
                    ))
                elif auction.end_timestamp <= auction.start_timestamp:
                    request.session.flash((
                        "Auction end must be after start.",
                        "error",
                    ))
                else:
                    # If start has already passed, jump straight to ACTIVE.
                    now = auction_now_ms()
                    if auction.start_timestamp <= now:
                        auction.state = AUCTION_STATE_ACTIVE
                        request.session.flash((
                            "Auction is now ACTIVE — accepting bids.",
                            "success",
                        ))
                    else:
                        auction.state = AUCTION_STATE_SCHEDULED
                        request.session.flash((
                            "Auction scheduled. Fields locked.",
                            "success",
                        ))
                    product_modified = True
            request.dbsession.add(auction)

    # torrent_opt_in — explicit per-product seeding consent.
    # MPS-22: gated by global torrent kill switch. Gated on pricing_mode
    # being present (signals the product-edit form, not uploads/inventory)
    # rather than "submit" — see the pricing_mode block note above.
    if (
        request.torrent_enabled
        and product.shop.torrent_enabled
        and "pricing_mode" in request.params
    ):
        new_opt_in = request.params.get("torrent_opt_in", "off") == "on"
        if new_opt_in != product.torrent_opt_in:
            product_modified = True
            product.torrent_opt_in = new_opt_in
            if new_opt_in:
                request.session.flash((
                    "Torrent seeding enabled for this product. "
                    "The file will be seeded when you upload or re-upload. "
                    "Warning: once seeded, files cannot be recalled from the BitTorrent network.",
                    "warning",
                ))
            else:
                request.session.flash((
                    "Torrent seeding disabled. "
                    "Note: if already seeded, the file persists in the BitTorrent network.",
                    "success",
                ))

    if s3_webhook_key and s3_webhook_bucket and s3_webhook_etag:
        # Check if the file exists and has a non-zero size
        try:
            response = request.shop_uploads_client.head_object(
                Bucket=s3_webhook_bucket,
                Key=s3_webhook_key,
            )
            file_size = response.get("ContentLength", 0)
            if file_size == 0:
                raise ValueError("File size is zero")
        except Exception:
            request.session.flash(
                ("File upload failed. Pick a file and try again.", "error")
            )
            return HTTPFound(request.route_url("product_edit", product_id=product.id))

        # Store the uploaded user's file_metadata in our database.
        file_key = product.store_file_metadata(s3_webhook_key)

        # This is how we protect our digital downloads while using the
        # same bucket for thumbnails and previews related to the product.
        # Use the new visibility-aware ACL method
        acl = product.get_s3_acl_for_file_key(file_key)
        if product.is_not_sellable:
            acl = "public-read"

        # don't allow proxies to cache and set the max age to 2 days.
        cache_control = "private, max-age=172800"

        # copy upload to our system defined s3 location.
        # https://boto3.amazonaws.com/v1/documentation/api/latest/reference/services/s3.html#S3.Client.copy_object
        content_type = product.get_content_type(file_key) or "application/octet-stream"
        request.shop_uploads_client.copy_object(
            ACL=acl,
            Bucket=request.shop_bucket_name,
            CopySource={
                "Bucket": s3_webhook_bucket,
                "Key": s3_webhook_key,
            },
            ContentDisposition=product.get_content_disposition(file_key),
            ContentType=content_type,
            CacheControl=cache_control,
            Key=f"{product.s3_path}/{file_key}",
            MetadataDirective="REPLACE",
        )

        # Mirror to shop's custom S3 bucket if configured
        from ..lib.s3_mirror import mirror_key_async
        mirror_key_async(
            request.shop_uploads_client,
            request.shop_bucket_name,
            f"{product.s3_path}/{file_key}",
            product.shop,
            content_type=content_type,
            cache_control=cache_control,
        )

        # delete original upload key.
        request.shop_uploads_client.delete_object(
            Bucket=s3_webhook_bucket,
            Key=s3_webhook_key,
        )

        # get file size & store in our database.
        response = request.shop_uploads_client.head_object(
            Bucket=request.shop_bucket_name,
            Key=f"{product.s3_path}/{file_key}",
        )

        product_content_length = response["ContentLength"]

        tmp_file_bytes = product.file_bytes or {}
        tmp_file_bytes[file_key] = product_content_length
        product.file_bytes = tmp_file_bytes

        product.stamp_updated_timestamp()
        request.dbsession.add(product)
        request.dbsession.flush()
        if product.shop.watch_mode_enabled:
            reforge_discovery_ring_async(
                product.shop.id, request.registry["dbsession_factory"]
            )

        # Generate .torrent bundle only when:
        #  - torrent distribution is enabled for the shop
        #  - the owner explicitly opted this product in (torrent_opt_in)
        #  - the product is public (visibility == 1)
        # Torrents are permanent — files persist in the swarm once seeded.
        #
        # Trigger key:
        #  - paid products (is_sellable): fire when the PREVIEW is uploaded
        #    (the paid product file is never seeded)
        #  - free content (!is_sellable): fire when the CONTENT file is uploaded
        torrent_trigger_key = "preview" if product.is_sellable else "product"
        if (
            request.torrent_enabled
            and file_key == torrent_trigger_key
            and product.shop.torrent_enabled
            and product.torrent_opt_in
            and product.visibility == 1
        ):
            from ..lib.torrent import generate_torrent_async, build_bundle_files
            bundle_files = build_bundle_files(product, request.shop_cdn_endpoint)
            if bundle_files:
                generate_torrent_async(
                    request.shop_uploads_client,
                    request.shop_bucket_name,
                    bundle_name=product.slug or str(product.id),
                    files=bundle_files,
                    description=product.description or "",
                    s3_path=product.s3_path,
                    product_id=product.id,
                    session_factory=request.registry["dbsession_factory"],
                    cdn_endpoint=request.shop_cdn_endpoint,
                )

        # Compute MD5 + SHA-256 for every uploaded file.
        from ..lib.checksums import compute_checksums_async
        compute_checksums_async(
            request.shop_uploads_client,
            request.shop_bucket_name,
            s3_key=f"{product.s3_path}/{file_key}",
            file_key=file_key,
            product_id=product.id,
            session_factory=request.registry["dbsession_factory"],
        )

        # Generate vocal isolation tracks if this is audio/video.
        # Runs in a detached child — karaoke takes minutes and would hang
        # the response (black screen after upload) if run inline.
        # MPS-22: gated by global karaoke kill switch.
        from ..models.product import get_media_type
        from ..lib.karaoke import capture_karaoke_config, process_karaoke_detached
        upload_media_type = get_media_type(product.extensions.get(file_key))
        shop = product.shop
        if (
            request.karaoke_enabled
            and upload_media_type in ("video", "audio")
            and shop.unsandbox_public_key
            and shop.unsandbox_secret_key
        ):
            ext = product.extensions.get(file_key)
            process_karaoke_detached(
                product_id=product.id,
                file_key=file_key,
                extension=ext,
                s3_path=product.s3_path,
                karaoke_config=capture_karaoke_config(shop, request.app),
                db_url=str(request.dbsession.get_bind().url),
            )

        # redirect back to this page to clear
        # the params posted by the s3 webhooks.
        return HTTPFound(f"{product.absolute_edit_url(request)}?uploaded={file_key}")

    if product_modified and product.error_message is None:
        request.dbsession.add(product)
        request.dbsession.flush()
        if product.shop.watch_mode_enabled:
            reforge_discovery_ring_async(
                product.shop.id, request.registry["dbsession_factory"]
            )
        return HTTPFound(product.absolute_edit_url(request))

    signed_posts = {}

    for file_key in product.file_keys:
        key_starts_with = f"{product.s3_path}/{file_key}."
        conditions = [
            {"success_action_redirect": product.absolute_edit_url(request)},
            ["starts-with", "$key", key_starts_with],
        ]

        signed_posts[file_key] = request.shop_uploads_client.generate_presigned_post(
            Bucket=request.shop_bucket_name,
            Key=key_starts_with + "${filename}",
            ExpiresIn=900,
            Conditions=conditions,
        )

        # We must create a field key/value for each extra condition.
        # unfortunately `conditions` is a list of single key/value dicts
        # and/or 2/3 item lists so we flatten them into a single fields dict.
        # [{1:2}, [3,4]] -> {1:2, 3:4}
        for condition in conditions:
            if isinstance(condition, list):
                if len(condition) == 2:
                    # convert list into dict.
                    condition = {condition[0]: condition[1]}
                else:
                    continue
            signed_posts[file_key]["fields"].update(condition)

    # Fetch locations and inventory quantities for physical products
    locations = []
    if product.is_physical:
        locations = (
            request.dbsession.query(ShopLocation)
            .filter_by(shop_id=product.shop_id)
            .all()
        )
        inventories = {
            inv.shop_location.id: inv.quantity for inv in product.inventories
        }

    price_history = []
    if product.is_sellable:
        history_rows = product.price_history.limit(20).all()
        for i, ph in enumerate(history_rows):
            price_history.append({
                "price_formatted": f"${cents_to_dollars(ph.price_in_cents):.2f}",
                "ago": timestamp_to_ago_string(ph.created_timestamp),
                "datetime": str(timestamp_to_datetime(ph.created_timestamp)),
                "is_current": i == 0,
            })

    signed_product_url = None
    if product.has_product_file:
        try:
            params = {
                "Bucket": request.shop_bucket_name,
                "Key": product.s3_key,
                "ResponseContentDisposition": f"inline; filename={product.slug}.{product.extensions.get('product', '')}",
            }
            content_type = product.get_content_type("product")
            if content_type:
                params["ResponseContentType"] = content_type
            signed_product_url = request.shop_uploads_client.generate_presigned_url(
                ClientMethod="get_object",
                Params=params,
                ExpiresIn=900,
            )
        except Exception:
            pass

    # MPS-20: format auction timestamps for datetime-local input.
    def _ms_to_input(ts_ms):
        if not ts_ms:
            return ""
        from datetime import datetime, timezone
        return datetime.fromtimestamp(ts_ms / 1000, tz=timezone.utc).strftime(
            "%Y-%m-%dT%H:%M",
        )

    return {
        "product": product,
        "title": title,
        "description": description,
        "price": price,
        "visibility": visibility,
        "signed_posts": signed_posts,
        "signed_product_url": signed_product_url,
        "locations": locations,
        "inventories": inventories if product.is_physical else {},
        "price_history": price_history,
        "auction_start_iso": (
            _ms_to_input(product.auction.start_timestamp)
            if product.auction else ""
        ),
        "auction_end_iso": (
            _ms_to_input(product.auction.end_timestamp)
            if product.auction else ""
        ),
        # MPS-22: torrent kill switch hides all torrent context from edit UI
        "torrent_enabled": (
            product.shop.torrent_enabled if request.torrent_enabled else False
        ),
        "torrent_opt_in": (
            product.torrent_opt_in if request.torrent_enabled else False
        ),
        "torrent_magnet_link": (
            (product.torrent_magnet_link or "") if request.torrent_enabled else ""
        ),
        "torrent_file_url": (
            (product.torrent_file_url or "") if request.torrent_enabled else ""
        ),
        "torrent_seeded_but_private": bool(
            request.torrent_enabled
            and product.torrent_magnet_link
            and product.visibility != 1
        ),
    }


@view_config(route_name="product_inventory_edit")
@shop_editor_required()
def product_inventory_edit(request):
    product = request.product

    if not product.is_physical:
        request.session.flash(("This product is not physical.", "error"))
        return HTTPFound(get_referer_or_home(request))

    # Get all shop locations for the product's shop
    shop_locations = request.shop.shop_locations

    updated = False

    for location in shop_locations:
        quantity = request.params.get(f"inventory_{location.id}", None)
        if quantity is not None:
            try:
                quantity = int(quantity)
                inventory = next(
                    (
                        inv
                        for inv in product.inventories
                        if inv.shop_location_id == location.id
                    ),
                    None,
                )
                if inventory:
                    if inventory.quantity != quantity:
                        inventory.update_quantity(quantity)
                        request.dbsession.add(inventory)
                        updated = True
                else:
                    inventory = Inventory(
                        shop_location=location, product=product, quantity=quantity
                    )
                    request.dbsession.add(inventory)
                    updated = True
            except ValueError:
                request.session.flash(
                    (f"Invalid quantity for location {location.name}.", "error")
                )

    if updated:
        request.dbsession.flush()
        request.session.flash(("Inventory updated successfully.", "success"))

    return HTTPFound(get_referer_or_home(request))


@view_config(route_name="product_bundle_edit")
@view_config(route_name="product_bundle_edit2")
@shop_editor_required()
def product_bundle_edit(request):
    from ..models.product import get_products_by_ids

    from ..lib.uuid_validation import UUID_ALL_PATTERN

    product = request.product

    if not product.is_bundle:
        request.session.flash(
            ("You may only add products to a Product bundle.", "error")
        )

    bundle_data = request.params.get("bundle-data", "").strip()

    if bundle_data:
        products = get_products_by_ids(
            request.dbsession, UUID_ALL_PATTERN.findall(bundle_data)
        )

        if products is None:
            request.session.flash(
                ("We didn't detect any product ids. Try again.", "error")
            )
            return HTTPFound(get_referer_or_home(request))

        for product_to_bundle in products:
            if not product.add_product_to_bundle(product_to_bundle):
                request.session.flash((product.error_message, "error"))
                product.error_message = None
                continue

            request.session.flash(
                (
                    f"You added product {product_to_bundle.title} to this bundle!",
                    "success",
                )
            )

    return HTTPFound(get_referer_or_home(request))


@view_config(route_name="product_remove_bundled_product")
@shop_editor_required()
def product_bundle_remove_product(request):
    from ..models.product import get_product_by_id

    product = request.product

    bundled_product_id = request.matchdict.get("bundled_product_id")
    bundled_product = get_product_by_id(request.dbsession, bundled_product_id)

    if not product.is_bundle:
        request.session.flash(
            ("You may only remove products from a Product bundle.", "error")
        )

    if not product.remove_product_from_bundle(bundled_product):
        request.session.flash((product.error_message, "error"))
    else:
        request.session.flash(("You removed a product from this bundle!", "success"))

    return HTTPFound(get_referer_or_home(request))
