from __future__ import print_function, unicode_literals
import six
import os, sys
from io import BytesIO
from twisted.python import usage
from twisted.internet import defer

# does "flappserver start" need us to refrain from importing the reactor here?
import foolscap
from foolscap.api import Tub, Referenceable, fireEventually

class BaseOptions(usage.Options):
    def opt_h(self):
        return self.opt_help()

class UploadFileOptions(BaseOptions):
    def getSynopsis(self):
        return "Usage: flappclient [--furl=|--furlfile] upload-file SOURCEFILES.."
    def parseArgs(self, *sourcefiles):
        self.sourcefiles = sourcefiles
    longdesc = """This client sends one or more files to the upload-file
    service waiting at the given FURL. All files will be placed in the
    pre-configured target directory, using the basename of each SOURCEFILE
    argument."""

class Uploader(Referenceable):
    def run(self, rref, sourcefile, name):
        self.f = open(os.path.expanduser(sourcefile), "rb")
        return rref.callRemote("putfile", six.ensure_binary(name), self)

    def remote_read(self, size):
        return self.f.read(size)

class UploadFile(Referenceable):
    def run(self, rref, options):
        d = defer.succeed(None)
        for sf in options.sourcefiles:
            name = os.path.basename(sf)
            d.addCallback(self._upload, rref, sf, name)
            d.addCallback(self._done, options, name)
        d.addCallback(lambda _ign: 0)
        return d
    def _upload(self, _ignored, rref, sf, name):
        return Uploader().run(rref, sf, name)
    def _done(self, _ignored, options, name):
        options.stdout.write(six.ensure_binary("%s: uploaded\n" % name))


class RunCommandOptions(BaseOptions):
    def getSynopsis(self):
        return "Usage: flappclient [--furl=|--furlfile] run-command"
    longdesc = """This client triggers a prearranged command to be executed
    by the run-command service waiting at the given FURL. The executable, its
    working directory, and all arguments are configured by the server. Unless
    the server has overridden the defaults, this client will emit the
    command's stdout and stderr as it runs, and will exit with the same
    result code as the remote command. If the server desires it, this client
    will read data from stdin and send everything (plus a close-stdin event)
    to the server.

    This client has no control over the command being run or its
    arguments."""


from twisted.internet.stdio import StandardIO as TwitchyStandardIO
class StandardIO(TwitchyStandardIO):
    def childConnectionLost(self, fd, reason):
        # the usual StandardIO class doesn't seem to handle half-closed stdio
        # well, specifically when our stdout is closed, then some data is
        # written to our stdin. The class responds to stdout's closure by
        # shutting down everything. I think this is related to
        # ProcessWriter.doRead returning CONNECTION_LOST instead of
        # CONNECTION_DONE (which ProcessWriter.connectionLost sends along to
        # StandardIO.childConnectionLost). There is code in
        # StandardIO.childConnectionLost to treat CONNECTION_DONE as a
        # half-close, but not CONNECTION_LOST.
        #
        # so this hack is to make it look more like a half-close
        #print >>sys.stderr, "my StandardIO.childConnectionLost", fd, reason.value
        from twisted.internet import error, main
        from twisted.python import failure
        if reason.check(error.ConnectionLost) and fd == "write":
            #print >>sys.stderr, " fixing"
            reason = failure.Failure(main.CONNECTION_DONE)
        return TwitchyStandardIO.childConnectionLost(self, fd, reason)

from twisted.internet.protocol import Protocol
#from zope.interface import implements
#from twisted.internet.interfaces import IHalfCloseableProtocol

def wrap_in_binary_mode(f):
    if hasattr(f, "buffer"):
        # py3 "text file", as returned by open(), or sys.std(in|out|err)
        return f.buffer # _io.BufferedWriter
    assert isinstance(f, BytesIO)
    return f

class RunCommand(Referenceable, Protocol):
    #implements(IHalfCloseableProtocol)
    def run(self, rref, options):
        self.done = False
        self.d = defer.Deferred()
        rref.notifyOnDisconnect(self._done, 3)
        self.stdin_writer = None
        self.stdio = options.stdio
        self.stdout = options.stdout
        self.stderr = options.stderr
        d = rref.callRemote("execute", self)
        d.addCallback(self._started)
        d.addErrback(self._err)
        return self.d

    def dataReceived(self, data):
        if not isinstance(data, bytes):
            raise TypeError("stdin can accept only strings of bytes, not '%s'"
                            % (type(data),))
        # this is from stdin. It shouldn't be called until after _started
        # sets up stdio and self.stdin_writer
        self.stdin_writer.callRemoteOnly("feed_stdin", data)

    def connectionLost(self, reason):
        # likewise, this won't be called unless _started wanted stdin
        self.stdin_writer.callRemoteOnly("close_stdin")

    def _started(self, stdin_writer):
        if stdin_writer:
            self.stdin_writer = stdin_writer # rref
            self.stdio(self) # start accepting stdin
        # otherwise they don't want our stdin, so leave stdin_writer=None

    def remote_stdout(self, data):
        #print(b"remote_stdout", type(data))
        assert isinstance(data, bytes)
        #print(data)
        self.stdout.write(data)
        self.stdout.flush()
        #print(b"flushed stdout")
    def remote_stderr(self, data):
        assert isinstance(data, bytes)
        self.stderr.write(data)
        self.stderr.flush()
    def remote_done(self, signal, exitcode):
        if signal:
            self._done(127)
        else:
            self._done(exitcode)
    def _err(self, f):
        self._done(f)
    def _done(self, res):
        if not self.done:
            self.done = True
            self.d.callback(res)

