"""
Command-line code for moat.micro
"""

# pylint: disable=import-outside-toplevel
from __future__ import annotations

import anyio
import logging
import os
import sys
from functools import wraps
from pathlib import Path as FSPath
from shutil import which

import asyncclick as click

from moat.util import (
    NotGiven,
    SigCancel,
    attrdict,
    combine_dict,
    get_part,
    merge,
    to_attrdict,
    yload,
    yprint,
)
from moat.lib.codec import get_codec
from moat.lib.codec.errors import NoPathError, RemoteError
from moat.lib.micro import idle
from moat.lib.path import (
    P,
)
from moat.lib.rpc import Msg, RootCmd
from moat.lib.run import attr_args, load_subgroup, process_args
from moat.micro.util import TEST_MAGIC

logger = logging.getLogger(__name__)


skip_exc = {FileNotFoundError, FileExistsError, ConnectionRefusedError}


def catch_errors(fn):
    """
    Wrapper for commands so that some errors don't cause a stack trace.
    """

    @wraps(fn)
    async def wrapper(*a, **k):
        try:
            return await fn(*a, **k)
        except (NoPathError, ConnectionRefusedError) as e:
            raise click.ClickException(str(e))  # noqa:B904
        except Exception as e:
            if "bdb" in sys.modules:
                skip_exc.add(sys.modules["bdb"].BdbQuit)
            if type(e) in skip_exc and "MOAT_TB" not in os.environ:
                raise click.ClickException(repr(e))  # noqa:B904
            raise

    return wrapper


@load_subgroup(
    prefix="moat.micro",
    epilog="""
        The 'section' parameter says which part of the MoaT configuration
        (below "moat.micro") to use to connect to the remote system.
        The defaults are:

        \b
        Command  Section
        =======  =======
        run      run        server mode
        setup,   setup      used for initial non-MoaT access to the device
         install
        *        connect    used for all other commands

        The 'remote' parameter specifies the prefix that talks to
        the remote system. The default is 'r'.

        """,
)
@click.pass_context
@click.option("-S", "--section", type=P, help="Section to use")
@click.option("-R", "--remote", type=P, help="Path for talking to the satellite")
@click.option("-P", "--path", type=(str, P), multiple=True, help="named remote component")
async def cli(ctx, section, remote, path):
    """Run MicroPython satellites

    'moat micro' configures MoaT satellites and runs the link to them,
    as well as applications using it."""
    obj = ctx.obj
    cfg = obj.cfg.micro
    remote2 = None
    if "--help" in sys.argv:
        return  # HACK

    inv = ctx.invoked_subcommand
    if inv == "run":
        if remote is not None:
            raise click.UsageError("You don't use a remote with 'moat micro run'")
        if path:
            raise click.UsageError("You don't use paths with 'moat micro run'")
        if section is None:
            section = P("run")
    elif inv in ("setup", "install"):
        if remote is not None:
            remote2, remote = remote, None
        else:
            remote2 = P("s")
        if path:
            raise click.UsageError("You don't use paths with 'moat micro setup|install'")
        if section is None:
            section = P("setup")
    elif inv == "path":
        pass
    else:
        if section is None:
            section = P("connect")
    if section is not None:
        try:
            cfg = get_part(cfg, section)
        except KeyError:
            raise click.UsageError(f"The config section '{section}' doesn't exist.") from None
    if remote2 is not None:
        cfg.remote2 = remote2

    if remote is not None:
        cfg.remote = remote
    elif "remote" not in cfg:
        cfg.remote = P("r")
    pth = cfg.setdefault("path", {})
    for n, p in path:
        pth[n] = p
    for k, v in pth.items():
        if isinstance(v, str):
            v = P(v)  # noqa:PLW2901
        pth[k] = v

    obj.mcfg = to_attrdict(cfg)


