# -*- coding: utf-8 -*-
# :-
# SPDX-FileCopyrightText: © 2018 Franz Glasner
# SPDX-FileCopyrightText: © 2025 Franz Glasner
# SPDX-License-Identifier: BSD-3-Clause
# :-
r"""A simple password generator to generate random passwords from selected
character repertoires.

Use :command:`genpwd.py --help' for a detailed help message.

"""

from __future__ import (division, absolute_import, print_function)

import argparse
import base64
import binascii
import os
import sys

from . import (__version__, __revision__)


#
# Unreserved characters according to RFC 1738 (URL) **and** RFC 3986 (URI)
# No general delimiters and no sub-delimiters.
#
WEB_CHARS = (b"ABCDEFGHIJKLMNOPQRSTUVWYXZabcdefghijklmnopqrstuvwxyz"
             b"0123456789-._")
# WEB_CHARS without visually similar characters (0O, 1lI)
SAFE_WEB_CHARS = (b"ABCDEFGHJKLMNPQRSTUVWYXZabcdefghijkmnopqrstuvwxyz"
                  b"23456789-._")
# SAFE_WEB_CHARS with preference to punctuation
SAFE_WEB_CHARS_2 = b".-_" + SAFE_WEB_CHARS
# Unreserved characters from URI but with sub-delims allowed
URI_CHARS = WEB_CHARS + b"~" + b"!$&'()*+,;="
# URI_CHARS without visually similar characters
SAFE_URI_CHARS = SAFE_WEB_CHARS + b"~" + b"!$&'()*+,;="
# Just like SAFE_URI_CHARS but prefers punctuation characters
SAFE_URI_CHARS_2 = (b"~" + b"!$&'()*+,;=" + SAFE_WEB_CHARS
                    + b"~" + b"!$&'()*+,;=")
# All visible characters from ASCII character set but no space
FULL_ASCII = (b"0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ"
              b"abcdefghijklmnopqrstuvwxyz!#$%&/()*+-.,:;<=>?@^_`[\\]{|}'\"~")
#
# A safer variant of FULL_ASCII:
# - no characters that are visually similar (0O, 1lI)
# - no characters with dead keys on german keyboards
# - no backslash (too easily interpret as escape character
# - no single or double quotes
SAFE_ASCII = (b"23456789ABCDEFGHJKLMNPQRSTUVWXYZ"
              b"abcdefghijkmnopqrstuvwxyz!#$%&/()*+-.,:;<=>?@_[]{|}~")
# just numeric and alphabetic
ALNUM = b"0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz"
# safer alpha-numberic without visually similar characters
SAFE_ALNUM = b"23456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz"


PY2 = sys.version_info[0] <= 2


