# === How to Run These Unit Tests ===
# 1. Open a terminal (command line).
# 2. Change the current directory to the project root.
# 3. Run:
#       python -m unittest tests.test_ebay_rest
#
# Notes:
# - Avoid repeating the same API call with identical parameters, as eBay may return an "Internal Server Error."
# - Use the eBay sandbox environment whenever possible instead of production.
# - If sandbox tests fail unexpectedly, don't panic — eBay's sandbox had is unreliable. Try again later.

# Standard library imports
import datetime
from copy import deepcopy
from functools import lru_cache
from json import load as json_load
import os
import random
import string
from typing import Dict, Any, Optional, Tuple
import unittest
from urllib.parse import urlparse

# 3rd party libraries
from currency_converter import CurrencyConverter

# Local imports
from src.ebay_rest import API, DateTime, Error, Reference
from src.ebay_rest.token import ApplicationToken, UserToken


class Credentials:
    """
    A helper class to cache credentials from an ebay_rest.json file.
    """

    def __init__(self, path: Optional[str] = None, file: str = "ebay_rest.json"):
        if path is None:
            path = os.getcwd()
        path = os.path.abspath(path)
        path_file = os.path.join(path, file)
        self._credentials = type(self)._caching_loader(path_file)

    @classmethod
    @lru_cache(maxsize=5)
    def _caching_loader(cls, path_file: str) -> Dict[str, Any]:
        with open(path_file, "r", encoding="utf-8") as f:
            return json_load(f)

    def instruction_names(self) -> Tuple[str, ...]:
        return tuple(self._credentials["***_instructions_***"].keys())

    def get_instruction(self, name: str) -> Dict[str, Any]:
        return self._credentials["***_instructions_***"][name]

    def application_names(self) -> Tuple[str, ...]:
        return tuple(self._credentials["applications"].keys())

    def get_application(self, name: str) -> Dict[str, Any]:
        return self._credentials["applications"][name]

    def user_names(self) -> Tuple[str, ...]:
        return tuple(self._credentials["users"].keys())

    def get_user(self, name: str) -> Dict[str, Any]:
        return self._credentials["users"][name]

    def header_names(self) -> Tuple[str, ...]:
        return tuple(self._credentials["headers"].keys())

    def get_header(self, name: str) -> Dict[str, Any]:
        return self._credentials["headers"][name]

    def get_key_pair(self, name: str) -> Dict[str, Any]:
        return self._credentials["key_pairs"][name]

    def key_pair_names(self) -> Tuple[str, ...]:
        return tuple(self._credentials["key_pairs"].keys())

    def root_names(self) -> Tuple[str, ...]:
        return tuple(self._credentials.keys())


class CredentialTests(unittest.TestCase):

    def test_credential_files(self):
        self._check_credential_file("ebay_rest_EXAMPLE.json")
        self._check_credential_file("ebay_rest.json")

    def _check_credential_file(self, filename):
        """
        Check if a credential file is structured correctly.
        """
        try:
            creds = Credentials(file=filename)
        except Error as error:
            self.fail(error)

        root_names = creds.root_names()

        # Are only the expected root keys present?
        self.assertEqual(
            set(root_names),
            {
                "***_instructions_***",
                "applications",
                "users",
                "headers",
                "key_pairs",
            },
        )

        # Does the example file have instructions? Are all instructions numbered and textual?
        instruction_names = creds.instruction_names()
        if filename == "ebay_rest_EXAMPLE.json":
            self.assertGreater(
                len(instruction_names), 0, f"No instructions in {filename}."
            )
        for name in instruction_names:
            try:
                int(name)
            except ValueError:
                self.fail(
                    f"In {filename} the instruction key {name} is not string representing an integer."
                )
            self.assertIsInstance(
                creds.get_instruction(name),
                str,
                f"In {filename} the value for instruction key {name} is not a string.",
            )

        # Does the application section have all the required keys?
        application_names = creds.application_names()
        self.assertGreater(len(application_names), 0, f"No applications in {filename}.")
        for name in application_names:
            application = creds.get_application(name)
            self.assertEqual(
                set(application.keys()),
                {"app_id", "cert_id", "dev_id", "redirect_uri"},
                f"In {filename} the application {name} does not have the expected keys.",
            )

        # Does the user section have all the required keys?
        user_names = creds.user_names()
        self.assertGreater(len(user_names), 0, f"No users in {filename}.")
        for name in user_names:
            user = creds.get_user(name)
            self.assertTrue(
                {
                    "email_or_username",
                    "password",
                    "refresh_token",
                    "refresh_token_expiry",
                }.issubset(set(user.keys())),
                f"In {filename} the user {name} is missing some required keys.",
            )

        # Does the header section have all the required keys?
        header_names = creds.header_names()
        self.assertGreater(len(header_names), 0, f"No headers in {filename}.")
        for name in header_names:
            header = creds.get_header(name)
            self.assertEqual(
                set(header.keys()),
                {
                    "accept_language",
                    "affiliate_campaign_id",
                    "affiliate_reference_id",
                    "content_language",
                    "country",
                    "currency",
                    "device_id",
                    "marketplace_id",
                    "zip",
                },
                f"In {filename} the header {name} does not have the expected keys.",
            )

        # Does the key_pair section have all the required keys?
        key_pair_names = creds.key_pair_names()
        self.assertGreater(len(key_pair_names), 0, f"No key_pairs in {filename}.")
        for name in key_pair_names:
            key_pair = creds.get_key_pair(name)
            self.assertEqual(
                set(key_pair.keys()),
                {"private_key", "signing_key_id"},
                f"In {filename} the key_pair {name} does not have the expected keys.",
            )

    def test_required_accounts(self):
        """
        Test the accounts that most other unit tests need. Are they present and well-formed?
        """
        creds = Credentials()
        for name in ("sandbox_1", "production_1"):
            self.assertTrue(
                name in creds.user_names(),
                msg=f"The file ebay_rest.json is missing user: {name}",
            )
            user = creds.get_user(name)
            message = (
                f"In ebay_rest.json for user {name} the refresh_token and expiry should not be empty."
                + " For instructions see the FAQ in README.md about avoiding browser pop-ups"
            )
            self.assertNotEqual(user["refresh_token"], "", message)
            self.assertNotEqual(user["refresh_token_expiry"], "", message)

    def test_credentials_from_dicts(self):
        """
        set credentials via dicts
        """

        try:
            creds = Credentials()
            application = creds.get_application("sandbox_1")
            user = creds.get_user("sandbox_1")
            header = creds.get_header("US")
            key_pair = creds.get_key_pair("sandbox_1")
        except KeyError as e:
            self.fail(
                f"The ebay_rest.json file is missing an essential application, user or header record {e}"
            )

        # use good credentials
        try:
            # demonstrate one way to supply dicts during instantiation
            api = API(
                application=application, user=user, header=header, key_pair=key_pair
            )
        except Error as error:
            self.fail(error)
        else:
            self.assertIsInstance(api, API, "An API object was not returned.")

        # try each credential that when bad should raise an exception
        sensitive_params = {
            # "application": ("placeholder",),
            "user": ("refresh_token_expiry",),
            "header": (
                "accept_language",
                "content_language",
                "country",
                "currency",
                "marketplace_id",
            ),
            # "key_pair": ("placeholder",),
        }
        good_config = {
            "user": user,
            "application": application,
            "header": header,
            "key_pair": key_pair,
        }
        for key in sensitive_params:
            for sub_key in sensitive_params[key]:
                bad_config = deepcopy(good_config)
                if sub_key in bad_config[key]:
                    bad_config[key][sub_key] += "@bad"
                    with self.assertRaises(
                        Error,
                        msg=f"No error raised on {key} {sub_key} with value {bad_config[key][sub_key]}",
                    ):
                        # demonstrate another way to supply dicts during instantiation
                        API(**bad_config)

    def test_credential_path(self):
        """
        supply a path to ebay_rest.json
        """
        try:
            api = API(
                path=os.getcwd(), application="sandbox_1", user="sandbox_1", header="US"
            )
        except Error as error:
            self.fail(error)
        else:
            self.assertIsInstance(api, API, "An API object was not returned.")


