# piker: trading gear for hackers
# Copyright (C) 2018-present  Tyler Goodlet (in stewardship for pikers)

# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU Affero General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.

# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
# GNU Affero General Public License for more details.

# You should have received a copy of the GNU Affero General Public License
# along with this program.  If not, see <https://www.gnu.org/licenses/>.

"""
Platform configuration (files) mgmt.

"""
import platform
import os
import shutil
from typing import (
    Callable,
    MutableMapping,
)
from pathlib import Path

from bidict import bidict
import platformdirs
import tomlkit
try:
    import tomllib
except ModuleNotFoundError:
    import tomli as tomllib


from tractor._exceptions import reg_err_types
from tractor.devx.pformat import ppfmt
from .log import get_logger

log = get_logger('broker-config')


# XXX NOTE: orig impl was taken from `click`
# |_https://github.com/pallets/click/blob/main/src/click/utils.py#L449
#
# (since apparently they have some super weirdness with SIGINT and
# sudo.. no clue we're probably going to slowly just modify it to our
# own version over time..)
#
def get_app_dir(
    app_name: str,
    roaming: bool = True,
    force_posix: bool = False,

) -> str:
    '''
    Returns the config folder for the application.  The default behavior
    is to return whatever is most appropriate for the operating system.

    ----
    NOTE, below is originally from `click` impl fn, we can prolly remove?
    ----

    :param roaming: controls if the folder should be roaming or not on Windows.
                    Has no affect otherwise.
    :param force_posix: if this is set to `True` then on any POSIX system the
                        folder will be stored in the home folder with a leading
                        dot instead of the XDG config home or darwin's
                        application support folder.
    '''
    # NOTE: for testing with `pytest` we leverage the `tmp_dir`
    # fixture to generate (and clean up) a test-request-specific
    # directory for isolated configuration files such that,
    # - multiple tests can run (possibly in parallel) without data races
    #   on the config state,
    # - we don't need to ever worry about leaking configs into the
    #   system thus avoiding needing to manage config cleaup fixtures or
    #   other bothers (since obviously `tmp_dir` cleans up after itself).
    #
    # In order to "pass down" the test dir path to all (sub-)actors in
    # the actor tree we preload the root actor's runtime vars state (an
    # internal mechanism for inheriting state down an actor tree in
    # `tractor`) with the testing dir and check for it whenever we
    # detect `pytest` is being used (which it isn't under normal
    # operation).
    # if "pytest" in sys.modules:
    #     import tractor
    #     actor = tractor.current_actor(err_on_no_runtime=False)
    #     if actor:  # runtime is up
    #         rvs = tractor._state._runtime_vars
    #         import pdbp; pdbp.set_trace()
    #         testdirpath = Path(rvs['piker_vars']['piker_test_dir'])
    #         assert testdirpath.exists(), 'piker test harness might be borked!?'
    #         app_name = str(testdirpath)

    os_name: str = platform.system()
    conf_dir: Path = platformdirs.user_config_path()
    app_dir: Path = conf_dir / app_name

    # ?TODO, from `click`; can remove?
    if force_posix:
        def _posixify(name):
            return "-".join(name.split()).lower()

        return os.path.join(
            os.path.expanduser(
                "~/.{}".format(
                    _posixify(app_name)
                )
            )
        )

    log.info(
        f'Using user config directory,\n'
        f'platform.system(): {os_name!r}\n'
        f'conf_dir: {conf_dir!r}\n'
        f'app_dir: {conf_dir!r}\n'
    )
    return app_dir


_click_config_dir: Path = Path(get_app_dir('piker'))
_config_dir: Path = _click_config_dir

# NOTE: when using `sudo` we attempt to determine the non-root user
# and still use their normal config dir.
if (
    (_parent_user := os.environ.get('SUDO_USER'))
    and
    _parent_user != 'root'
):
    non_root_user_dir = Path(
        os.path.expanduser(f'~{_parent_user}')
    )
    root: str = 'root'
    _ccds: str = str(_click_config_dir)  # click config dir as string
    i_tail: int = int(_ccds.rfind(root) + len(root))
    _config_dir = (
        non_root_user_dir
        /
        Path(_ccds[i_tail+1:])  # +1 to capture trailing '/'
    )


_conf_names: set[str] = {
    'conf',  # god config
    'brokers',  # sec backend deatz
    'watchlists',  # (user defined) market lists
}

# TODO: probably drop all this super legacy, questrade specific,
# config stuff XD ?
_watchlists_data_path: Path = _config_dir / Path('watchlists.json')
_context_defaults = dict(
    default_map={
        # Questrade specific quote poll rates
        'monitor': {
            'rate': 3,
        },
        'optschain': {
            'rate': 1,
        },
    }
)


class ConfigurationError(Exception):
    'Misconfigured settings, likely in a TOML file.'


class NoSignature(ConfigurationError):
    'No credentials setup for broker backend!'

# auto-register for tractor IPC exc-marshalling.
reg_err_types([
    ConfigurationError,
    *ConfigurationError.__subclasses__(),
])


def _override_config_dir(
    path: str
) -> None:
    global _config_dir
    _config_dir = path


def _maybe_use_test_dir() -> None:
    '''
    When running under the `pytest` harness, override
    the config dir to the per-test-tmp dir "passed down"
    the actor tree via `tractor`'s runtime-vars
    inheritance mechanism.

    See the `tractor_runtime_overrides` usage in our
    `tests.conftest._open_test_pikerd()` as well as
    `.service._actor_runtime.open_piker_runtime()` for
    the root-actor's pre-loading of the var state.

    NOTE: this must be checked lazily at config-path
    access time (NOT import time) since sub-actors only
    receive runtime-vars once their `tractor` runtime
    has fully booted.

    '''
    global _config_dir
    import tractor
    actor = tractor.current_actor(
        err_on_no_runtime=False,
    )
    if actor is None:
        return

    rvs: dict = tractor.runtime._state._runtime_vars
    pvars: dict|None = rvs.get('piker_vars')
    if (
        pvars
        and
        (testdir := pvars.get('piker_test_dir'))
    ):
        testdirpath = Path(testdir)
        assert testdirpath.exists(), (
            f'piker test harness might be borked!?\n'
            f'testdirpath: {testdirpath!r}\n'
        )
        if _config_dir != testdirpath:
            _override_config_dir(testdirpath)


