#!/usr/bin/env python3
"""Main CLI module"""
from typing import Optional, Union, List
from contextlib import contextmanager
from chapps.models import (
    User,
    Email,
    Domain,
    Quota,
    user_quota_assoc,
    user_emails_assoc,
    user_domains_assoc,
)
from chapps.policy import (
    EmailPolicy,
    OutboundQuotaPolicy,
    GreylistingPolicy,
    SenderDomainAuthPolicy,
)
from chapps.config import CHAPPSConfig
from chapps.dbsession import sql_engine, sessionmaker
from chapps.alembic.apply import main as apply_migrations
from chapps.util import hash_password
from chapps._version import __version__
from sqlalchemy.exc import IntegrityError
from pathlib import Path
import typer
import validators
import json as JSON
import time

# from pprint import pformat


try:
    from chapps.spf_policy import SPFEnforcementPolicy

    HAVE_SPF = True
except Exception:
    HAVE_SPF = False

TIMEFMT = "%b %d %Y %T %Z"

app = typer.Typer(help="CHAPPS Configuration Management CLI")
admin_app = typer.Typer(help="CHAPPS administration subcommands")
domain_app = typer.Typer(help="CHAPPS Domain settings subcommands")
app.add_typer(admin_app, name="admin")
app.add_typer(domain_app, name="domain")
Session = sessionmaker(sql_engine)
association = {
    "Email": user_emails_assoc,
    "Domain": user_domains_assoc,
    "Quota": user_quota_assoc,
}

MIN_IMPORT_LINE_LENGTH = 6 + 5 + 4  # assuming tiniest emails and domains
"""There is always an operation and there are 2 colons, username, and a resource"""

NO = ["0", "n", "N", "no", "No", "NO", "F", "false", "FALSE"]


class CHAPPS_CLI_Exception(Exception):
    """Superclass for CLI Exceptions"""


class UnrecoverableException(CHAPPS_CLI_Exception):
    """
    Raising this error indicates that the underlying routine cannot continue
    """


class NoSuchUserException(UnrecoverableException):
    """No such User as the one provided exists in the control DB"""


class NoSuchDomainException(UnrecoverableException):
    """No such Domain as the one provided exists in the control DB"""


class NoSuchAssocException(UnrecoverableException):
    """No matching associated resource could be found"""


class NoSuchQuotaException(UnrecoverableException):
    """No matching quota record cold be found"""


class ImportParseError(UnrecoverableException):
    """A line in the imported file could not be parsed"""


class NoSuchOperationError(ImportParseError):
    """A line in the imported file contained an unrecognized operation"""


def assocType(resource):
    """Determine resource type

    .. todo::

      Add validation to resource-type ID in CLI
    """
    return Email if "@" in resource else Domain


@contextmanager
def handle_cli_exceptions():
    try:
        yield
    except UnrecoverableException as e:
        raise typer.Exit(code=1)


def _print(msg):
    typer.echo(msg)


def _b(msg):
    return typer.style(msg, fg=typer.colors.WHITE, bold=True)


def _red(msg):
    return typer.style(msg, fg=typer.colors.RED, bold=True)


def _yellow(msg):
    return typer.style(msg, fg=typer.colors.YELLOW, bold=True, underline=True)


def _alert(msg):
    b = _red(">>>")
    e = _red("<<<")
    m = _yellow(msg)
    return " ".join([b, m, e])


def user_or_die(sess: Session, username: str) -> Optional[str]:
    user = sess.execute(User.select_by_name(username)).scalar()
    if user is None:
        _print(
            f"Cannot find user {_b(username)}\nPerhaps they "
            "are identified some other way?"
        )
        raise NoSuchUserException(f"No such user '{username}'.")
    return user


def domain_or_die(sess: Session, domainname: str) -> Optional[str]:
    domain = sess.execute(Domain.select_by_name(domainname)).scalar()
    if domain is None:
        _print(
            f"Cannot find domain {_b(domainname)}\nPlease check "
            "the spelling."
        )
        raise NoSuchDomainException(f"No such domain '{domainname}'.")
    return domain