@cli.command(name="setup", short_help="Copy MoaT to MicroPython")
@click.pass_context
@click.option("-i", "--install", is_flag=True, help="Install MicroPython")
@click.option("-r", "--run", is_flag=True, help="Run MoaT after updating")
@click.option("-F", "--rom", is_flag=True, help="Upload to ROM Flash")
@click.option("--run-section", type=P, help="Section with runtime config (default: 'run')")
@click.option("-N", "--reset", is_flag=True, help="Reboot after updating")
@click.option("-K", "--kill", is_flag=True, help="Reboot initially")
@click.option(
    "-S",
    "--source",
    type=click.Path(dir_okay=True, file_okay=True, path_type=anyio.Path),
    help="Files to sync",
)
@click.option("-D", "--dest", type=str, default="", help="Destination path")
@click.option("-R", "--root", type=str, default=".", help="Destination root")
@click.option("-U/-V", "--update/--no-update", is_flag=True, help="Run standard updates")
@click.option("-l/-L", "--large/--no-large", is_flag=True, help="Use more RAM")
@click.option("-s", "--state", type=str, help="State to enter by default")
@click.option("-c", "--config", type=P, help="Config part to use for the device")
@click.option("-w", "--watch", is_flag=True, help="monitor the target's output after setup")
@click.option("-C", "--cross", help="path to mpy-cross")
@click.option("-M", "--mode", help="Mode add_on (filename, section)")
@click.option(
    "-m",
    "--main",
    type=click.Path(dir_okay=False, readable=True, exists=True),
    help="file to use as main_.py",
)
@catch_errors
async def setup_(ctx, run_section=None, mode=None, **kw):
    """
    Initial sync of MoaT code to a MicroPython device.

    MoaT must not currently run on the target.
    """
    from .setup import setup  # noqa: PLC0415

    ocfg = ctx.obj.cfg["micro"]

    cfg = ctx.obj.mcfg
    cfg.args.config = get_part(
        ocfg,
        cfg.args.config[:-1] / f"{cfg.args.config[-1]}_{mode}"
        if mode is not None
        else cfg.args.config,
    )

    default = {
        k: v
        for k, v in kw.items()
        if ctx.get_parameter_source(k) == click.core.ParameterSource.DEFAULT
    }
    param = {
        k: v
        for k, v in kw.items()
        if ctx.get_parameter_source(k) != click.core.ParameterSource.DEFAULT
    }

    # teach the 'large' flag to be ternary
    if "large" in default:
        default["large"] = None

    st = {
        k: (v if v != "-" else NotGiven) for k, v in cfg.setdefault("args", {}).items() if k in kw
    }

    st = combine_dict(param, st, default)

    st["config_file"] = f"moat_{mode}.cfg" if mode is not None else "moat.cfg"

    run = st["run"]
    if run:
        if run_section is not None:
            st["run"] = ctx.obj.cfg.micro._get(run_section)  # noqa: SLF001
        else:
            try:
                st["run"] = ctx.obj.cfg.micro._get(P("setup.run"))  # noqa: SLF001
            except KeyError:
                st["run"] = ctx.obj.cfg.micro["run"]

    return await setup(cfg, **st)


@cli.command("sync", short_help="Sync MoaT code")
@click.pass_context
@click.option(
    "-s",
    "--source",
    type=click.Path(dir_okay=True, file_okay=True, path_type=anyio.Path),
    multiple=True,
    help="more files to sync",
)
@click.option("-d", "--dest", type=str, default=".", help="Destination path")
@click.option("-C", "--cross", help="path to mpy-cross")
@click.option("-B/-b", "--boot/--no-boot", help="Reboot after updating")
@click.option("-U/-V", "--update/--no-update", is_flag=True, help="Run standard updates")
@catch_errors
async def sync_(ctx, **kw):
    """
    Sync of MoaT code to a running MicroPython device.

    """
    from .files import MoatFSPath  # noqa: PLC0415
    from .setup import do_copy, do_update  # noqa: PLC0415

    obj = ctx.obj
    cfg = obj.mcfg

    default = {
        k: v
        for k, v in kw.items()
        if ctx.get_parameter_source(k) == click.core.ParameterSource.DEFAULT
    }

    mydir = FSPath(__file__).parent.parent.parent
    mpydir = mydir / "ext" / "micropython"
    cross = mpydir / "mpy-cross" / "build" / "mpy-cross"
    if not cross.exists():
        cross = which("mpy-cross")
    default["cross"] = cross

    param = {
        k: v
        for k, v in kw.items()
        if ctx.get_parameter_source(k) != click.core.ParameterSource.DEFAULT
    }
    st = {k: (v if v != "-" else NotGiven) for k, v in cfg.get("sync", {}).items() if k in kw}
    st = combine_dict(param, st, default)

    async def syn(source=(), dest=".", cross=None, update=False, boot=False):
        if cross == "-":
            cross = None
        dest = dest.lstrip("/")  # needs to be relative
        if not update and not source:
            if obj.debug:
                print("Nothing to do.", file=sys.stderr)
            return
        if dest is None:
            raise click.UsageError("Destination cannot be empty")
        async with (
            RootCmd(cfg) as dsp,
            dsp.sub_at(cfg.remote) as cfr,
            cfr.sub_at(cfg.path.fs) as rfs,
            cfr.sub_at(cfg.path.sys) as rsys,
        ):
            root = MoatFSPath("/").connect_repl(rfs)
            dst = MoatFSPath(dest).connect_repl(rfs)

            async def hsh(p):
                return await rsys.hash(p=p)

            if update:
                await do_update(dst, root, cross, hsh)
            for s in source:
                await do_copy(s, root, dest, cross if cross != "-" else None)
            if boot:
                await rsys.boot(code="SysBooT", m=1)

    await syn(**st)
    if obj.debug:
        print(" " * 50, end="\r")


