"""
bal.core.plugin_base
=====================

GUI-agnostic foundation of the plugin.

It contains:
    * :class:`BalConfig`   - a thin typed wrapper around an Electrum config key
                             with a default value.
    * :class:`BalPlugin`   - the base plugin class (extends Electrum's
                             ``BasePlugin``) holding every configuration option
                             and the default "will settings".  The Qt-specific
                             ``Plugin`` subclass lives in ``bal.gui.qt.plugin``.
    * :class:`BalTimestamp`- helper to convert between relative durations
                             (``"30d"``, ``"1y"``) and absolute timestamps.

It also registers the three custom persisted dictionaries (``heirs``,
``will`` and ``will_settings``) with Electrum's JSON database so they are
serialised together with the wallet file.

This module performs **no** GUI work and imports nothing from PyQt / electrum.gui.
"""

import json
import os
import platform
from datetime import date, datetime, timedelta

from electrum import constants, json_db
from electrum.logging import get_logger
from electrum.plugin import BasePlugin
from electrum.transaction import tx_from_any

_logger = get_logger(__name__)


# --------------------------------------------------------------------------- #
# Plugin version - single source of truth
# --------------------------------------------------------------------------- #
# The version lives ONLY in bal/manifest.json (the file Electrum itself reads).
# We used to hardcode it in four files and keep them in sync with a pre-commit
# hook; reading it from the manifest removes that duplication.
#
# importlib.resources is used on purpose: it reads a data file bundled inside
# the ``bal`` package and works identically whether the plugin runs from an
# extracted directory or from INSIDE a zip (Electrum loads external plugins via
# zipimport). It never builds a path by hand, so there is no os.path.join
# backslash issue on Windows inside a zip.
_VERSION_CACHE = None


def get_version():
    """Return the plugin version from ``bal/manifest.json`` (cached).

    Zip-safe and independent of the current working directory. Falls back to
    ``"unknown"`` if the manifest cannot be read, so importing the plugin never
    fails just because of version lookup.
    """
    global _VERSION_CACHE
    if _VERSION_CACHE is None:
        try:
            import importlib.resources

            _parent_pkg = __package__.rpartition(".")[0] if __package__ else "bal"
            data = (
                importlib.resources.files(_parent_pkg)
                .joinpath("manifest.json")
                .read_text(encoding="utf-8")
            )
            _VERSION_CACHE = json.loads(data)["version"]
        except Exception as e:  # noqa: BLE001 - never break import over version
            _logger.error(f"failed to read version from manifest.json: {e}")
            _VERSION_CACHE = "unknown"
    return _VERSION_CACHE


# --------------------------------------------------------------------------- #
# Wallet-DB registration
# --------------------------------------------------------------------------- #
# Electrum needs to know how to (de)serialise the custom dictionaries the plugin
# stores inside the wallet file: a key name is associated with a conversion
# callable applied to each value when the wallet is loaded.  ``will`` values run
# through ``get_will`` so the stored transaction hex is turned back into a
# ``Transaction`` object.
#
# COMPATIBILITY NOTE (Electrum 4.8.0):
# Electrum 4.8.0 REMOVED ``json_db.register_dict`` and moved the registration API
# to the new ``electrum.stored_dict`` module, switching from flat key names to
# '/'-separated paths with a wildcard for the dict entries, and swapping the last
# two arguments:
#
#     Electrum <= 4.7.2 : json_db.register_dict(NAME, method, _type)
#     Electrum >= 4.8.0 : stored_dict.register_name('/NAME/*', _type, method)
#
# The conversion semantics are identical in both versions (``_type is dict`` ->
# ``method(**value)``, ``_type is tuple`` -> ``method(*value)``, otherwise
# ``method(value)``); only the registration API changed.  We therefore detect
# which API is available and adapt, so the plugin keeps working on BOTH 4.7.2 and
# 4.8.0 instead of breaking for users who have not upgraded yet.
def get_will(x):
    """Deserialise a stored will entry, rebuilding its ``tx`` object."""
    try:
        x["tx"] = tx_from_any(x["tx"])
    except Exception as e:
        raise e
    return x


try:
    # Electrum >= 4.8.0
    from electrum.stored_dict import register_name as _electrum_register_name  # pyright: ignore[reportMissingImports]

    def _register_will_dict(name, method, _type=None):
        """Register a plugin dict in the wallet DB (Electrum >= 4.8.0 API)."""
        _electrum_register_name(f"/{name}/*", _type, method)