def showUser(username: str, *, quota: bool = False):
    with Session() as sess:
        user = user_or_die(sess, username)
        q = user.quota
        u_quota = Quota.wrap(q)
        emails = Email.wrap(user.emails)
        domains = Domain.wrap(user.domains)
        user = User.wrap(user)
        _print(
            f"User: {user}\n  Quota: {u_quota}\n  E: {emails}\n  D: {domains}"
        )
    if quota and q:
        oqp = OutboundQuotaPolicy()
        avail, remarks = oqp.current_quota(username, q)
        limit = oqp.redis.get(oqp._fmtkey(username, "limit"))
        limit = limit.decode("utf-8") if limit else "none"
        _print(f"Outbound email quota remaining: {_b(avail)}/{limit} (cached)")
        if remarks:
            _print("\n".join(remarks))
    if q is None:
        _print(
            _alert(
                "This user has no quota policy assigned and so "
                "cannot send mail"
            )
        )
    if len(domains) + len(emails) < 1:
        _print(
            _alert(
                "This user has no domains or emails assigned "
                "and so cannot send mail"
            )
        )


def showDomain(domainname: str, live: bool = True, users: bool = False):
    domain_users = []
    with Session() as sess:
        domain = domain_or_die(sess, domainname)
        if users:
            domain_users = User.wrap(domain.users)
    grl = GreylistingPolicy()
    if HAVE_SPF:
        spf = SPFEnforcementPolicy()
    grl_cache, spf_cache = ["--"] * 2
    _print(domain)
    if live:
        try:
            grl_cache = grl.redis.get(
                grl._domain_option_key(domainname)
            ).decode(grl.config.chapps.payload_encoding)
            if HAVE_SPF:
                spf_cache = spf.redis.get(
                    spf._domain_option_key(domainname)
                ).decode(spf.config.chapps.payload_encoding)
        except AttributeError:
            pass
        _print(
            "Cached option values: SPF: "
            + spf_cache
            + " Greylisting: "
            + grl_cache
        )
    if users:
        _print(
            "Users:\n"
            + "\n".join([f"  {u.name} [#{u.id}]" for u in domain_users])
        )


@app.command()
def version():
    """Report the current CHAPPS version"""
    _print(f"This is CHAPPS version {__version__}")


@domain_app.command("show")
def domain_show(domainname: str, live: bool = True, users: bool = False):
    """Show information about a Domain

    Optionally list the Domain's Users in a brief way.

    By default, the option-cache status is also displayed, but may be disabled
    with the `--no-live` option.

    """
    with handle_cli_exceptions():
        showDomain(domainname, live, users)


def domain_flush_factory(policyclass: EmailPolicy):
    def flush(domainname):
        pol = policyclass()
        pol.redis.delete(pol._domain_option_key(domainname))

    return flush


flush_greylisting = domain_flush_factory(GreylistingPolicy)
if HAVE_SPF:
    flush_spf = domain_flush_factory(SPFEnforcementPolicy)


@domain_app.command()
def flush(domainname: str):
    """Flush all enforcement option flags for a Domain"""
    with handle_cli_exceptions():
        flush_greylisting(domainname)
        if HAVE_SPF:
            flush_spf(domainname)
        showDomain(domainname)


@app.command()
def tally(
    client_address: str,
    epoch: bool = False,
    json: bool = False,
    clear: bool = False,
):
    """Display or clear the successful Greylisting tally for a client by IP

    Supply the IP address as the sole argument to this command.  The tally will
    be displayed, consisting of instance values and timestamps.  Instance
    values are an internal tracking value used between Postfix and the policy
    delegate, and they are intended to be unique.  The timestamp is the time at
    which the successful delivery (post-greylisting) occurred.  If the tally
    contains enough records, then emails which would be greylisted are
    delivered immediately instead.

    The timestamp is given by default in human-readable format.  If the output
    is intended for consumption by a program, two different options may be of
    interest.  Supply the `--epoch` flag to cause times to be printed in UNIX
    epoch time, as a float.  Supply the `--json` flag instead to receive the
    data in JSON format, suitable for machine parsing.

    If the `--clear` flag is specified, then the tally will be deleted after it
    is read.  The values found therein will still be returned, in case they are
    of interest.

    """
    if ":" in client_address:
        val = validators.ipv6
    else:
        val = validators.ipv4
    try:
        val(client_address)
    except validators.ValidationFailure:
        _print(
            f"The string '{_b(client_address)}' is not a valid IP address."
            "  Please note that port numbers (extra digits after a colon)"
            " may not be included."
        )
    # if we got this far, client_address contains a valid IP address
    grl = GreylistingPolicy()
    client_key = grl._client_key(client_address)
    tally_bytes = grl.redis.zrange(client_key, 0, -1, withscores=True)
    if clear:
        grl.redis.delete(client_key)
    tally_tuples = [
        (i.decode(grl.config.chapps.payload_encoding), float(t))
        for i, t in tally_bytes
    ]
    if json:
        _print(JSON.dumps(tally_tuples))
        return
    if epoch:
        # _print(pformat(tally_tuples))
        # return
        instance_times = tally_tuples
    else:
        instance_times = [
            (i, time.strftime(TIMEFMT, time.gmtime(t)))
            for i, t in tally_tuples
        ]
    # _print(pformat(instance_times))
    if len(instance_times) == 0:
        _print(f"There is no tally for client {_b(client_address)}")
    elif len(instance_times) >= grl.allow_after:
        _print(
            f"Whitelisting tally for {_b(client_address)}"
            f"{' ('+_b('cleared')+')' if clear else ''}"
            ":"
        )
    else:
        _print(
            f"Greylisting tally for {_b(client_address)} ({grl.allow_after}"
            " are required for whitelisting)"
            f"{' ('+_b('cleared')+')' if clear else ''}"
            ":"
        )
    for idx, (instance, timestamp) in enumerate(instance_times):
        _print(f"  [{idx}] {instance}: {_b(timestamp)}")