@cli.command(short_help="Reboot MoaT node")
@click.pass_obj
@click.option("-s", "--state", help="State after reboot")
@catch_errors
async def boot(obj, state):
    """
    Restart a MoaT node

    """
    cfg = obj.mcfg
    async with (
        RootCmd(cfg) as dsp,
        dsp.sub_at(cfg.remote) as cfr,
        cfr.sub_at(cfg.path.sys) as sd,
    ):
        if state:
            await sd.state(state=state)

        # reboot via the multiplexer
        logger.info("Rebooting target.")
        await sd.boot(code="SysBooT", m=1)

        # await t.send(["sys","boot"], code="SysBooT")
        await anyio.sleep(2)

        res = await sd("test")
        assert res == TEST_MAGIC, res

        res = await sd("ping", m="pong")
        if res != "R:pong":
            raise RuntimeError("wrong reply")
        print("Success:", res, file=sys.stderr)


@cli.command(short_help="Send a MoaT command")
@click.pass_obj
@click.argument("path", nargs=1, type=P)
@attr_args(with_path=True, with_proxy=True, with_arglist=True)
@click.option("-a", "--parts", is_flag=True, help="Retrieve a possibly-partial result")
@click.option("-t", "--time", is_flag=True, help="Time the command")
@click.option("-S", "--stream", is_flag=True, help="Get data stream from remote")
@catch_errors
async def cmd(obj, path, time, parts, stream, **attrs):
    """
    Send a MoaT command.

    The command is prefixed by the "micro.connect.remote" option;
    use "moat micro -R ‹path› cmd …" to change it if necessary.

    Positional arguments may follow the command name. They are parsed as
    with `--set`.
    """
    if parts and stream:
        raise click.UsageError("Parts and Stream modes don't like each other (yet).")
    cfg = obj.mcfg
    val = process_args({None: []}, no_path=True, **attrs)
    args = val.pop(None)
    logger.debug(
        "Command: %s %s %s",
        cfg.remote + path,
        "-" if not args else " ".join(str(a) for a in args),
        "-" if not val else " ".join(f"{k}={v!r}" for k, v in val.items()),
    )

    from time import monotonic as tm  # noqa: PLC0415

    from moat.util.times import humandelta  # noqa: PLC0415

    t1 = tm()
    async with (
        SigCancel(),
        RootCmd(cfg) as dsp,
        dsp.sub_at(cfg.remote) as cfr,
    ):
        try:
            t2 = tm()
            cmd = cfr.sub_at(path)
            if parts:
                from moat.lib.rpc import SubStore  # noqa: PLC0415

                res = await SubStore(cmd).get(*args, **val)
            elif stream:
                async with cmd.stream_in(*args, **val) as ms:
                    d = ms.to_list()
                    if d:
                        yprint(d)
                        print("---", file=obj.stdout)
                    async for m in ms:
                        yprint(m.to_list())
                        print("---", file=obj.stdout)
                    d = ms.to_list()
                    if d:
                        yprint(d)
                        print("---", file=obj.stdout)
            else:
                res = await cmd(*args, **val)
        except RemoteError as err:
            t3 = tm()
            yprint(dict(e=str(err.args[0])), stream=obj.stdout)
        else:
            if not stream:
                t3 = tm()
                if isinstance(res, Msg):
                    res = [res.args, res.kw]
                yprint(res, stream=obj.stdout)
    if time:
        print(f"{humandelta(t3 - t2)} (setup {humandelta(t2 - t1)})")


@cli.command(short_help="Read a console")
@click.pass_obj
@click.argument("path", nargs=1, type=P)
@catch_errors
async def cons(obj, path):
    """
    Read a Moat console.

    The command repeatedly calls the "crd" function on the given path and
    streams the result.
    """
    cfg = obj.mcfg
    async with (
        RootCmd(cfg) as dsp,
        dsp.sub_at(cfg.remote) as cfr,
    ):
        crd = cfr.sub_at(path).crd
        while True:
            try:
                res = await crd()
            except RemoteError as err:
                print(f"\nERR: {err!r}\n")
            else:
                print(res.decode("utf-8", errors="replace"), end="")