class APIBothEnvironmentsSingleSiteTests(unittest.TestCase):
    def test_start(
        self,
    ):
        try:
            # all initialization options and for each the first and second usage
            for environment in ("production", "sandbox"):
                for throttle, timeout in ((False, None), (True, None), (True, 60.0)):

                    if timeout is None:
                        api = API(
                            application=environment + "_1",
                            user=environment + "_1",
                            header="US",
                            throttle=throttle,
                        )
                    else:
                        api = API(
                            application=environment + "_1",
                            user=environment + "_1",
                            header="US",
                            throttle=throttle,
                            timeout=timeout,
                        )
                    item_id = None
                    for record in api.buy_browse_search(limit=1, q="lego"):
                        if "record" not in record:
                            self.assertTrue(
                                "total" in record, f"Unexpected non-record{record}."
                            )
                        else:
                            item = record["record"]
                            self.assertTrue(isinstance(item["item_id"], str))
                            item_id = item["item_id"]
                            break
                    if item_id:
                        item = api.buy_browse_get_item(item_id=item_id)
                        self.assertTrue(isinstance(item, dict))
        except Error as error:
            self.fail(error)


class APIMarketplaceSpecificTests(unittest.TestCase):

    def test_belgium_nederlandse(self):
        # noinspection SpellCheckingInspection
        api = API(application="production_1", user="production_1", header="NLBE")
        for record in api.buy_browse_search(limit=1, q="black"):
            if "record" not in record:
                self.assertGreater(
                    record["total"]["records_yielded"],
                    0,
                    "No item from Belgium found; try a different q value.",
                )
            else:
                item = record["record"]
                host = urlparse(item["item_web_url"]).hostname
                self.assertEqual(host, "www.benl.ebay.be")  # noqa: typo


class APISandboxMultipleSiteTests(unittest.TestCase):
    """
    API tests that require multiple marketplaces and that can be done on the sandbox.
    """

    @classmethod
    def setUpClass(cls):
        cls.currency_converter = CurrencyConverter()  # this is slow, so do it once

    def test_object_reuse(self):
        """
        Do the same parameters return the same API object?
        """
        a1 = API(application="sandbox_1", user="sandbox_1", header="ENCA")
        a2 = API(application="sandbox_1", user="sandbox_1", header="ENCA")
        b = API(application="sandbox_1", user="sandbox_1", header="GB")
        self.assertEqual(a1, a2)
        self.assertNotEqual(a1, b)

    def test_shipping_accuracy(self):
        """
        Is closer shipping less expensive?
        """

        # domestic
        # d_market = 'EBAY_CA'    # set in header
        d_country = "CA"
        d_currency = "CAD"
        # d_zip = 'K1M 1M4'         # set in header

        # foreign
        # f_market = 'EBAY_GB'      # set in header
        f_country = "GB"
        # f_currency = 'GBP'
        # f_zip = 'SW1A 1AA'        # set in header

        # more constants
        shipping_currency = "USD"

        tally = 0  # how many times domestic shipping is less expensive than foreign

        d_api = API(application="production_1", user="production_1", header="ENCA")
        f_api = API(application="production_1", user="production_1", header="GB")

        # in the local marketplace, find items located locally that also ship to foreign
        # low-priced items are targeted because they are unlikely to have free shipping
        # black items are targeted because they are plentiful, and the q parameter is mandatory
        try:
            filter_ = (
                f"deliveryCountry:{f_country},itemLocationCountry:{d_country},price:[1..2],"
                f"priceCurrency:{d_currency} "
            )

            for record in d_api.buy_browse_search(q="black", filter=filter_, limit=10):
                if "record" not in record:
                    self.assertTrue(
                        "total" in record, f"Unexpected non-record{record}."
                    )
                else:
                    item = record["record"]
                    item_id = item["item_id"]
                    if item["item_location"]["country"] == d_country:
                        # get the lowest domestic shipping cost
                        try:
                            d_item = d_api.buy_browse_get_item(
                                item_id=item_id, fieldgroups="PRODUCT"
                            )
                        except Error as error:
                            self.fail(error)
                        d_shipping = self._get_lowest_shipping(
                            d_item, d_country, shipping_currency
                        )

                        # get the lowest foreign shipping cost
                        try:
                            f_item = f_api.buy_browse_get_item(
                                item_id=item_id, fieldgroups="PRODUCT"
                            )
                        except Error as error:
                            self.fail(error)
                        f_shipping = self._get_lowest_shipping(
                            f_item, f_country, shipping_currency
                        )

                        # compare costs, inconclusive when equal or some None
                        if (d_shipping is not None) and (f_shipping is not None):
                            if d_shipping < f_shipping:
                                tally += 1  # what we would expect most of the time
                            elif d_shipping > f_shipping:
                                tally -= (
                                    1  # very rare, put a break point here find trouble
                                )
                            else:
                                tally = (
                                    tally  # flat rate worldwide shipping is possible
                                )
                        else:
                            pass  # self.fail(f"For {item_id} both shipping costs can't be found.")
                    else:
                        self.fail(
                            f"Item {item_id} is supposed to be located in {d_country}."
                        )

        except Error as error:
            self.fail(error)

        self.assertTrue(tally > 0, "Domestic shipping should cost less than foreign.")

    def _get_lowest_shipping(self, item: dict, country: str, currency: str):
        costs = []

        # is shipping excluded?
        ship_to_locations = item["ship_to_locations"]
        if ship_to_locations:
            if self._in_region(ship_to_locations["region_excluded"] or [], country):
                return None
            # if not in either list, then eBay concludes that shipping is allowed, so skip the following
            # elif not self._in_region(ship_to_locations['region_included'], country):
            # return None

        # find all costs
        if not item["shipping_options"]:
            return None
        for shipping_option in item["shipping_options"]:
            shipping_cost = shipping_option["shipping_cost"]
            if shipping_cost["currency"] == currency:
                cost = float(shipping_cost["value"])
            elif shipping_cost["converted_from_currency"] == currency:
                cost = float(shipping_cost["converted_from_value"])
            else:
                cost = self.currency_converter.convert(
                    float(shipping_cost["value"]), shipping_cost["currency"], currency
                )
            if cost is not None:
                costs.append(cost)
            else:
                self.fail("A shipping cost, without a cost, should not be possible.")

        if costs:
            costs.sort()
            return costs[0]
        else:
            self.fail("Shipping options without any options, should not be possible.")

    def _in_region(self, regions: dict, country: str):
        if country in ("CA", "US"):
            region_ids = ("AMERICAS", "NORTH_AMERICA")
        elif country in ("GB",):
            region_ids = ("EUROPE",)
        else:
            self.fail("add region ids for your country")

        for region in regions:
            region_type = region["region_type"]
            if region_type == "COUNTRY":
                if region["region_id"] == country:
                    return True
            elif region_type == "WORLD_REGION":
                if region["region_id"] in region_ids:
                    return True
            elif region_type == "WORLDWIDE":
                return True
        return False