except ImportError:
    # Electrum <= 4.7.2
    def _register_will_dict(name, method, _type=None):
        """Register a plugin dict in the wallet DB (Electrum <= 4.7.2 API)."""
        json_db.register_dict(name, method, _type)  # pyright: ignore[reportAttributeAccessIssue]


_register_will_dict("heirs", tuple)
_register_will_dict("will", dict)
_register_will_dict("will_settings", lambda x: x)


class BalConfig:
    """Typed accessor for a single Electrum configuration key.

    Wraps ``config.get`` / ``config.set_key`` and supplies a default value
    when the key is missing.
    """

    def __init__(self, config, name, default):
        self.config = config
        self.name = name
        self.default = default

    def get(self, default=None):
        """Return the stored value, falling back to ``default`` then ``self.default``."""
        v = self.config.get(self.name, default)
        if v is None:
            if default is not None:
                v = default
            else:
                v = self.default
        return v

    def set(self, value, save=True):
        """Persist ``value`` for this key."""
        self.config.set_key(self.name, value, save=save)


class BalPlugin(BasePlugin):
    """Base plugin: holds configuration and default inheritance settings.

    The GUI layer subclasses this in ``bal.gui.qt.plugin.Plugin`` and adds the
    Electrum ``@hook`` methods.  Keeping the configuration here means the CLI
    layer (or unit tests) can use the plugin logic without importing Qt.
    """

    # Command used to open an .ics calendar file, per operating system.
    default_app = {
        "Linux": "xdg-open",
        "Windows": "cmd /c start",
        "Darwin": "open",
    }

    # Human-readable chain name ("bitcoin", "testnet", "regtest", ...).
    chainname = (
        constants.net.NET_NAME if constants.net.NET_NAME != "mainnet" else "bitcoin"
    )

    # Default geometry hint for some dialogs (kept from the original code).
    SIZE = (159, 97)

    @property
    def version(self):
        """Plugin version, read from ``bal/manifest.json`` (single source of
        truth). See :func:`get_version`."""
        return get_version()

    def __init__(self, parent, config, name):
        self.logger = get_logger(__name__)
        BasePlugin.__init__(self, parent, config, name)

        # Base directory for plugin data inside the Electrum data dir.
        self.base_dir = os.path.join(config.electrum_path(), "bal")
        self.plugin_dir = os.path.split(os.path.realpath(__file__))[0]

        # Make the plugin importable when loaded from a zip (legacy behaviour:
        # the parent directory of this file is added to ``sys.path``).
        zipfile = "/".join(self.plugin_dir.split("/")[:-1])
        import sys

        sys.path.insert(0, zipfile)

        self.parent = parent
        self.config = config
        self.name = name

        # ---------------------------------------------------------------- #
        # Configuration options (all persisted via Electrum's config).
        # ---------------------------------------------------------------- #
        self.ASK_BROADCAST = BalConfig(config, "bal_ask_broadcast", True)
        self.BROADCAST = BalConfig(config, "bal_broadcast", True)
        self.LOCKTIME_TIME = BalConfig(config, "bal_locktime_time", 90)
        # NOTE (A1): block-height locktimes were removed; the plugin now uses
        # only timestamp-based locktimes. LOCKTIME_BLOCKS is therefore no longer
        # read anywhere in the code. It is kept here (dormant) on purpose, to
        # avoid touching a persisted config key ("bal_locktime_blocks") that may
        # already exist in some users' saved settings.
        self.LOCKTIME_BLOCKS = BalConfig(config, "bal_locktime_blocks", 144 * 90)
        self.LOCKTIMEDELTA_TIME = BalConfig(config, "bal_locktimedelta_time", 7)
        # NOTE (A1): same as LOCKTIME_BLOCKS above - block-height locktimes were
        # removed, so LOCKTIMEDELTA_BLOCKS is no longer read anywhere. It is kept
        # here (dormant) on purpose, to avoid touching the persisted config key
        # "bal_locktimedelta_blocks" that may already exist in saved settings.
        self.LOCKTIMEDELTA_BLOCKS = BalConfig(
            config, "bal_locktimedelta_blocks", 144 * 7
        )
        self.ENABLE_MULTIVERSE = BalConfig(config, "bal_enable_multiverse", False)
        self.TX_FEES = BalConfig(config, "bal_tx_fees", 100)
        self.INVALIDATE = BalConfig(config, "bal_invalidate", True)
        self.ASK_INVALIDATE = BalConfig(config, "bal_ask_invalidate", True)
        self.PREVIEW = BalConfig(config, "bal_preview", True)
        self.SAVE_TXS = BalConfig(config, "bal_save_txs", True)

        # SAVE_HISTORY (history persistence): when enabled, the valid will
        # transactions are saved into the wallet's LOCAL history (the History
        # tab) after every check, each with a configurable label. Default ON.
        self.SAVE_HISTORY = BalConfig(config, "bal_save_history", True)

        # HISTORY_LABEL: label text applied to the will transactions saved into
        # the wallet's local history. May contain the "{willexecutor}" token,
        # which is replaced with the will-executor URL of each will item at
        # save time.
        self.HISTORY_LABEL = BalConfig(
            config,
            "bal_history_label",
            "BitcoinAfterLife inheritance transaction - {willexecutor}",
        )

        # AUTO_SIGN (Group B / B2): when enabled, pressing "Check" will, after
        # querying the will-executor servers, automatically sign the will
        # transactions and broadcast them to their will-executors, without the
        # user having to invoke "Sign" and "Broadcast" separately. The wallet
        # password is requested only when the wallet is actually encrypted
        # (handled by BalWindow.get_wallet_password). Default ON.
        self.AUTO_SIGN = BalConfig(config, "bal_auto_sign", True)

        # EDITABLE_DATES (Group C / C2): when enabled, the delivery-time and
        # check-alive date fields are editable everywhere (toolbar / Heirs tab),
        # not only inside the "Build your will" wizard. Default OFF, so the dates
        # stay display-only outside the wizard unless the user opts in.
        self.EDITABLE_DATES = BalConfig(config, "bal_editable_dates", False)

        # NUM_REMINDERS (Group D / D1): how many SEPARATE reminder events the
        # exported .ics calendar should contain. Each reminder becomes its own
        # VEVENT (its own date in the calendar). The dates are spread uniformly
        # across the check-alive period and the LAST one always falls one day
        # before the delivery deadline (locktime). Default 3; the settings
        # dialog caps it at 5 and the date builder additionally limits it to at
        # most one event per available day.
        self.NUM_REMINDERS = BalConfig(config, "bal_num_reminders", 3)

        # "Add transaction without will-executor" backup tx. Default OFF
        # (False): a fresh wallet does NOT create the extra no-will-executor
        # backup transaction (the "azure" tx), so a plain inheritance has no
        # backup tx unless the user explicitly enables it from the wizard. The
        # chosen value is persisted per wallet, so reopening the plugin always
        # follows what is saved in that wallet (the default only applies when no
        # value has been stored yet, i.e. new wallets).
        self.NO_WILLEXECUTOR = BalConfig(config, "bal_no_willexecutor", False)
        self.MAX_WILLEXECUTOR_FEE = BalConfig(
            config, "bal_max_willexecutor_fee", 500000
        )
        self.HIDE_REPLACED = BalConfig(config, "bal_hide_replaced", True)
        self.HIDE_INVALIDATED = BalConfig(config, "bal_hide_invalidated", True)
        self.ALLOW_REPUSH = BalConfig(config, "bal_allow_repush", True)
        self.FIRST_EXECUTION = BalConfig(config, "bal_first_execution", True)
        # SIMPLE / ADVANCED mode (global, plugin-wide).
        #
        # "basic"    -> SIMPLE mode (DEFAULT): hides advanced controls (the
        #               Raw/Date selector and the "Check Alive" field/icon) and
        #               disables the "check alive" postpone behaviour, so the
        #               plugin is easier for non-technical users.
        # "advanced" -> shows every control (the original behaviour).
        #
        # This is stored in Electrum's GLOBAL config (not in the wallet file),
        # so it never affects compatibility with existing wallets: an old wallet
        # simply opens with whatever global value is set, and its owner can
        # switch to "advanced" from the plugin settings whenever they want.
        self.USER_TYPE = BalConfig(config, "bal_user_type", "basic")
        self.WELIST_SERVER = BalConfig(
            config, "bal_welist_server", "https://welist.bitcoin-after.life/"
        )
        self.EVENT_DESCRIPTION = BalConfig(
            config,
            "bal_event_description",
            "BAL will execution of $wallet_name\r\n heirs list:  \r\n$heirs_complete",
        )
        self.EVENT_SUMMARY = BalConfig(
            config, "bal_event_summary", "BAL -Will execution of $wallet_name"
        )

        # Default will-executor servers, keyed by network.
        self.WILLEXECUTORS = BalConfig(
            config,
            "bal_willexecutors",
            {
                "mainnet": {
                    "https://we.bitcoin-after.life": {
                        "base_fee": 100000,
                        "status": "New",
                        "info": "Bitcoin After Life Will Executor",
                        "address": "bc1qusymuetsz2psaqzqxv8qmzcy64d9meckj3lxxf",
                        "selected": True,
                    }
                },
                "testnet": {
                    "https://we.bitcoin-after.life": {
                        "base_fee": 100000,
                        "status": "New",
                        "info": "Bitcoin After Life Will Executor",
                        "address": "bcrt1qa5cntu4hgadw8zd3n6sq2nzjy34sxdtd9u0gp7",
                        "selected": True,
                    }
                },
                "testnet4": {
                    "https://we.bitcoin-after.life": {
                        "base_fee": 100000,
                        "status": "New",
                        "info": "Bitcoin After Life Will Executor",
                        "address": "bcrt1qa5cntu4hgadw8zd3n6sq2nzjy34sxdtd9u0gp7",
                        "selected": True,
                    }
                },
                "regtest": {
                    "https://we.bitcoin-after.life": {
                        "base_fee": 100000,
                        "status": "New",
                        "info": "Bitcoin After Life Will Executor",
                        "address": "bcrt1qa5cntu4hgadw8zd3n6sq2nzjy34sxdtd9u0gp7",
                        "selected": True,
                    }
                },
            },
        )
        self.WILL_SETTINGS = BalConfig(
            config,
            "bal_will_settings",
            BalPlugin.default_will_settings(),
        )

        self.system = platform.system()
        self.CALENDAR_APP = BalConfig(
            config, "bal_open_app", self.default_app.get(self.system, "")
        )

        # Cached toggles used by the GUI list filters.
        self._hide_invalidated = self.HIDE_INVALIDATED.get()
        self._hide_replaced = self.HIDE_REPLACED.get()

    def resource_path(self, *parts):
        """Absolute path to a file bundled inside the plugin directory."""
        return os.path.join(self.plugin_dir, *parts)

    def sync_hide_filters(self):
        """Re-read the "hide" filter flags from the persisted config.

        The cached ``_hide_invalidated`` / ``_hide_replaced`` flags are used by
        the GUI list to decide which rows to skip.  They can be changed from two
        different places:

        * the list toolbar buttons, which call :meth:`hide_invalidated` /
          :meth:`hide_replaced` (a toggle that updates both the cache and the
          config), and
        * the Settings dialog checkboxes, which write the config directly
          (``BalConfig.set``) without touching the cached flags.

        In the second case the cache and the config would drift apart and the
        transaction list would keep filtering with the *old* value, so the
        toggled rows never appear/disappear until Electrum is restarted.
        Re-syncing the cache from the config here (called by ``update_all``)
        keeps every code path coherent regardless of where the change came
        from.
        """
        self._hide_invalidated = self.HIDE_INVALIDATED.get()
        self._hide_replaced = self.HIDE_REPLACED.get()

    def hide_invalidated(self):
        """Toggle (and persist) the "hide invalidated transactions" filter."""
        self._hide_invalidated = not self._hide_invalidated
        self.HIDE_INVALIDATED.set(self._hide_invalidated)

    def hide_replaced(self):
        """Toggle (and persist) the "hide replaced transactions" filter."""
        self._hide_replaced = not self._hide_replaced
        self.HIDE_REPLACED.set(self._hide_replaced)

    def validate_will_settings(self, will_settings):
        """Fill in any missing will-setting with its default value."""
        defaults = BalPlugin.default_will_settings()
        if not will_settings:
            will_settings = {}
        if int(will_settings.get("baltx_fees", 0)) < 1:
            will_settings["baltx_fees"] = defaults['baltx_fees']
        if not will_settings.get("threshold"):
            will_settings["threshold"] = defaults['threshold']
        if not will_settings.get("locktime"):
            will_settings["locktime"] = defaults['locktime']
        return will_settings

    def is_basic_mode(self):
        """Return True when the plugin runs in SIMPLE ("basic") mode.

        Centralises the USER_TYPE check so the GUI never compares the raw
        string in many places. Anything other than the explicit "advanced"
        value is treated as basic, so the safe/simple behaviour is the default
        even if the stored value is missing or unexpected.
        """
        return str(self.USER_TYPE.get()).lower() != "advanced"

    @staticmethod
    def default_will_settings():
        """Default will settings: a fee rate plus absolute threshold/locktime."""
        will_settings: dict[str, float] = {"baltx_fees": 20}
        will_settings.update(BalPlugin.default_will_settings_absolute())
        return will_settings

    @staticmethod
    def default_will_settings_absolute():
        """Convert the default relative dates into absolute timestamps (from today)."""
        relative_dates = BalPlugin.default_will_settings_relative()
        today = date.today()
        dt = datetime(today.year, today.month, today.day, 0, 0, 0)
        threshold = (
            dt + timedelta(days=BalTimestamp(relative_dates["threshold"]).duration_to_days())
        ).timestamp()
        locktime = (
            dt + timedelta(days=BalTimestamp(relative_dates["locktime"]).duration_to_days())
        ).timestamp()
        return {"threshold": threshold, "locktime": locktime}

    @staticmethod
    def default_will_settings_relative():
        """Default relative dates: 30 days threshold, 1 year locktime."""
        return {"threshold": "30d", "locktime": "1y"}