@cli.command("cfg", short_help="Get / Update the configuration")
@click.pass_obj
@click.option("-c", "--config", is_flag=True, help="Use MoaT config")
@click.option("-C", "--config-sat", is_flag=True, help="Use the satellite config")
@click.option("-r", "--read", type=click.File("r"), help="Read config from this file")
@click.option("-R", "--read-sat", help="Read config file from the satellite")
@click.option("-w", "--write", type=click.File("w"), help="Write config to this file")
@click.option("--stdout", is_flag=True, hidden=True)
@click.option("-W", "--write-sat", help="Write config file to the satellite")
@click.option("-S", "--sync", is_flag=True, help="Sync the satellite after writing")
@click.option("-D", "--send", is_flag=True, help="Also send to the satellite")
@click.option("-A", "--auth", is_flag=True, help="Authoritative: clear other satellite data")
@click.option(
    "-I",
    "--prio",
    type=str,
    default="mlrsa",
    help="Source priority: (first match wins)"
    " m:moat config l:local file r:remote file s:satellite config a:s-arguments",
)
@attr_args(with_proxy=True)
@catch_errors
async def cfg_(
    obj,
    read,
    read_sat,
    config,
    config_sat,
    write,
    write_sat,
    sync,
    send,
    stdout,
    auth,
    prio,
    **attrs,
):
    """
    Print, update and/or modify a remote configuration.

    The default for reading a config is the remote system. If modifiers (``-s …``)
    are used but no write option (``-w``, ``-W``), the remote config is
    updated in-place without being transferred.

    The default for writing is the remote system if modifiers have been
    used (and no write option). The remote config is updated anyway if you
    use ``--sync``.

    Configuration is read as YAML from stdin (``-r -``) or a
    file (``-r PATH``), as CBOR from Flash (``-R xx.cfg``), or from MoaT
    config (``-c``). Use satellite's memory (``-R -``),

    If you use multiple config sources, the ``--prio`` argument controls
    which data are preferred. The last match wins.

    An "app" section must be present if you write a complete configuration
    to the satellite.

    This command assumes that the remote system supplies a ``cfg.Cmd`` app at
    path "r.s.cfg_", and a ``fs.Cmd`` app at path "r.s.fs". You can change
    these paths with the ``-P fs ‹path›`` and ``-P cfg ‹path›`` options to
    ``moat micro``.
    """
    # add missing keys to prio
    for a in "mlrsa":
        if a not in prio:
            prio = a + prio

    if write == "-":
        stdout = True
        write = None

    has_attrs = any(a for a in attrs.values())

    arg_src = read or read_sat or config or config_sat
    arg_dest_ns = write or write_sat or stdout
    arg_dest = arg_dest_ns or sync

    if auth:
        sync = True
    if sync:
        send = True

    if (has_attrs or sync) and not auth and not arg_dest_ns:
        # we might be OK with incomplete data
        if not arg_dest_ns:
            send = True
        elif arg_dest_ns and not arg_src:
            # we have a destination but not complete source? that won't do
            config_sat = True
    else:
        # no attrs or auth = we want complete data
        if not arg_dest:
            # default to writing to stdout
            stdout = True
        if not arg_src:
            # default to input from remote config
            config_sat = True

    # do we need complete full data?
    if arg_dest_ns or auth:
        # do we not read it from anywhere?
        if not read and not read_sat and not config and not config_sat:
            # well, do read it then.
            config_sat = True

    mcfg = obj.mcfg

    if read_sat or write_sat:
        from .files import MoatFSPath  # noqa: PLC0415

    p_cfg = mcfg.path.get("cfg", P("cfg_"))
    p_fs = mcfg.path.get("fs", P("fs"))
    async with (
        SigCancel(),
        RootCmd(mcfg) as dsp,
        dsp.sub_at(mcfg.remote) as cfr,
        cfr.cfg_at(p_cfg) as cf,
        cfr.sub_at(p_fs) as fs,
    ):
        codec = get_codec("std-cbor")

        rcfg = attrdict()
        for k in prio:
            if k == "a":
                rcfg = process_args(rcfg, **attrs)
            elif k == "m":
                if config:
                    mc = obj.cfg.micro
                    merge(rcfg, mc.get_(mc.setup.args.config), replace=True)
            elif k == "l":
                if read:
                    merge(rcfg, yload(read if read != "-" else sys.stdin), replace=True)
            elif k == "r":
                if read_sat:
                    p = MoatFSPath(read_sat).connect_repl(fs)
                    d = await p.read_bytes(chunk=64)
                    merge(rcfg, codec.decode(d), replace=True)
            elif k == "s":
                if config_sat:
                    merge(rcfg, await cf.get(), replace=True)
            else:
                raise click.UsageError(f"Unknown prio key: {k!r}")

        if write_sat:
            if "app" in rcfg:
                p = MoatFSPath(write_sat).connect_repl(fs)
                d = codec.encode(rcfg)
                await p.write_bytes(d, chunk=64)
            else:
                print("No 'app' section. Not writing.", file=sys.stderr)

        if send:
            if not auth or ("app" in rcfg and "app" in rcfg.app):
                await cf.set(rcfg, sync=sync, replace=auth)
            else:
                print("No 'app' section. Not replacing.", file=sys.stderr)

        if write:
            yprint(rcfg, stream=write)

        if stdout:
            yprint(rcfg, stream=obj.stdout)