class APISandboxSingleSiteTests(unittest.TestCase):
    """
    API tests that can be done on a single marketplace and in the sandbox.
    Also, try the non-default option of making asynchronous HTTP requests.
    """

    @classmethod
    def setUpClass(cls):
        # TODO Change to async_req=True.
        cls._api = API(
            application="sandbox_1", user="sandbox_1", header="US", async_req=False
        )
        cls.marketplace_id = cls._api._header["marketplace_id"]

    def test_paging_no_results(self):
        try:
            for record in self._api.buy_browse_search(
                q="atomic-donkey-kick--this-will-not-be-found-Geraldine"
            ):
                if "record" not in record:
                    self.assertTrue(
                        "total" in record, f"Unexpected non-record{record}."
                    )
                    self.assertEqual(record["total"]["records_yielded"], 0)
                    self.assertEqual(record["total"]["records_available"], 0)
                else:
                    item = record["record"]
                    self.assertTrue(isinstance(item["item_id"], str), "Malformed item.")
                    self.fail(msg="No items should be returned.")
        except Error as error:
            self.fail(error)

    def test_positional_none_kw_none(self):
        """
        Try a call with no positional arguments and no keyword arguments.
        """
        # https://developer.ebay.com/api-docs/sell/compliance/resources/listing_violation_summary/methods/getListingViolationsSummary
        try:
            result = self._api.sell_compliance_get_listing_violations_summary(
                x_ebay_c_marketplace_id=self.marketplace_id
            )
        except Error as error:
            self.fail(error)
        else:
            if result is not None:  # If there are listing violations
                self.assertIn(
                    "violation_summaries", result
                )  # the dict should have this key

    def test_positional_some_kw_none(self):
        # https://developer.ebay.com/api-docs/commerce/taxonomy/resources/category_tree/methods/getDefaultCategoryTreeId
        try:
            result = self._api.commerce_taxonomy_get_default_category_tree_id("EBAY_US")
        except Error as error:
            self.fail(error)
        else:
            self.assertTrue(
                "category_tree_id" in result,
                msg="A call with some positional and no kw arguments failed.",
            )

    def test_positional_none_kw_some(self):
        try:
            for record in self._api.buy_browse_search(q="red"):
                if "record" in record:
                    item = record["record"]
                    self.assertTrue(
                        "item_id" in item,
                        msg="A call with some positional and no kw arguments failed.",
                    )
                    break
                else:
                    self.assertTrue(
                        "total" in record, f"Unexpected non-record{record}."
                    )
        except Error as error:
            self.fail(error)

    def test_positional_some_kw_some(self):
        # This is redundant, several unit tests call buy_browse_search, so this test case is well covered.
        pass

    def test_try_except_else_api(self):
        """
        Test that an exception occurs when expected.
        """
        try:
            self._api.buy_browse_get_item(item_id="invalid")
        except Error as error:
            self.assertIsNotNone(error)
        else:
            self.assertIsNotNone(None, msg="Failed to raise an exception.")

    def test_buying_options(self):
        """
        Does buying option filtering work?
        """
        options = [
            "BEST_OFFER",
            "FIXED_PRICE",
        ]  # on Production 'AUCTION' and 'CLASSIFIED_AD' are also options
        for option in options:
            try:
                success = None
                keywords = "black"
                filter_ = "buyingOptions:{" + option + "}"
                for record in self._api.buy_browse_search(
                    q=keywords, filter=filter_, limit=500
                ):
                    if "record" not in record:
                        self.assertTrue(
                            "total" in record, f"Unexpected non-record{record}."
                        )
                    else:
                        item = record["record"]
                        if option in item["buying_options"]:
                            success = True
                        else:
                            success = False
                            break
            except Error as error:
                self.fail(error)
            else:
                message = (
                    "The search for "
                    + option
                    + " items returned no items or a non-auction item."
                )
                self.assertTrue(success, message)

    def test_get_transaction_summary(self):
        """
        https://developer.ebay.com/api-docs/sell/finances/resources/transaction/methods/getTransactionSummary
        """
        try:
            result = self._api.sell_finances_get_transaction_summary(
                filter="transactionStatus:{PAYOUT}",
                x_ebay_c_marketplace_id=self.marketplace_id,
            )
        except Error as error:
            self.fail(error)
        else:
            self.assertTrue("credit_count" in result)

    @unittest.skip  # TODO, insufficient permissions.
    def test_sell_feed_create_inventory_task(self):
        """
        https://developer.ebay.com/api-docs/sell/feed/resources/inventory_task/methods/createInventoryTask

        The underlying REST call returns the new id in the header, which can't be gotten with the combination of Swagger
        and OpenAPI contract which we rely on here. A workaround uses a related call to retrieve task_id_new.
        """

        try:
            feed_type = "LMS_ACTIVE_INVENTORY_REPORT"
            body = {"schemaVersion": "1.0", "feedType": feed_type}
            task_ids_pre = self.get_task_ids(feed_type=feed_type)

            result = self._api.sell_feed_create_inventory_task(
                content_type="application/json", body=body
            )
            self.assertIsNone(
                result,
                "eBay made a change, the call now returns something, can the work around go?",
            )
            task_ids_post = self.get_task_ids(feed_type=feed_type)
            task_ids_new = task_ids_post.difference(task_ids_pre)
            self.assertEqual(
                1,
                len(task_ids_new),
                "In this test, one and only one task id is expected.",
            )
            # task_id_new = task_ids_new.pop()    # the new task id that we worked so hard to get

        except Error as error:
            self.fail(error)
        except Exception as ex:
            self.fail(f"An unexpected error occurred: {str(ex)}")

    def get_task_ids(self, feed_type: str):
        """
        https://developer.ebay.com/api-docs/sell/feed/resources/inventory_task/methods/getInventoryTasks

        :param feed_type: (string)
        :return: task_ids (set(string))
        """
        task_ids = set()
        for record in self._api.sell_feed_get_inventory_tasks(
            feed_type=feed_type, look_back_days="1"
        ):
            if "record" in record:
                task_ids.add(record["record"]["task_id"])
            elif "total" in record:
                self.assertEqual(
                    record["total"]["records_yielded"],
                    record["total"]["records_available"],
                    "We missed some data - narrow the search!",
                )
            else:
                self.fail(f"unexpected record {record}")
        return task_ids

    def test_sell_commerce_media_create_image_from_file(self):
        """
        https://developer.ebay.com/api-docs/commerce/media/resources/image/methods/createImageFromFile
        """
        try:
            result = self._api.commerce_media_create_image_from_file(
                content_type="multipart/form-data",
                files={"image": self.get_upload_sample_path_file("image.jpg")},
            )
        except Error as error:
            self.fail(error)
        else:
            self.assertIn("expiration_date", result)
            self.assertIn("image_url", result)

    def test_sell_feed_upload_file(self):
        """
        https://developer.ebay.com/api-docs/sell/feed/resources/task/methods/uploadFile

        This test first creates a task using createTask to get a valid task_id,
        then uploads the file using that ID.

        See: https://developer.ebay.com/api-docs/sell/feed/resources/task/methods/createTask
        The createTask method returns a taskId in the Location header.
        """
        try:
            # First, create a task to get a valid task_id
            # See: https://developer.ebay.com/api-docs/sell/feed/resources/task/methods/createTask
            # Use LMS_ORDER_ACK feed type for upload tasks
            feed_type = "LMS_ORDER_ACK"
            body = {"feedType": feed_type, "schemaVersion": "1.0"}

            # Get task IDs before creating
            task_ids_pre = set()
            for record in self._api.sell_feed_get_tasks(
                feed_type=feed_type, look_back_days="1"
            ):
                if "record" in record:
                    task_ids_pre.add(record["record"]["task_id"])

            # Create the task
            self._api.sell_feed_create_task(
                body=body,
                content_type="application/json",
                x_ebay_c_marketplace_id="EBAY_US",
            )

            # Get task IDs after creating and find the new one
            task_ids_post = set()
            for record in self._api.sell_feed_get_tasks(
                feed_type=feed_type, look_back_days="1"
            ):
                if "record" in record:
                    task_ids_post.add(record["record"]["task_id"])

            task_ids_new = task_ids_post.difference(task_ids_pre)
            self.assertEqual(
                1, len(task_ids_new), "Expected exactly one new task to be created"
            )
            task_id = task_ids_new.pop()

            # Now upload the file using the task_id from createTask
            # See: https://developer.ebay.com/api-docs/sell/feed/resources/task/methods/uploadFile
            result = self._api.sell_feed_upload_file(
                task_id=task_id,
                content_type="multipart/form-data",
                files={"file": self.get_upload_sample_path_file("data.xml")},
            )
        except Error as error:
            self.fail(error)
        else:
            self.assertIsNotNone(result)

    # TODO As of 2025-11-12, the Fulfillment API is in Beta - it is glitchy, so try again later.
    # If the second step, the actual upload, fails, then see https://github.com/matecsaj/ebay_rest/issues/60.
    # @unittest.skip
    def test_sell_fulfillment_upload_evidence_file(self):
        """
        First, get a valid payment_dispute_id,
        Second, upload the evidence, an image file, using that ID
        Third, verify

        See: https://developer.ebay.com/api-docs/sell/fulfillment/resources/payment_dispute/methods/getPaymentDisputeSummaries
        See: https://developer.ebay.com/api-docs/sell/fulfillment/resources/payment_dispute/methods/uploadEvidenceFile
        """
        # get the first payment_dispute_id
        payment_dispute_id = None
        for record in self._api.sell_fulfillment_get_payment_dispute_summaries():
            if "record" not in record:
                self.assertTrue("total" in record, f"Unexpected non-record{record}.")
            else:
                item = record["record"]
                self.assertTrue(isinstance(item["payment_dispute_id"], str))
                payment_dispute_id = item["payment_dispute_id"]
                break
        if payment_dispute_id is None:
            self.fail("No payment dispute found")

        try:
            result = self._api.sell_fulfillment_upload_evidence_file(
                payment_dispute_id=payment_dispute_id,  # unique identification string
                content_type="multipart/form-data",
                files={
                    "image": self.get_upload_sample_path_file("image.jpg")
                },  # try "file" instead of "image"
            )
        except Error as error:
            self.fail(error)
        else:
            self.assertIn("credit_count", result)

    @staticmethod
    def get_upload_sample_path_file(file_name: str) -> str:
        """
        Get the full path to a sample file for upload testing.

        :param file_name: The name of the file in the upload_samples directory

        :return: The absolute path to the upload sample file
        """
        return os.path.join(os.path.dirname(__file__), "upload_samples", file_name)

    def test_commerce_media_upload_video(self):
        """
        First get a valid video_id using create_video, upload the video file (upload_video) using that ID, then verify info using get_video

        See: https://developer.ebay.com/api-docs/commerce/media/resources/video/methods/createVideo
        See: https://developer.ebay.com/api-docs/commerce/media/resources/video/methods/uploadVideo
        """
        try:
            create_body = {
                "classification": ["ITEM"],
                "description": "test video description",
                "size": os.path.getsize(self.get_upload_sample_path_file("video.mp4")),
                "title": "test video title",
            }
            # Call with _return_http_data_only=False to get headers
            _api_response, _status, headers = self._api.commerce_media_create_video(
                content_type="application/json",
                body=create_body,
                _return_http_data_only=False,
            )

            # Extract video_id from Location header Format: /commerce/media/v1_beta/video/{video_id} or full URL
            video_id = None
            if headers:
                location_header = headers.get("Location")
                if location_header:
                    parts = location_header.rstrip("/").split("/")
                    if parts:
                        video_id = parts[-1]

            self.assertIsNotNone(
                video_id, "Failed to get videoId from create_video Location header"
            )
            # Now upload the video file using the video_id from createVideo
            result = self._api.commerce_media_upload_video(
                content_type="application/octet-stream",
                video_id=video_id,
                files={"file": self.get_upload_sample_path_file("video.mp4")},
            )

            # Verify upload succeeded by calling get_video to check video status
            # upload_video returns None, so we need to get the video info to verify it was uploaded
            video_info = self._api.commerce_media_get_video(video_id=video_id)

        except Error as error:
            self.fail(error)
        else:
            self.assertIsNone(result, "upload_video should return None on success")
            self.assertIsNotNone(
                video_info, "get_video should return video information"
            )
            # Check that video_info has the expected structure
            if isinstance(video_info, dict):
                self.assertIn("video_id", video_info, "Video ID should be present")
                self.assertIn("status", video_info, "Video status should be present")
                self.assertIn("title", video_info, "Video title should be present")
                self.assertIn("size", video_info, "Video size should be present")

    @unittest.skip  # TODO I don't know if this works, eBay has not granted my account the permission to use it.
    def test_commerce_message_get_conversations(self):
        """
        https://developer.ebay.com/api-docs/commerce/message/resources/conversation/methods/getConversations
        """
        try:
            for record in self._api.commerce_message_get_conversations(
                conversation_type="FROM_EBAY", reference_type="LISTING"
            ):
                if "record" in record:
                    item = record["record"]
                    self.assertIsNotNone(item)
        except Error as error:
            self.fail(error)

    @unittest.skip  # TODO I don't know if this works, eBay has not granted my account the permission to use it.
    def test_commerce_vero_get_vero_reason_codes(self):
        """
        https://developer.ebay.com/api-docs/commerce/vero/resources/vero_reason_code/methods/getVeroReasonCodes

        Important! You must be a member of the Verified Rights Owner (VeRO) Program to use this call.
        """
        try:
            result = self._api.commerce_vero_get_vero_reason_codes(
                x_ebay_c_marketplace_id="EBAY_US"
            )
        except Error as error:
            self.fail(error)
        else:
            self.assertIsNotNone(result)

    @unittest.skip  # TODO I don't know if this works, eBay has not granted my account the permission to use it.
    def test_sell_e_delivery_international_shipping_get_address_preferences(self):
        """
        https://developer.ebay.com/api-docs/sell/leads/resources/classified_lead/methods/getAllClassifiedLeads

        Important! This method is only available for sellers in Greater China with an active eDIS account.
        https://edis.ebay.com/login
        """
        try:
            result = (
                self._api.sell_edelivery_international_shipping_get_address_preferences()
            )
        except Error as error:
            self.fail(error)
        else:
            self.assertIsNotNone(result)

    @unittest.skip  # TODO I don't know if this works, eBay has not granted my account the permission to use it.
    def test_sell_leads_get_all_classified_leads(self):
        """
        https://developer.ebay.com/api-docs/sell/leads/resources/classified_lead/methods/getAllClassifiedLeads
        """
        try:
            result = self._api.sell_leads_get_all_classified_leads(
                content_type="application/json"
            )
        except Error as error:
            self.fail(error)
        else:
            self.assertIsNotNone(result)

    @unittest.skip  # TODO I don't know if this works, eBay has not granted my account the permission to use it.
    def test_sell_stores_get_store(self):
        """
        https://developer.ebay.com/api-docs/sell/stores/resources/store/methods/getStore
        """
        try:
            result = self._api.sell_stores_get_store()
        except Error as error:
            self.fail(error)
        else:
            self.assertIsNotNone(result)