@domain_app.command()
def greylist(
    domainname: str,
    enforce: str = typer.Option("", help="[Y/N]"),
    flush: bool = typer.Option(True, help="Whether to clear the option cache"),
):
    """Toggle or set Greylisting enforcement for a Domain

    With no options, the Greylisting option will be toggled, and the cache
    flushed.

    Supply the `--enforce` option to specify the desired setting, instead of
    toggling.

    """
    with Session() as sess:
        domain = domain_or_die(sess, domainname)
        if enforce:
            domain.greylist = enforce not in NO
        else:
            domain.greylist = not domain.greylist
        sess.commit()
    if flush:
        flush_greylisting(domainname)
    showDomain(domainname, live=flush)


if HAVE_SPF:

    @domain_app.command()
    def check_spf(
        domainname: str,
        enforce: str = typer.Option("", help="[Y/N]"),
        flush: bool = typer.Option(
            True, help="Whether to clear the option cache"
        ),
    ):
        """Toggle or set SPF enforcement for a Domain

        With no options, the SPF checking option will be toggled, and the
        cache flushed.

        Supply the `--enforce` option to specify the desired setting, instead
        of toggling.

        """
        with Session() as sess:
            domain = domain_or_die(sess, domainname)
            if enforce:
                domain.check_spf = enforce not in NO
            else:
                domain.check_spf = not domain.check_spf
            sess.commit()
        if flush:
            flush_spf(domainname)
        showDomain(domainname, live=flush)


@admin_app.command()
def flush_config(to_file: str = None):
    """Flush the currently active config to disk.

    By default, this updates the current config file in place.  An argument may
    be supplied in order to have the new config file written out to some other
    location.
    """
    CHAPPSConfig.get_config().write(to_file)


@admin_app.command()
def active_config():
    """Show what config file chapps-cli is using.

    """
    _print("Using config from " + CHAPPSConfig.get_config.chapps.config_file)


@admin_app.command()
def api_password(
    to_file: str = None,
    password: str = typer.Option(
        ..., prompt=True, confirmation_prompt=True, hide_input=True
    ),
):
    """Performs a config-file flush to change the API password

    Generally it should be fine to flush the config file, but if your situation
    is somehow weird, you will want to be aware that this over-writes the
    'current' config file with a new one, wherein the password has been
    changed.  Provide the `--to-file` option to write to an alternate location.

    """
    config = CHAPPSConfig.get_config()
    config.configparser["CHAPPS"]["password"] = hash_password(
        password, config.chapps.payload_encoding
    )
    config.write(to_file)
    _print(
        "New config has been written."
        "  Please restart CHAPPS for changes to take effect."
    )


@admin_app.command()
def db_setup():
    """Initialize or migrate the database schema.

    The target database must already exist.  Empty databases will be populated
    with the current schema, and older databases will be brought up to date via
    incremental Alembic migrations.
    """
    return apply_migrations()