class ClientOptions(usage.Options):
    synopsis = "Usage: flappclient [--furl=|--furlfile=] COMMAND"

    optParameters = [
        ("furl", None, None, "FURL of the service to contact"),
        ("furlfile", "f", None, "file containing the FURL of the service"),
        ]

    longdesc = """This client invokes a remote service that is running as
    part of a 'flappserver'. Each service lives at a specific secret FURL,
    which starts with 'pb://'. This FURL can be passed on the command line
    with --furl=FURL, or it can be stored in a file (along with comment lines
    that start with '#') and passed with --furlfile=FILE.

    Each service has a specific COMMAND type, and the client invocation must
    match the service. For more details on a specific command, run
    'flappclient COMMAND --help', e.g. 'flappclient upload-file --help'.
    """

    subCommands = [
        ("upload-file", None, UploadFileOptions, "upload a file"),
        ("run-command", None, RunCommandOptions, "cause a command to be run"),
        ]

    def read_furlfile(self):
        ff = os.path.expanduser(self["furlfile"])
        for line in open(ff).readlines():
            line = line.strip()
            if line.startswith("pb://"):
                return line
        return None

    def postOptions(self):
        self.furl = self["furl"]
        if self["furlfile"]:
            self.furl = self.read_furlfile()
        if not self.furl:
            raise usage.UsageError("must provide --furl or --furlfile")
        if not hasattr(self, 'subOptions'):
            raise usage.UsageError("must specify a command")

    def opt_help(self):
        self.stdout.write(six.ensure_binary("%s\n" % (self.synopsis,)))
        sys.exit(0)

    def opt_version(self):
        from twisted import copyright
        self.stdout.write(six.ensure_binary("Foolscap version: %s\n" % foolscap.__version__))
        self.stdout.write(six.ensure_binary("Twisted version: %s\n" % copyright.version))
        sys.exit(0)

dispatch_table = {
    "upload-file": UploadFile,
    "run-command": RunCommand,
    }


def parse_options(command_name, argv, stdio, stdout, stderr):
    try:
        config = ClientOptions()
        config.stdout = stdout
        config.stderr = stderr
        config.parseOptions(argv)

        config.subOptions.stdio = stdio # for streaming input
        config.subOptions.stdout = stdout
        config.subOptions.stderr = stderr

    except usage.error as e:
        stderr.write(six.ensure_binary("%s:  %s\n" % (command_name, e)))
        stderr.write(b"\n")
        c = getattr(config, 'subOptions', config)
        stderr.write(six.ensure_binary("%s\n" % (c,)))
        sys.exit(1)

    return config

def run_command(config):
    c = dispatch_table[config.subCommand]()
    tub = Tub()
    try:
        from twisted.internet import reactor
        from twisted.internet.endpoints import clientFromString
        from foolscap.connections import tor
        CONTROL = os.environ.get("FOOLSCAP_TOR_CONTROL_PORT", "")
        SOCKS = os.environ.get("FOOLSCAP_TOR_SOCKS_PORT", "")
        if CONTROL:
            h = tor.control_endpoint(clientFromString(reactor, CONTROL))
            tub.addConnectionHintHandler("tor", h)
        elif SOCKS:
            h = tor.socks_endpoint(clientFromString(reactor, SOCKS))
            tub.addConnectionHintHandler("tor", h)
        #else:
        #    h = tor.default_socks()
        #    tub.addConnectionHintHandler("tor", h)
    except ImportError:
        pass
    d = defer.succeed(None)
    d.addCallback(lambda _ign: tub.startService())
    d.addCallback(lambda _ign: tub.getReference(config.furl))
    d.addCallback(c.run, config.subOptions) # might provide tub here
    d.addBoth(lambda res: tub.stopService().addCallback(lambda _ign: res))
    return d


def run_flappclient(argv=None, run_by_human=True, stdio=StandardIO):
    if run_by_human:
        stdout = wrap_in_binary_mode(sys.stdout)
        stderr = wrap_in_binary_mode(sys.stderr)
    else:
        stdout = BytesIO()
        stderr = BytesIO()
    if argv:
        command_name,argv = argv[0],argv[1:]
    else:
        command_name = sys.argv[0]

    d = fireEventually()
    d.addCallback(lambda _ign: parse_options(command_name, argv,
                                             stdio, stdout, stderr))
    d.addCallback(run_command)

    if run_by_human:
        # we need to spin up our own reactor
        from twisted.internet import reactor
        stash_rc = []
        def good(rc):
            stash_rc.append(rc)
            reactor.stop()
        def oops(f):
            if f.check(SystemExit):
                stash_rc.append(f.value.args[0])
            else:
                stderr.write(b"flappclient command failed:\n")
                stderr.write(six.ensure_binary("%s\n" % (f,)))
                stash_rc.append(-1)
            reactor.stop()
        d.addCallbacks(good, oops)
        reactor.run()
        sys.exit(stash_rc[0])
    else:
        def _convert_system_exit(f):
            f.trap(SystemExit)
            return f.value.args[0]
        d.addErrback(_convert_system_exit)
        def done(rc):
            return (rc, stdout.getvalue(), stderr.getvalue())
        d.addCallback(done)
        return d