class APIProductionSingleTests(unittest.TestCase):
    """
    API tests that can be done on a single production marketplace.
    """

    @classmethod
    def setUpClass(cls):
        cls._api = API(application="production_1", user="production_1", header="US")

    def test_complex_filtering(self):
        """
        Does complex filtering work?

        Use the production environment because it has vastly more items to work with
        and some filters are not available in the sandbox.
        """
        filters = []
        buying_option = "FIXED_PRICE"  # options are 'AUCTION', 'BEST_OFFER', 'CLASSIFIED_AD', 'FIXED_PRICE'
        filters.append("buyingOptions:{" + buying_option + "}")
        condition_option = "New"
        filters.append("itemCondition:{" + condition_option + "}")
        currency = "USD"
        filters.append(f"priceCurrency:{currency}")
        price_max = "50"
        filters.append("price:[.." + price_max + "]")
        # TODO add more filters https://developer.ebay.com/api-docs/buy/static/ref-buy-browse-filters.html
        filter_ = ",".join(filters)

        limit = 25
        try:
            for record in self._api.buy_browse_search(
                q="black", filter=filter_, limit=limit
            ):
                if "record" in record:
                    item = record["record"]
                    self.assertIn(
                        buying_option, item["buying_options"], "Wrong buying option."
                    )
                    if "AUCTION" in item["buying_options"]:
                        self.assertIsInstance(
                            item["item_end_date"],
                            str,
                            "Auctions must have an end date.",
                        )
                    # TODO eBay appears to consider some conditions equivalent so this assert is needs to change
                    # self.assertEqual(condition_option, item["condition"], "Wrong condition.")
                    if item["price"] is None:
                        self.assertIn(
                            "AUCTION",
                            item["buying_options"],
                            "Non-auctions need prices.",
                        )
                    else:
                        # The following relies on priceCurrency being in the filter.
                        self.assertLessEqual(
                            float(item["price"]["value"]),
                            float(price_max),
                            "Wrong price.",
                        )
                elif "total" in record:
                    self.assertEqual(
                        limit, record["total"]["records_yielded"], "Insufficient items."
                    )
                else:
                    self.fail(f"Unexpected record {record}")
        except Error as error:
            self.fail(error)

    def test_hidden_page_boundaries(self):
        """
        Warning, this can't be run in the sandbox because searches do not return enough results.
        """
        limit_and_keywords = (
            (
                1,
                "red",
            ),  # start at one because zero is not allowed
            (
                2,
                "orange",
            ),
            (
                198,
                "yellow",
            ),
            (
                199,
                "green",
            ),
            (
                200,
                "blue",
            ),  # the biggest page can hold 200 items
            (
                201,
                "indigo",
            ),
            (
                202,
                "violet",
            ),
        )
        for limit, keywords in limit_and_keywords:
            counter = 0
            safety = limit + 5
            for record in self._api.buy_browse_search(limit=limit, q=keywords):
                if "record" not in record:
                    self.assertTrue(
                        "total" in record, f"Unexpected non-record{record}."
                    )
                else:
                    item = record["record"]
                    self.assertTrue(isinstance(item["item_id"], str), "Malformed item.")
                    counter += 1
                    if counter >= safety:
                        break
            self.assertTrue(counter == limit, f"Page boundary error on limit {limit}.")

    def test_sell_inventory(self):
        """
        https://developer.ebay.com/api-docs/sell/inventory/static/overview.html
        """
        # A unique, merchant-defined key (ID) for an inventory location.
        # This unique identifier, or key, is used in other Inventory API calls to identify an inventory location.
        length = random.randint(1, 36)
        allowed = (
            string.ascii_letters + string.digits + "_" + "-"
        )  # more might be allowed, eBay's docs are unclear
        merchant_location_key = "".join(random.choice(allowed) for _i in range(length))

        # Create a new inventory location.
        # https://developer.ebay.com/api-docs/sell/inventory/resources/location/methods/createInventoryLocation
        # A template for supplying all possible information about a location.
        _body = {
            "location": {
                "address": {
                    "addressLine1": "string",
                    "addressLine2": "string",
                    "city": "string",
                    "country": "CountryCodeEnum",
                    "county": "string",
                    "postalCode": "string",
                    "stateOrProvince": "string",
                },
                "geoCoordinates": {"latitude": "number", "longitude": "number"},
            },
            "locationAdditionalInformation": "string",
            "locationInstructions": "string",
            "locationTypes": ["StoreTypeEnum"],
            "locationWebUrl": "string",
            "merchantLocationStatus": "StatusEnum : [DISABLED,ENABLED]",
            "name": "string",
            "operatingHours": [
                {
                    "dayOfWeekEnum": "DayOfWeekEnum : [MONDAY,TUESDAY,WEDNESDAY,THURSDAY,FRIDAY,SATURDAY,SUNDAY]",
                    "intervals": [{"close": "string", "open": "string"}],
                }
            ],
            "phone": "string",
            "specialHours": [
                {"date": "string", "intervals": [{"close": "string", "open": "string"}]}
            ],
        }
        # A template for supplying the bare minimum about a location.
        body = {
            "location": {
                "address": {
                    "addressLine1": "2********e",
                    "addressLine2": "B********3",
                    "city": "S*****e",
                    "stateOrProvince": "**",
                    "postalCode": "9***5",
                    "country": "US",
                }
            },
            "locationInstructions": "Items ship from here.",
            "name": "W********1",
            "merchantLocationStatus": "ENABLED",
            "locationTypes": ["WAREHOUSE"],
        }
        try:
            self._api.sell_inventory_create_inventory_location(
                content_type="application/json",
                body=body,
                merchant_location_key=merchant_location_key,
            )
        except Error as error:
            self.fail(error)

        # Confirm the new location.
        # https://developer.ebay.com/api-docs/sell/inventory/resources/location/methods/getInventoryLocations
        confirmed = False
        try:
            for record in self._api.sell_inventory_get_inventory_locations():
                if "record" not in record:
                    self.assertTrue(
                        "total" in record, f"Unexpected non-record{record}."
                    )
                else:
                    location = record["record"]
                    if "merchant_location_key" in location:
                        if location["merchant_location_key"] == merchant_location_key:
                            confirmed = True
                            break
                    else:
                        self.fail(
                            f"A location is missing the merchant_location_key {record}."
                        )
        except Error as error:
            self.fail(error)
        self.assertTrue(confirmed, "Failed to confirm that the new location exists.")

    def test_sell_create_or_replace_inventory_item(self):
        """
        See https://developer.ebay.com/api-docs/sell/inventory/static/overview.html &
        https://developer.ebay.com/api-docs/sell/inventory/resources/inventory_item/methods/createOrReplaceInventoryItem#h2-samples
        """
        # Create a new inventory item.
        # A template for supplying all possible information about an inventory item.
        _body = {
            "availability": {
                "pickupAtLocationAvailability": [
                    {
                        "availabilityType": "AvailabilityTypeEnum : [IN_STOCK,OUT_OF_STOCK,SHIP_TO_STORE]",
                        "fulfillmentTime": {
                            "unit": "TimeDurationUnitEnum : [YEAR,MONTH,DAY,HOUR,CALENDAR_DAY,"
                            "BUSINESS_DAY,MINUTE,SECOND,MILLISECOND]",
                            "value": "integer",
                        },
                        "merchantLocationKey": "string",
                        "quantity": "integer",
                    }
                ],
                "shipToLocationAvailability": {
                    "availabilityDistributions": [
                        {
                            "fulfillmentTime": {
                                "unit": "TimeDurationUnitEnum : [YEAR,MONTH,DAY,HOUR,"
                                "CALENDAR_DAY,BUSINESS_DAY,MINUTE,SECOND,MILLISECOND]",
                                # noqa: E501
                                "value": "integer",
                            },
                            "merchantLocationKey": "string",
                            "quantity": "integer",
                        }
                    ],
                    "quantity": "integer",
                },
            },
            "condition": "ConditionEnum : [NEW,LIKE_NEW,NEW_OTHER,"
            "NEW_WITH_DEFECTS,MANUFACTURER_REFURBISHED,"
            "CERTIFIED_REFURBISHED,EXCELLENT_REFURBISHED,"
            "VERY_GOOD_REFURBISHED,GOOD_REFURBISHED,SELLER_REFURBISHED,"
            "USED_EXCELLENT,USED_VERY_GOOD,USED_GOOD,USED_ACCEPTABLE,FOR_PARTS_OR_NOT_WORKING]",
            # noqa: E501
            "conditionDescription": "string",
            "packageWeightAndSize": {
                "dimensions": {
                    "height": "number",
                    "length": "number",
                    "unit": "LengthUnitOfMeasureEnum : [INCH,FEET,CENTIMETER,METER]",
                    "width": "number",
                },
                "packageType": "PackageTypeEnum : [LETTER,BULKY_GOODS,CARAVAN,CARS,"
                "EUROPALLET,EXPANDABLE_TOUGH_BAGS,EXTRA_LARGE_PACK,"  # noqa: typo
                "FURNITURE,INDUSTRY_VEHICLES,LARGE_CANADA_POSTBOX,"
                "LARGE_CANADA_POST_BUBBLE_MAILER,LARGE_ENVELOPE,"
                "MAILING_BOX,MEDIUM_CANADA_POST_BOX,MEDIUM_CANADA_POST_BUBBLE_MAILER,"
                "MOTORBIKES,ONE_WAY_PALLET,PACKAGE_THICK_ENVELOPE,PADDED_BAGS,"
                "PARCEL_OR_PADDED_ENVELOPE,ROLL,SMALL_CANADA_POST_BOX,SMALL_CANADA_POST_BUBBLE_MAILER,"
                "TOUGH_BAGS,UPS_LETTER,USPS_FLAT_RATE_ENVELOPE,USPS_LARGE_PACK,VERY_LARGE_PACK,"
                "WINE_PAK]",
                "weight": {
                    "unit": "WeightUnitOfMeasureEnum : [POUND,KILOGRAM,OUNCE,GRAM]",
                    "value": "number",
                },
            },
            "product": {
                "aspects": "string",
                "brand": "string",
                "description": "string",
                "ean": ["string"],
                "epid": "string",  # noqa: typo
                "imageUrls": ["string"],
                "isbn": ["string"],
                "mpn": "string",
                "subtitle": "string",
                "title": "string",
                "upc": ["string"],
                "videoIds": ["string"],
            },
        }
        # A template for supplying limited information about an inventory item.
        body = {
            "availability": {"shipToLocationAvailability": {"quantity": 50}},
            "condition": "NEW",
            "product": {
                "title": "GoPro Hero4 Helmet Cam",
                "description": "New GoPro Hero4 Helmet Cam. Unopened box.",
                "imageUrls": [
                    "https://i.ebayimg.com/images/i/182196556219-0-1/s-l1000.jpg",
                    "https://i.ebayimg.com/images/i/182196556219-0-1/s-l1001.jpg",
                    "https://i.ebayimg.com/images/i/182196556219-0-1/s-l1002.jpg",
                ],
            },
        }

        # The natural language used in the body; see 'Local Support' in the following link for permitted values.
        # https://developer.ebay.com/api-docs/static/rest-request-components.html#HTTP
        content_language = "en-US"

        # The seller-defined SKU value for the inventory item, unique across the seller's inventory.
        length = random.randint(1, 50)
        allowed = (
            string.ascii_letters + string.digits + "_" + "-"
        )  # more might be allowed, eBay's docs are unclear
        sku = "".join(random.choice(allowed) for _i in range(length))

        try:
            self._api.sell_inventory_create_or_replace_inventory_item(
                body=body,
                content_language=content_language,
                content_type="application/json",
                sku=sku,
            )
        except Error as error:
            self.fail(error)

    @staticmethod
    def get_upload_sample_path_file(file_name: str) -> str:
        """
        Get the full path to a sample file for upload testing.

        :param file_name: The name of the file in the upload_samples directory
        :return: The absolute path to the upload sample file
        """
        return os.path.join(os.path.dirname(__file__), "upload_samples", file_name)

    def test_commerce_media_upload_document(self):
        """
        First get a valid document_id using createDocument, then upload the document using that ID.

        See: https://developer.ebay.com/api-docs/commerce/media/resources/document/methods/createDocument
        See: https://developer.ebay.com/api-docs/commerce/media/resources/document/methods/uploadDocument

        Warning: On 2025-11-11 the sandbox lacked support for this.
        """
        try:
            # First, create a document to get a valid document_id
            create_body = {
                "documentType": "USER_GUIDE_OR_MANUAL",
                "languages": ["ENGLISH"],
            }
            create_result = self._api.commerce_media_create_document(
                content_type="application/json", body=create_body
            )

            # Extract documentId from the response; it can be a dict (documentId) or object (document_id property)
            if isinstance(create_result, dict):
                document_id = create_result.get("documentId") or create_result.get(
                    "document_id"
                )
            else:
                document_id = getattr(create_result, "document_id", None) or getattr(
                    create_result, "documentId", None
                )

            self.assertIsNotNone(
                document_id, "Failed to get documentId from create_document response"
            )
            # Now upload the document file using the document_id from createDocument
            result = self._api.commerce_media_upload_document(
                document_id=document_id,
                content_type="multipart/form-data",
                files={"file": self.get_upload_sample_path_file("document.pdf")},
            )

        except Error as error:
            self.fail(error)
        else:
            self.assertIn("document_metadata", result)
            self.assertIn("document_status", result)