@app.command()
def allow(
    username: str,
    email_or_domain: str,
    create: bool = False,
    flush: bool = True,
):
    """Permit a user to send email from a domain or as a whole email address

    Pass the --create flag to this command in order to allow creation of
    nonexistent Email or Domain entries.

    Using this command also clears the Redis policy cache of its flag for
    the user and email-or-domain combination specified.
    """
    with handle_cli_exceptions():
        return _allow(username, email_or_domain, create, flush)


def _sda_flush(username, email_or_domain):
    sda = SenderDomainAuthPolicy()
    sda.redis.delete(sda._sender_domain_key(username, email_or_domain))


def _allow(
    username: str,
    email_or_domain: str,
    create: bool = False,
    flush: bool = True,
):
    assoc_type = assocType(email_or_domain)
    with Session() as sess:
        try:
            user = user_or_die(sess, username)
            assoc = sess.execute(
                assoc_type.select_by_name(email_or_domain)
            ).scalar()
            if create and (assoc is None):
                _print(
                    f"Creating {assoc_type.__name__.lower()} "
                    f"'{email_or_domain}'."
                )
                try:
                    sess.add(assoc_type.Meta.orm_model(name=email_or_domain))
                except IntegrityError as e:
                    _print(
                        f"  Encountered integrity error: {e}; "
                        "attempting to look up resource afresh."
                    )
                    pass
                assoc = sess.execute(
                    assoc_type.select_by_name(email_or_domain)
                ).scalar()
            if assoc is None:
                _print(
                    "Unable to find or create "
                    f"{assoc_type.__name__.lower()} '{_b(email_or_domain)}'."
                )
                raise NoSuchAssocException(
                    f"No such {assoc_type.__name__.lower()} '{email_or_domain}'."
                )
            sess.execute(
                association[assoc_type.__name__].insert_assoc(
                    user.id, assoc.id
                )
            )
            _print(
                f"Allowing user '{username}' to send from "
                f"{assoc_type.__name__.lower()} '{assoc.name}'"
            )
            sess.commit()
        except Exception as e:
            raise e
    if flush:
        _sda_flush(username, email_or_domain)
    showUser(username)


@app.command()
def deny(username: str, email_or_domain: str, flush: bool = True):
    """Prevent a user sending email appearing to come from a domain or email

    Both entities must already exist; not having any record for a domain
    or email means no one has permission.

    Using this command also clears the Redis policy cache of its flag for
    the user and email-or-domain combination specified.
    """
    with handle_cli_exceptions():
        return _deny(username, email_or_domain, flush)


def _deny(username: str, email_or_domain: str, flush: bool = True, *args):
    assoc_type = assocType(email_or_domain)
    with Session() as sess:
        user = user_or_die(sess, username)
        assoc = sess.execute(
            assoc_type.select_by_name(email_or_domain)
        ).scalar()
        if assoc is None:
            _print(
                f"No {assoc_type.__name__.lower()} named '{_b(email_or_domain)}'"
                f" could be found.  Please check the spelling and try again."
            )
            raise NoSuchAssocException(
                f"No such {assoc_type.__name__.lower()} '{email_or_domain}'."
            )
        sess.execute(
            association[assoc_type.__name__].delete_assoc(user.id, assoc.id)
        )
        _print(
            f"Denying user '{username}' ability to send from "
            f"{assoc_type.__name__.lower()} '{assoc.name}'"
        )
        sess.commit()
    if flush:
        _sda_flush(username, email_or_domain)
    showUser(username)


@app.command()
def reset(username: str, refresh: bool = True):
    """Reset a user's quota, making it seem they've sent no email

    CHAPPS keeps a day-long log of all attempts to send email.  This routine
    drops those records for the named user.  It reports the length of the list
    before and after, for clarity and verification.

    If this routine discovers that the cached sending limit in Redis does not
    match the current contents of the policy configuration database, it will
    cause Redis to be updated with the correct data from the policy database.
    Provide the --no-refresh flag to suppress this behavior.

    """
    with handle_cli_exceptions():
        with Session() as sess:
            user = user_or_die(sess, username)
            quota = user.quota
        oqp = OutboundQuotaPolicy()
        attkey = oqp._fmtkey(username, "attempts")
        limitkey = oqp._fmtkey(username, "limit")
        old_att = oqp.redis.zrange(attkey, 0, -1)
        old_limit = oqp.redis.get(limitkey)
        old_limit = int(old_limit.decode("utf-8")) if old_limit else None
        oqp.redis.delete(attkey)
        new_att = oqp.redis.zrange(attkey, 0, -1)
        _print(
            f"Dropped {len(old_att)} xmits from log; new log has "
            f"{len(new_att) if new_att else 0}"
        )
        if quota and refresh and (old_limit != quota.quota):
            _print(
                _b(
                    f"Cached quota {old_limit} does not match quota policy "
                    f"limit {quota.quota}; adjusting."
                )
            )
            oqp.refresh_policy_cache(username, quota)
        showUser(username, quota=True)