def main(argv=None):
    aparser = argparse.ArgumentParser(
        description="A simple password generator for passwords with a given"
                    " length within a selected character repertoire",
        fromfile_prefix_chars='@')
    aparser.add_argument(
        "-v", "--version", action="version",
        version="%s (rv:%s)" % (__version__, __revision__))
    group = aparser.add_mutually_exclusive_group()
    group.add_argument(
        "--algorithm", "-a",
        choices=("web", "safe-web", "safe-web-2",
                 "uri", "safe-uri", "safe-uri-2",
                 "ascii", "safe-ascii",
                 "alnum", "safe-alnum",
                 "bin-base64", "bin-urlsafe-base64", "bin-base32", "bin-hex",
                 "bin-ascii85",),
        default="safe-ascii",
        help="""
Select an algorithm and (implicitly) a character repertoire.
All repertoires that start with `bin-' just encode the output of
"os.urandom()" with the selected encoder.
All repertoires that end with `-safe' or `safe-2' do not contain visually
similar characters (currently `0O' or `Il1').
All repertoires that end with `-2' are variants with a bias to punctuation
characters.
This is incompatible with option `--repertoire'.
Default: safe-ascii""")
    group.add_argument(
        "--repertoire", "-r",
        action="store", metavar="REPERTOIRE",
        help="""
Select from given character repertoire.
The repertoire must be characters from the ISO-8859-15 character set.
An empty REPERTOIRE selects implicitly the default algorithm.
This is incompatible with option `--algorithm'.""")
    aparser.add_argument(
        "-E", dest="use_bin_length", action="store_true",
        help="For some repertoires make OUTPUT-LENGTH the number of bytes"
             " that is to be read from random sources instead of output bytes")
    aparser.add_argument(
        "--group", "-G", action="store_true",
        help="""Group the results. If no "--group-sep" or "--group-size" is
given use their respective defaults.""")
    aparser.add_argument(
        "--group-sep", action="store", default=None, metavar="GROUP-SEP",
        help="""Group the result using GROUP-SEP as separator.
Option "--group" is implied.
Default when grouping is enabled is the SPACE character ` '.""")
    aparser.add_argument(
        "--group-size", action="store", type=int, default=None,
        metavar="GROUP-SIZE",
        help="""Group the result using a group size of GROUP-SIZE characters.
Option "--group" is implied.
Default when grouping is enabled is 6.""")
    aparser.add_argument(
        "req_length", metavar="OUTPUT-LENGTH", type=int,
        help="The required length of the generated output")

    opts = aparser.parse_args(args=argv)

    grouper = None
    if opts.group or opts.group_sep is not None or opts.group_size is not None:
        if opts.group_sep is None:
            gsep = b' '
        else:
            if PY2:
                gsep = opts.group_sep
            else:
                gsep = opts.group_sep.encode("utf8")
        gsize = 6 if opts.group_size is None else opts.group_size
        grouper = make_grouper(sep=gsep, size=gsize)

    if opts.repertoire:
        try:
            repertoire = (opts.repertoire
                          if PY2
                          else opts.repertoire.encode("iso-8859-15"))
        except UnicodeError:
            raise ValueError("non ISO-8859-15 character in given repertoire")
        pwd = gen_from_repertoire(opts.req_length, repertoire, grouper=grouper)
    elif opts.algorithm == "web":
        pwd = gen_from_repertoire(
            opts.req_length, WEB_CHARS, grouper=grouper)
    elif opts.algorithm == "safe-web":
        pwd = gen_from_repertoire(
            opts.req_length, SAFE_WEB_CHARS, grouper=grouper)
    elif opts.algorithm == "safe-web-2":
        pwd = gen_from_repertoire(
            opts.req_length, SAFE_WEB_CHARS_2, grouper=grouper)
    elif opts.algorithm == "uri":
        pwd = gen_from_repertoire(
            opts.req_length, URI_CHARS, grouper=grouper)
    elif opts.algorithm == "safe-uri":
        pwd = gen_from_repertoire(
            opts.req_length, SAFE_URI_CHARS, grouper=grouper)
    elif opts.algorithm == "safe-uri-2":
        pwd = gen_from_repertoire(
            opts.req_length, SAFE_URI_CHARS_2, grouper=grouper)
    elif opts.algorithm == "ascii":
        pwd = gen_from_repertoire(
            opts.req_length, FULL_ASCII, grouper=grouper)
    elif opts.algorithm == "safe-ascii":
        pwd = gen_from_repertoire(
            opts.req_length, SAFE_ASCII, grouper=grouper)
    elif opts.algorithm == "alnum":
        pwd = gen_from_repertoire(opts.req_length, ALNUM, grouper=grouper)
    elif opts.algorithm == "safe-alnum":
        pwd = gen_from_repertoire(
            opts.req_length, SAFE_ALNUM, grouper=grouper)
    elif opts.algorithm == "bin-base64":
        encoder = base64.b64encode
        pwd = gen_bin(opts.req_length, opts.use_bin_length, encoder,
                      rstrip_chars=b"=", grouper=grouper)
    elif opts.algorithm == "bin-urlsafe-base64":
        encoder = base64.urlsafe_b64encode
        pwd = gen_bin(opts.req_length, opts.use_bin_length, encoder,
                      rstrip_chars=b"=", grouper=grouper)
    elif opts.algorithm == "bin-base32":
        encoder = base64.b32encode
        pwd = gen_bin(opts.req_length, opts.use_bin_length, encoder,
                      rstrip_chars=b"=", grouper=grouper)
    elif opts.algorithm == "bin-ascii85":
        encoder = base64.a85encode
        pwd = gen_bin(
            opts.req_length, opts.use_bin_length, encoder, grouper=grouper)
    elif opts.algorithm == "bin-hex":
        encoder = binascii.hexlify
        pwd = gen_bin(
            opts.req_length, opts.use_bin_length, encoder, grouper=grouper)
    else:
        raise NotImplementedError("algorithm not yet implemented: %s"
                                  % opts.algorithm)
    if opts.group or opts.group_size or opts.group_sep:
        if len(pwd) < opts.req_length:
            raise AssertionError("internal length mismatch")
    else:
        if opts.use_bin_length:
            if len(pwd) < opts.req_length:
                raise AssertionError("internal length mismatch")
        else:
            if len(pwd) != opts.req_length:
                raise AssertionError("internal length mismatch")
    if PY2:
        print(pwd)
        sys.stdout.flush()
    else:
        sys.stdout.buffer.write(pwd)
        sys.stdout.buffer.write(b'\n')
        sys.stdout.buffer.flush()


def gen_from_repertoire(length, repertoire, grouper=None):
    """Select `length` characters randomly from given character repertoire
    `repertoire`.

    """
    assert len(repertoire) <= 256
    pwd = []
    while len(pwd) < length:
        rndbytes = os.urandom(16)
        for c in rndbytes:
            if PY2:
                c = ord(c)
            if c < len(repertoire):
                pwd.append(repertoire[c])
                if len(pwd) >= length:
                    break
    if PY2:
        pwd = b''.join(pwd)
    else:
        pwd = bytes(pwd)
    if grouper:
        pwd = grouper(pwd)
    return pwd


def gen_bin(length, use_bin_length, encoder, rstrip_chars=None, grouper=None):
    """Generate from :func:`os.urandom` and just encode with given `encoder`.

    """
    pwd = encoder(os.urandom(length))
    pwd = pwd.rstrip(rstrip_chars) if use_bin_length else pwd[:length]
    if grouper:
        pwd = grouper(pwd)
    return pwd


def make_grouper(sep=b' ', size=6):

    def _grouper(pwd):
        if not pwd or size <= 0:
            return pwd
        assert isinstance(pwd, bytes)
        groups = []
        idx = 0
        while idx < len(pwd):
            groups.append(pwd[idx:idx+size])
            idx += size
        return sep.join(groups)

    assert isinstance(sep, bytes)
    return _grouper


if __name__ == "__main__":
    main()