def _conf_fn_w_ext(
    name: str,
) -> str:
    # change this if we ever change the config file format.
    return f'{name}.toml'


def get_conf_dir() -> Path:
    '''
    Return the user configuration directory ``Path``
    on the local filesystem.

    '''
    _maybe_use_test_dir()
    return _config_dir


def get_conf_path(
    conf_name: str = 'brokers',

) -> Path:
    '''
    Return the top-level default config path normally under
    ``~/.config/piker`` on linux for a given ``conf_name``, the config
    name.

    Contains files such as:
    - brokers.toml
    - watchlists.toml

    # maybe coming soon ;)
    - signals.toml
    - strats.toml

    '''
    if 'account.' not in conf_name:
        assert str(conf_name) in _conf_names

    fn = _conf_fn_w_ext(conf_name)
    return get_conf_dir() / Path(fn)


def repodir() -> Path:
    '''
    Return the abspath as ``Path`` to the git repo's root dir.

    '''
    repodir: Path = Path(__file__).absolute().parent.parent
    confdir: Path = repodir / 'config'

    if not confdir.is_dir():
        # prolly inside stupid GH actions CI..
        repodir: Path = Path(os.environ.get('GITHUB_WORKSPACE'))
        confdir: Path = repodir / 'config'

    assert confdir.is_dir(), (
        f'{confdir} DNE, {repodir} is likely incorrect!'
    )
    return repodir


def load(
    # NOTE: always appended with .toml suffix
    conf_name: str = 'conf',
    path: Path | None = None,

    decode: Callable[
        [str | bytes,],
        MutableMapping,
    ] = tomllib.loads,

    touch_if_dne: bool = True,

    **tomlkws,

) -> tuple[dict, Path]:
    '''
    Load config file by name.

    If desired config is not in the top level piker-user config path then
    pass the `path: Path` explicitly.

    '''
    # create the $HOME/.config/piker dir if dne
    conf_dir: Path = get_conf_dir()
    if not conf_dir.is_dir():
        conf_dir.mkdir(
            parents=True,
            exist_ok=True,
        )

    path_provided: bool = path is not None
    path: Path = path or get_conf_path(conf_name)

    if (
        not path.is_file()
        and
        touch_if_dne
    ):
        # only do a template if no path provided,
        # just touch an empty file with same name.
        if path_provided:
            with path.open(mode='x'):
                pass

        # try to copy in a template config to the user's dir if one
        # exists.
        else:
            fn: str = _conf_fn_w_ext(conf_name)
            template: Path = repodir() / 'config' / fn
            if template.is_file():
                shutil.copyfile(template, path)

            elif fn and template:
                assert template.is_file(), f'{template} is not a file!?'

            assert path.is_file(), f'Config file {path} not created!?'

    with path.open(mode='r') as fp:
        config: dict = decode(
            fp.read(),
            **tomlkws,
        )

    log.debug(f"Read config file {path}")
    return config, path


def write(
    config: dict,  # toml config as dict

    name: str | None = None,
    path: Path | None = None,
    fail_empty: bool = True,

    **toml_kwargs,

) -> None:
    ''''
    Write broker config to disk.

    Create a ``brokers.ini`` file if one does not exist.

    '''
    if name:
        path: Path = path or get_conf_path(name)
        dirname: Path = path.parent
        if not dirname.is_dir():
            log.debug(f"Creating config dir {_config_dir}")
            dirname.mkdir()

    if (
        not config
        and fail_empty
    ):
        raise ValueError(
            "Watch out you're trying to write a blank config!"
        )

    log.debug(
        f"Writing config `{name}` file to:\n"
        f"{path}"
    )
    with path.open(mode='w') as fp:
        return tomlkit.dump(  # preserve style on write B)
            config,
            fp,
            **toml_kwargs,
        )


def load_accounts(
    providers: list[str]|None = None
) -> bidict[str, str|None]:

    conf, path = load(
        conf_name='brokers',
    )
    accounts = bidict({
        # XXX, default paper-engine entry; this MUST be set.
        'paper': None,
    })
    msg: str = (
        'Loading account(s) from `brokers.toml`,\n'
    )
    for (
        provider_name,
        section,
    ) in conf.items():
        accounts_section: dict[str, str] = section.get('accounts')
        if accounts_section is None:
            msg += f'No accounts declared for {provider_name!r}?\n'
            continue

        # msg += f'Loaded accounts for {provider_name!r}?\n'
        if (
            providers is None
            or (
                providers
                and
                provider_name in providers
            )
        ):
            for (
                label,
                value,
            ) in accounts_section.items():
                account_alias: str = f'{provider_name}.{label}'
                accounts[account_alias] = value
                msg += f'{account_alias} = {value!r}\n'

        else:
            log.debug(
                f'NOT loading account(s) for entry in `brokers.toml`,\n'
                f'The account provider was not requested for loading.\n'
                f'requested-providers: {providers!r}\n'
                f'this-provider: {provider_name!r}\n'
                f'\n'
                f'{ppfmt(accounts_section)}\n'
            )
            # ?TODO? mk this bp work?
            # breakpoint()

    log.info(msg)
    return accounts