@app.command()
def refresh(username: str):
    """Refresh quota policy cache for a user

    This syncs up CHAPPS's operational idea of a user's limit with their
    configured limit in the policy database.
    """
    with handle_cli_exceptions():
        with Session() as sess:
            user = user_or_die(sess, username)
            quota = user.quota
        _print(f"Refreshing quota policy cache for user '{username}'")
        OutboundQuotaPolicy().refresh_policy_cache(username, quota)
        showUser(username, quota=True)


@app.command()
def show(username: str, quota: bool = False):
    """Show data about a user's configuration

    Supply the --quota flag to see real-time data about available quota and
    cached quota limit.

    """
    with handle_cli_exceptions():
        showUser(username, quota=quota)


@app.command()
def set_quota(username: str, quota: str):
    """Assign a user an existing quota

    The user and quota are both referred to by their names.

    This command cannot create Quota records.
    """
    with handle_cli_exceptions():
        return _set_quota(username, quota)


def _set_quota(username: str, quota: str, *args):
    with Session() as sess:
        user = user_or_die(sess, username)
        quota_orm = sess.execute(Quota.select_by_name(quota)).scalar()
        if quota_orm:
            _print(f"Assigning quota '{quota}' to user '{username}'")
            user.quota = quota_orm
            sess.commit()
        else:
            _print(f"Unable to find a quota named '{_b(quota)}'")
            raise NoSuchQuotaException("No such quota " + quota)
    showUser(username)


operation_map = dict(allow=_allow, deny=_deny, quota=_set_quota)


@app.command()
def import_file(filename: str, create: bool = False):
    """Import a permissions assigment file

    Provided for simplifying entry of large amounts of data at once.  This
    routine is not currently optimized for 1000s of entries, but should be okay
    for 100s.

    This feature runs successive `allow`, `deny` or `set-quota` commands, as
    specified by the first token in the line:

      ['allow', 'deny', 'quota']:<user>:<email,domain, or quota>

    using the next two tokens as the user and the resource in that order, just
    like on the commandline.  In the file, the tokens are separated by colons
    (:) without spaces.  Leading and trailing whitespace is ignored.  Lines
    starting with a hash mark (#) are ignored, as are lines under 15 characters
    in length.

    As an example, to allow user `caleb@chapps.io` to send email which appears to
    originate from `chapps.com`, create an entry in the import file like so:

      allow:caleb@chapps.io:chapps.com
      quota:caleb@chapps.io:Q1000

    The second line assigns the quota named `Q1000` to the user `caleb@chapps.io`.

    Pass the filename as an argument to the import_file command.

    Optionally, supply the --create flag to be passed through to allow, in
    order to allow nonexistent resources to be created.

    """
    import_path = Path(filename)
    if not import_path.exists():
        _print("Cannot find " + _b(filename))
        raise typer.Exit(code=1)
    exceptions = []
    with import_path.open("r") as fh:
        lineno = 0
        for line in fh:
            lineno += 1
            line = line.strip()
            if (len(line) < MIN_IMPORT_LINE_LENGTH) or (line[0] == "#"):
                continue
            _print(f"\nLine {lineno}:")
            try:
                operation, user, resource = line.split(":")
                op = operation_map[operation]
                op(user, resource, create)
            except ValueError as e:
                _print(
                    "Lines are expected to be three tokens separated by ':' "
                    "(colon); the tokens themselves may not contain colons."
                )
                exceptions.append(f"Line {lineno}: {e}: " + line)
                continue
            except KeyError:
                msg = f"Nonexistent operation {operation}"
                _print(msg)
                exceptions.append(f"Line {lineno}: {msg}")
                continue
            except UnrecoverableException as e:
                # no print here as the other routines generally do that
                exceptions.append(f"Line {lineno}: {e}")
                continue
    if exceptions:
        _print(
            _b(
                "\nThe following lines of the input file caused exceptions and"
                " were not executed:"
            )
        )
        _print("\n".join(exceptions))


if __name__ == "__main__":
    app()