class BalTimestamp:
    """Parse and convert relative durations / absolute timestamps.

    A value may be:
        * ``"<n>y"`` -> ``n`` years (unit ``"y"``)
        * ``"<n>d"`` -> ``n`` days  (unit ``"d"``)
        * an integer -> an absolute UNIX timestamp (``unit is None``)
    """

    value: int
    unit: str | None

    def __init__(self, value):
        self.value = 1
        self.unit = None
        str_value = str(value)
        if str_value and str_value[-1].lower() in ("y", "d"):
            self.value = int(str_value[:-1])
            self.unit = str_value[-1]
        else:
            try:
                self.value = int(value)
            except Exception as _e:
                self.value = 1
            self.unit = None

    def duration_to_days(self):
        """Return the duration expressed in days (years are ``*365``)."""
        return self.value * 365 if self.unit == 'y' else self.value

    @staticmethod
    def _safe_fromtimestamp(ts):
        """``datetime.fromtimestamp`` that never raises ``OverflowError``.

        On Windows ``time_t`` is 32-bit, so ``datetime.fromtimestamp`` raises
        ``OverflowError: Python int too large to convert to C int`` for any
        timestamp past the year-2038 limit (e.g. ``NLOCKTIME_MAX = 2**32 - 1``,
        used as the default/sentinel locktime).  On 64-bit Linux the same call
        succeeds, which is why this only crashed on the user's Windows build.

        We clamp out-of-range timestamps to INT32_MAX, mirroring Electrum's own
        ``get_max_allowed_timestamp`` workaround (see Electrum issue #6170).
        """
        int32_max = 2 ** 31 - 1
        try:
            return datetime.fromtimestamp(ts)
        except (OSError, OverflowError, ValueError):
            try:
                return datetime.fromtimestamp(min(int(ts), int32_max))
            except (OSError, OverflowError, ValueError):
                return datetime.fromtimestamp(int32_max)

    def to_date(self, from_date=None, reverse=False):
        """Resolve to a ``datetime``.

        For absolute values the stored timestamp is returned; for relative ones
        the duration is added to (or, if ``reverse``, subtracted from)
        ``from_date`` (defaulting to *now*), normalised to midnight.
        """
        if self.unit is None:
            return self._safe_fromtimestamp(self.value)
        else:
            if from_date is None:
                from_date = datetime.now()
            if isinstance(from_date, (int, float)):
                from_date = self._safe_fromtimestamp(from_date)
            reverse = 1 if not reverse else -1
            try:
                return (
                    from_date + (reverse * timedelta(days=self.duration_to_days()))
                ).replace(hour=0, minute=0, second=0, microsecond=0)
            except (OverflowError, OSError, ValueError):
                # Duration overflowed datetime's range; clamp to INT32_MAX.
                return self._safe_fromtimestamp(2 ** 31 - 1).replace(
                    hour=0, minute=0, second=0, microsecond=0
                )

    def to_timestamp(self, from_date=None, reverse=False):
        """Same as :meth:`to_date` but returns a UNIX timestamp."""
        return self.to_date(from_date, reverse).timestamp()

    def __str__(self):
        if self.unit is None:
            return self._safe_fromtimestamp(self.value).isoformat()
        else:
            return f"{self.value}{self.unit}"

    def __repr__(self):
        if self.unit is None:
            return self._safe_fromtimestamp(self.value).isoformat()
        else:
            return f"{self.value}{self.unit}"