@cli.command("run", short_help="Run the multiplexer")
@click.pass_obj
@catch_errors
async def run_(obj):
    """
    Run the MoaT stack.
    """
    async with SigCancel(), RootCmd(obj.mcfg):
        await idle()


@cli.command("mount")
@click.option("-b", "--blocksize", type=int, help="Max read/write message size", default=256)
@click.argument("path", type=click.Path(file_okay=False, dir_okay=True), nargs=1)
@click.pass_obj
@catch_errors
async def mount_(obj, path, blocksize):
    """Mount a controller's file system on the host"""
    from moat.micro.fuse import wrap  # noqa: PLC0415

    cfg = obj.mcfg

    async with (
        SigCancel(),
        RootCmd(cfg) as dsp,
        dsp.sub_at(cfg.remote) as cfr,
        cfr.sub_at(cfg.path.fs) as sd,
        wrap(sd, path, blocksize=blocksize, debug=max(obj.debug - 1, 0)),
    ):
        if obj.debug:
            print("Mounted.")
        await idle()


@cli.command("rom")
@click.option("-d", "--device", type=int, help="ROMFS segment to use", default=0)
@click.argument("path", type=click.Path(file_okay=False, dir_okay=True), nargs=1)
@click.pass_obj
@catch_errors
async def rom(obj, path, device):
    """Send a file system to the device's ROM"""
    path  # noqa:B018
    raise NotImplementedError

    cfg = obj.mcfg

    async with (
        RootCmd(cfg) as dsp,
        dsp.sub_at(cfg.remote) as cfr,
        cfr.sub_at(cfg.path.rom) as sd,
    ):
        res = await sd.n()
        if device >= res:
            if res == 1:
                raise RuntimeError("Device has only one ROMFS.")
            elif not res:
                raise RuntimeError("Device does not have ROMFS.")
            raise RuntimeError("Device 0…{res-1} only.")

        _nblk, _blksz = await sd.stat()

        if obj.debug:
            print("Building ROMFS.")

        # TODO


@cli.command("path")
@click.pass_obj
@click.option("-m", "--manifest", is_flag=True, help="main manifest")
@catch_errors
async def path_(obj, manifest):
    """Path to the embedded system's files"""

    import pathlib  # noqa: PLC0415

    if manifest:
        import moat.micro._embed._tag as m  # noqa: PLC0415

        print(m.__file__.replace("_tag", "manifest"), file=obj.stdout)
        return

    import moat.micro._embed  # noqa: PLC0415

    for p in moat.micro._embed.__path__:  # noqa:SLF001
        p = pathlib.Path(p) / "lib"  # noqa:PLW2901
        if p.exists():
            print(p)


@cli.command
@click.pass_obj
async def repl(obj):
    """Connect to the Python prompt on a remote"""

    cfg = obj.mcfg
    from moat.lib.stream import FilenoBuf  # noqa:PLC0415

    async with (
        SigCancel(),
        RootCmd(cfg) as dsp,
        dsp.sub_at(cfg.remote) as cfr,
        cfr.sub_at(cfg.terminal) as cft,
        cft().stream() as t1,
        FilenoBuf({}, 0, 1) as t2,
        anyio.create_task_group() as tg,
    ):
        print("--- Connected.  Quit: Ctrl+] --- ", file=sys.stderr)

        @tg.start_soon
        async def t_12():
            async for msg in t1:
                tx = msg[0]
                if tx and tx[0] == 0:
                    await t2.wr("[………]".encode("utf-8"))
                    tx = tx[1:]
                await t2.wr(tx)
            tg.cancel_scope.cancel()

        buf = bytearray(80)
        while True:
            try:
                n = await t2.rd(buf)
            except EOFError:
                tg.cancel_scope.cancel()
                break
            if b"\x1d" in buf[:n]:
                tg.cancel_scope.cancel()
                break
            await t1.send(buf[:n].replace(b"\n", b"\r"))
    print("\nTerminated.", file=sys.stderr)