class APISandboxDigitalSignatureTests(unittest.TestCase):
    """
    API tests that test eBay Digital Signatures and public/private key pairs
    """

    @classmethod
    def setUpClass(cls):
        sandbox = True
        application = "sandbox_1" if sandbox else "production_1"
        user = "sandbox_1" if sandbox else "production_1"
        key_pair = "sandbox_1" if sandbox else "production_1"
        _api = API(
            application=application,
            user=user,
            header="US",
            key_pair=key_pair,
            digital_signatures=True,
        )
        original_private_key = _api._key_pair_token._private_key
        original_signing_key_id = _api._key_pair_token._signing_key_id
        if original_private_key and original_signing_key_id:
            cls.key_fail = None
        else:
            cls.key_fail = (
                "Need a private key and signing_key_id for Digital Signatures tests"
            )
        # retain only what the class methods need
        cls.application = application
        cls._api = _api
        cls.original_private_key = original_private_key
        cls.original_signing_key_id = original_signing_key_id
        cls.user = user
        cls.marketplace_id = _api._header["marketplace_id"]

    def setUp(self):
        # Reset private key
        self._api._key_pair_token._private_key = self.original_private_key
        if self.key_fail is not None:
            self.fail(self.key_fail)

    def test_get_signing_key(self):
        """
        Check that we can load all the remaining key information given the private key and signing_key_id.
        """
        key_pair = {
            "private_key": self.original_private_key,
            "signing_key_id": self.original_signing_key_id,
        }
        api = API(
            application=self.application,
            user=self.user,
            header="US",
            key_pair=key_pair,
            digital_signatures=True,
        )
        try:
            key = api.get_digital_signature_key(create_new=False)
        except Error as error:
            self.fail(error)
        else:
            self.assertEqual(key["private_key"], self.original_private_key)
            self.assertEqual(key["signing_key_id"], self.original_signing_key_id)

    def test_good_private_key(self):
        """
        Check that a valid private key succeeds.
        """
        try:
            result = self._api.sell_finances_get_transaction_summary(
                filter="transactionStatus:{PAYOUT}",
                x_ebay_c_marketplace_id=self.marketplace_id,
            )
        except Error as error:
            self.fail(f"Expected success but got Error {error.number}: {error.reason}")
        else:
            self.assertIsNotNone(result)

    def test_005_bad_private_key(self):
        """
        Check that an invalid private key fails.
        """
        # Intentionally corrupt the private key
        self._api._key_pair_token._private_key = "INVALID_KEY_DATA"
        try:
            self._api.sell_finances_get_transaction_summary(
                filter="transactionStatus:{PAYOUT}",
                x_ebay_c_marketplace_id=self.marketplace_id,
            )
        except Error as error:
            self.assertEqual(error.number, 96028)
        else:
            self.fail("API call should have failed with an invalid private key.")

    # This test permanently creates a new public/private key pair which cannot
    # be removed, hence it is skipped by default.
    @unittest.skip
    def test_create_signing_key(self):
        """
        Check that we can create a new public/private key.
        """
        self._api._key_pair_token._private_key = None
        self._api._key_pair_token._signing_key_id = None
        with self.assertRaises(Error):
            _key = self._api.get_digital_signature_key(create_new=False)
        key = self._api.get_digital_signature_key(create_new=True)
        self.assertTrue(key["private_key"])
        self.assertNotEqual(key["private_key"], self.original_private_key)
        try:
            result = self._api.sell_finances_get_transaction_summary(
                filter="transactionStatus:{PAYOUT}",
                x_ebay_c_marketplace_id=self.marketplace_id,
            )
        except Error as error:
            self.fail(error)
        else:
            self.assertTrue("credit_count" in result)


class DateTimeTests(unittest.TestCase):

    def test_now_returns_datetime_with_utc(self):
        dt = DateTime.now()
        self.assertIsInstance(
            dt, datetime.datetime, "now() should return a datetime instance."
        )
        self.assertIsNotNone(dt.tzinfo, "Returned datetime should have a timezone set.")
        # Instead of comparing to datetime.timezone.utc directly, verify that the UTC offset is zero.
        self.assertEqual(
            dt.tzinfo.utcoffset(dt),
            datetime.timedelta(0),
            "Returned datetime should have an offset of zero (UTC).",
        )

    def test_to_string_format(self):
        dt = datetime.datetime(
            2025, 2, 4, 12, 30, 15, 123456, tzinfo=datetime.timezone.utc
        )
        s = DateTime.to_string(dt)
        pattern = r"\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z"
        self.assertRegex(
            s, pattern, "to_string() output did not match the expected format."
        )

    def test_from_string_returns_correct_datetime(self):
        s = "2025-02-04T12:30:15.123Z"
        dt = DateTime.from_string(s)
        expected = datetime.datetime(
            2025, 2, 4, 12, 30, 15, 123000, tzinfo=datetime.timezone.utc
        )
        self.assertEqual(
            dt, expected, "from_string() did not return the expected datetime."
        )

    def test_round_trip_conversion(self):
        original = datetime.datetime(
            2025, 2, 4, 12, 30, 15, 789123, tzinfo=datetime.timezone.utc
        )
        s = DateTime.to_string(original)
        converted = DateTime.from_string(s)
        expected_microsecond = (original.microsecond // 1000) * 1000
        expected = datetime.datetime(
            original.year,
            original.month,
            original.day,
            original.hour,
            original.minute,
            original.second,
            expected_microsecond,
            tzinfo=datetime.timezone.utc,
        )
        self.assertEqual(
            converted,
            expected,
            "Round-trip conversion did not yield the expected datetime.",
        )

    def test_to_string_known_value(self):
        dt = datetime.datetime(
            2004, 8, 4, 19, 9, 2, 768000, tzinfo=datetime.timezone.utc
        )
        result = DateTime.to_string(dt)
        expected = "2004-08-04T19:09:02.768Z"
        self.assertEqual(
            result,
            expected,
            "to_string() did not produce the expected output for a known value.",
        )

    def test_to_string_with_invalid_input(self):
        with self.assertRaises(Error) as context:
            DateTime.to_string("not a datetime")  # type: ignore
        self.assertEqual(
            context.exception.number,
            98001,
            "to_string() did not raise the expected error for non-datetime input.",
        )

    def test_from_string_with_invalid_input_type(self):
        with self.assertRaises(Error) as context:
            DateTime.from_string(12345)  # type: ignore
        self.assertEqual(
            context.exception.number,
            98003,
            "from_string() did not raise the expected error for non-string input.",
        )

    def test_from_string_with_invalid_format(self):
        with self.assertRaises(Error) as context:
            DateTime.from_string("invalid date format")
        self.assertEqual(
            context.exception.number,
            98004,
            "from_string() did not raise the expected error for an invalid format.",
        )

    def test_now_increasing(self):
        dt1 = DateTime.now()
        dt2 = DateTime.now()
        self.assertLessEqual(
            dt1, dt2, "Consecutive calls to now() should yield non-decreasing times."
        )


class ErrorTests(unittest.TestCase):
    def test_error_init(self):
        # test basic initialization
        error = Error(number=1, reason="reason", detail="detail")
        self.assertEqual(error.number, 1)
        self.assertEqual(error.reason, "reason")
        self.assertEqual(error.detail, "detail")

    def test_error_json_prettify(self):
        # test JSON prettification for string
        detail_json = '{"a": 1, "b": 2}'
        error = Error(number=1, reason="reason", detail=detail_json)
        expected_detail = '{\n    "a": 1,\n    "b": 2\n}'
        self.assertEqual(error.detail, expected_detail)

        # test JSON prettification for bytes
        detail_bytes = b'{"a": 1, "b": 2}'
        error = Error(number=1, reason="reason", detail=detail_bytes)  # type: ignore
        self.assertEqual(error.detail, expected_detail)

    def test_error_str(self):
        # test string representation without detail or cause
        error = Error(number=1, reason="reason")
        expected_str = "Error number 1.\nReason: reason"
        self.assertEqual(str(error), expected_str)

        # test string representation with detail
        error = Error(number=1, reason="reason", detail="detail")
        expected_str = "Error number 1.\nReason: reason\nDetail: detail"
        self.assertEqual(str(error), expected_str)

        # test string representation with cause
        cause = Exception("cause message")
        error = Error(number=1, reason="reason", cause=cause)
        expected_str = (
            "Error number 1.\nReason: reason\nCause: Exception: cause message"
        )
        self.assertEqual(str(error), expected_str)

    def test_error_as_dict(self):
        cause = Exception("cause message")
        error = Error(number=1, reason="reason", detail="detail", cause=cause)
        expected_dict = {
            "number": 1,
            "reason": "reason",
            "detail": "detail",
            "cause": "cause message",
        }
        self.assertEqual(error.as_dict(), expected_dict)

    def test_error_log(self):
        error = Error(number=1, reason="reason")

        class MockLogger:
            def __init__(self):
                self.message = None

            def error(self, msg):
                self.message = msg

        logger = MockLogger()
        error.log(logger)
        self.assertEqual(logger.message, str(error))


class ApplicationTokenTests(unittest.TestCase):

    @classmethod
    def setUpClass(cls):
        cls.creds = Credentials()

    def is_token_valid(self, token: str) -> bool:
        """
        Helper, returns true if an application token is valid.
        """
        self.assertIsInstance(token, str)
        self.assertGreater(len(token), 0)
        return True  # TODO replace this with real validation logic that actually uses the token

    def normal_start_and_use(self, sandbox: bool, app_name: str) -> str:
        """
        Helper, test the normal startup and getting of an application token, either sandbox *or* production.
        """

        # initialization
        app = self.creds.get_application(app_name)
        app_token = ApplicationToken(
            sandbox=sandbox,
            client_id=app["app_id"],
            client_secret=app["cert_id"],
            ru_name=app["redirect_uri"],
        )
        self.assertIsInstance(app_token, ApplicationToken)

        # token retrieval, validity and caching
        token = app_token.get()
        self.assertTrue(self.is_token_valid(token))
        self.assertEqual(token, app_token.get())

        return token

    def test_normal_start_and_use(self):
        """
        Test the normal startup and getting of an application token, both sandbox *and* production.
        """
        sand_token = self.normal_start_and_use(True, "sandbox_1")
        prod_token = self.normal_start_and_use(False, "production_1")
        self.assertNotEqual(sand_token, prod_token)

    def bad_start_repetitive(
        self, app_name, sandbox, client_id, client_secret, ru_name
    ):
        """
        Helper, repetitive part, of test the bad startup of an application token, either sandbox *or* production.
        """
        with self.assertRaises(
            Error,
            msg=f"Expected Error for: "
            f"{app_name=} {sandbox=} {client_id=} {client_secret=} {ru_name=}",
        ) as cm:
            app = ApplicationToken(
                sandbox=sandbox,
                client_id=client_id,
                client_secret=client_secret,
                ru_name=ru_name,
            )
            app.get()

        self.assertEqual(
            cm.exception.number,
            96001,
            msg=f"Wrong error number for: "
            f"{app_name=} {sandbox=} {client_id=} {ru_name=}",
        )

    def bad_start(self, sandbox: bool, app_name: str):
        """
        Helper, test the bad startup of an application token, either sandbox *or* production.
        """
        app = self.creds.get_application(app_name)
        client_id = app["app_id"]
        client_secret = app["cert_id"]
        ru_name = app["redirect_uri"]
        # spoil each parameter in turn - TODO improve sensitivity so the skipped test actually fails
        self.bad_start_repetitive(
            app_name, not sandbox, client_id, client_secret, ru_name
        )
        self.bad_start_repetitive(
            app_name, sandbox, "BAD!" + client_id, client_secret, ru_name
        )
        self.bad_start_repetitive(
            app_name, sandbox, client_id, "BAD!" + client_secret, ru_name
        )
        # self.bad_start_repetitive(app_name, sandbox, client_id, client_secret, "BAD!" + ru_name)

    def test_bad_start(self):
        """
        Test the bad startup of an application token, both sandbox *and* production.
        """
        self.bad_start(True, "sandbox_1")
        self.bad_start(False, "production_1")


class UserTokenTests(unittest.TestCase):

    @classmethod
    def setUpClass(cls):
        cls.creds = Credentials()

    def is_token_valid(self, token: str) -> bool:
        """
        Helper, returns true if a user token is valid.
        """
        self.assertIsInstance(token, str)
        self.assertGreater(len(token), 0)
        return True  # TODO replace this with more validation logic that actually uses the token

    def normal_start_and_use(self, sandbox: bool, name: str) -> str:
        """
        Helper, test the normal startup and getting of a user token, either sandbox *or* production.
        """

        # initialization
        app = self.creds.get_application(name)
        user = self.creds.get_user(name)
        user_token = UserToken(
            sandbox=sandbox,
            client_id=app["app_id"],
            client_secret=app["cert_id"],
            ru_name=app["redirect_uri"],
            user_id=user["email_or_username"],
            user_password=user["password"],
            user_refresh_token=user["refresh_token"] if user["refresh_token"] else None,
            user_refresh_token_expiry=(
                user["refresh_token_expiry"] if user["refresh_token_expiry"] else None
            ),
        )
        self.assertIsInstance(user_token, UserToken)

        # token retrieval, validity and caching
        token = user_token.get()
        self.assertTrue(self.is_token_valid(token))
        self.assertEqual(token, user_token.get())

        return token

    def test_normal_start_and_use(self):
        """
        Test the normal startup and getting of a user token, both sandbox *and* production.
        """
        sand_token = self.normal_start_and_use(True, "sandbox_1")
        prod_token = self.normal_start_and_use(False, "production_1")
        self.assertNotEqual(sand_token, prod_token)

    def bad_start_repetitive(
        self,
        name,
        sandbox,
        client_id,
        client_secret,
        ru_name,
        user_id,
        user_password,
        refresh_token,
        refresh_token_expiry,
    ):
        """
        Helper, repetitive part, of test the bad startup of a user token, either sandbox *or* production.
        """
        with self.assertRaises(
            Error,
            msg=f"Expected Error for: "
            f"{name=} {sandbox=} {client_id=} {client_secret=} {ru_name=} "
            f"{user_id=} {user_password=} {refresh_token=} {refresh_token_expiry=}",
        ) as cm:
            user_token = UserToken(
                sandbox=sandbox,
                client_id=client_id,
                client_secret=client_secret,
                ru_name=ru_name,
                user_id=user_id,
                user_password=user_password,
                user_refresh_token=refresh_token,
                user_refresh_token_expiry=refresh_token_expiry,
            )
            user_token.get()

        self.assertIn(
            cm.exception.number,
            [96001, 96003, 96004, 96007, 96011, 96016, 96025, 96030, 96016],
            msg=f"Wrong error number {cm.exception.number} for: "
            f"{name=} {sandbox=} {client_id=} {ru_name=}",
        )

    def bad_start(self, sandbox: bool, name: str):
        """
        Helper, test the bad startup of a user token, either sandbox *or* production.
        """
        app = self.creds.get_application(name)
        user = self.creds.get_user(name)

        client_id = app["app_id"]
        client_secret = app["cert_id"]
        ru_name = app["redirect_uri"]

        user_id = user["email_or_username"]
        user_password = user["password"]
        refresh_token = user["refresh_token"] if user["refresh_token"] else None
        refresh_token_expiry = (
            user["refresh_token_expiry"] if user["refresh_token_expiry"] else None
        )

        # spoil each parameter in turn - TODO improve sensitivity so the skipped tests actually fail
        self.bad_start_repetitive(
            name,
            not sandbox,
            client_id,
            client_secret,
            ru_name,
            user_id,
            user_password,
            refresh_token,
            refresh_token_expiry,
        )
        self.bad_start_repetitive(
            name,
            sandbox,
            "BAD!" + client_id,
            client_secret,
            ru_name,
            user_id,
            user_password,
            refresh_token,
            refresh_token_expiry,
        )
        self.bad_start_repetitive(
            name,
            sandbox,
            client_id,
            "BAD!" + client_secret,
            ru_name,
            user_id,
            user_password,
            refresh_token,
            refresh_token_expiry,
        )
        # self.bad_start_repetitive(name, sandbox, client_id, client_secret, "BAD!" + ru_name, user_id, user_password, refresh_token, refresh_token_expiry)
        # self.bad_start_repetitive(name, sandbox, client_id, client_secret, ru_name, "BAD!" + user_id, user_password, refresh_token, refresh_token_expiry)
        # self.bad_start_repetitive(name, sandbox, client_id, client_secret, ru_name, user_id, "BAD!" + user_password, refresh_token, refresh_token_expiry)
        # if refresh_token:
        #     self.bad_start_repetitive(name, sandbox, client_id, client_secret, ru_name, user_id, user_password, "BAD!" + refresh_token, refresh_token_expiry)
        # if refresh_token_expiry:
        #    self.bad_start_repetitive(name, sandbox, client_id, client_secret, ru_name, user_id, user_password, refresh_token, "BAD!" + refresh_token_expiry)

    def test_bad_start(self):
        """
        Test the bad startup of a user token, both sandbox *and* production.
        """
        self.bad_start(True, "sandbox_1")
        self.bad_start(False, "production_1")

    def test_get_authorization_code(self):
        """
        Notes:
        1. A web browser window should pop up while this test successfully runs. If seen, manually to the captcha.
        2. eBay also refers to the authorization code as a token refresh token.
        3. Don't use sandbox_1, it will spoil its refresh token in ebay_rest.json
        """
        name = "sandbox_2"
        user = self.creds.get_user(name)
        message = f"In ebay_rest.json for user {name} the refresh_token and expiry must be empty."
        self.assertEqual(user["refresh_token"], "", message)
        self.assertEqual(user["refresh_token_expiry"], "", message)

        try:
            api = API(application=name, user=name, header="US")
        except Error as error:
            self.fail(error)
        else:
            # Try a method that needs a user refresh token.
            try:
                result = api.sell_compliance_get_listing_violations_summary(
                    x_ebay_c_marketplace_id=api._header["marketplace_id"]
                )
            except Error as error:
                self.fail(error)
            else:
                if result is not None:
                    self.assertIsNotNone("violation_summaries" in result)


class MultitonTests(unittest.TestCase):
    def test_help(self):
        """
        Documentation from the wrapped class should be returned instead of Multiton.
        """
        doc_string = API.__doc__
        self.assertTrue(
            "Multiton" not in doc_string and "API" in doc_string,
            msg="Double-check functools.update_wrapper in multiton.py.",
        )

    def test_thread_safety_concurrent_creation(self):
        """
        Test that concurrent creation of Multiton instances with the same parameters
        returns the same instance (no duplicates created due to race conditions).
        """
        import threading
        from src.ebay_rest.multiton import Multiton

        # Create a simple test class using Multiton
        class TestMultitonClass(metaclass=Multiton):
            def __init__(self, value):
                self.value = value

        instances = []
        threads = []

        def create_instance():
            inst = TestMultitonClass("test_value")
            instances.append(inst)

        # Create 10 threads that all try to create instances with the same params
        for _ in range(10):
            thread = threading.Thread(target=create_instance)
            threads.append(thread)

        # Start all threads at roughly the same time
        for thread in threads:
            thread.start()

        # Wait for all threads to complete
        for thread in threads:
            thread.join()

        # All instances should be the same object
        first_instance = instances[0]
        for instance in instances:
            self.assertIs(
                instance,
                first_instance,
                "All instances with same parameters should be identical (same object)",
            )

    def test_thread_safety_nested_instantiation(self):
        """
        Test that nested Multiton instantiation doesn't cause deadlock.
        A Multiton class that is creating another Multiton class in its __init__.
        """
        import threading
        from src.ebay_rest.multiton import Multiton

        class InnerMultiton(metaclass=Multiton):
            def __init__(self, name):
                self.name = name

        class OuterMultiton(metaclass=Multiton):
            def __init__(self, outer_name):
                self.outer_name = outer_name
                # This creates another Multiton instance while holding the lock
                self.inner = InnerMultiton("inner")

        results = []

        def create_nested():
            try:
                outer = OuterMultiton("outer")
                results.append(("success", outer))
            except Exception as e:
                results.append(("error", str(e)))

        # Try creating nested instances from multiple threads
        threads = []
        for _ in range(5):
            thread = threading.Thread(target=create_nested)
            threads.append(thread)
            thread.start()

        # Wait with timeout to detect deadlock
        for thread in threads:
            thread.join(timeout=5.0)
            self.assertFalse(
                thread.is_alive(), "Thread is still alive - possible deadlock detected"
            )

        # All operations should succeed
        for status, result in results:
            self.assertEqual(
                status, "success", f"Nested instantiation should not fail: {result}"
            )

    def test_thread_safety_cleanup(self):
        """
        Test that the cleanup of old instances doesn't cause issues
        when accessed concurrently.
        """
        import threading
        import time
        from src.ebay_rest.multiton import Multiton

        class TestCleanupClass(metaclass=Multiton):
            def __init__(self, value):
                self.value = value

        def create_and_access():
            # Create instances with different parameters
            for i in range(5):
                instance = TestCleanupClass(f"value_{i}")
                self.assertIsNotNone(instance)
                time.sleep(
                    0.01
                )  # Small delay to increase the chance of concurrent access

        threads = []
        for _ in range(5):
            thread = threading.Thread(target=create_and_access)
            threads.append(thread)
            thread.start()

        for thread in threads:
            thread.join(timeout=10.0)
            self.assertFalse(
                thread.is_alive(), "Thread should complete without hanging"
            )


class ReferenceTests(unittest.TestCase):
    def test_get_application_scopes(self):
        self.assertIsNotNone(
            Reference.get_application_scopes(), msg="Failed to load application scopes."
        )

    def test_get_country_codes(self):
        self.assertIsNotNone(
            Reference.get_country_codes(), msg="Failed to load country codes."
        )

    def test_get_currency_codes(self):
        self.assertIsNotNone(
            Reference.get_currency_codes(), msg="Failed to load currency codes."
        )

    def test_global_id_values(self):
        self.assertIsNotNone(
            Reference.get_global_id_values(), msg="Failed to load global id values."
        )

    def test_marketplace_id_values(self):
        self.assertIsNotNone(
            Reference.get_marketplace_id_values(),
            msg="Failed to load marketplace id values.",
        )

    def test_get_user_scopes(self):
        self.assertIsNotNone(
            Reference.get_user_scopes(), msg="Failed to load user scopes."
        )


if __name__ == "__main__":
    unittest.main()
