#!/usr/bin/env python3
# -*- coding: utf-8 -*-

from functools import reduce

# https://www.reddit.com/r/SwitchHacks/comments/f7psrk/anatomy_of_an_eshop_card_code/

# taken from the interwebs, none of these are new
EXAMPLE_CODES = [
    '4237082951213129',   # https://cr-cs.tumblr.com/post/153860583013/3ds-store-3ds-redeem-codes
    '5753437561933377',
    '5753437675070492',

    'A0387RMN33YJNGMC',   # https://cr-cs.tumblr.com/post/155573566497/cheap-eshop-codes-3ds-prepaid-card-code-generator-online
    'A039XJDX0WMEHG6W',
    'A03RL0KB2XHHRRBD',
    'A03RL0KC22JL2MEA',
    'A03RL0KD0UXF49FK',
    'A03RL0KE1X8GGMS6',
    'A05A259G2H2PJL5G',
    'A0693YFS0BPM6K2G',
#     'A071WNTC1BPR5EVU',   # https://www.nintendolife.com/news/2015/01/rumour_code_name_steam_demo_to_be_distributed_via_gamestop_stores
    'A0775H3G1MYCJW1Q',   # https://www.youtube.com/watch?v=RMsuHPbI04Y
    'A0796BH71Q1DSSYN',
    'A07K7E3702LYF1NM',   # https://www.youtube.com/watch?v=tIEnTXxSM98

    'B0GLFY1H1QR5GGDT',
    'B0K777732YWS7XVF',
    'B0QFT5QX0F2XMF3Q',
    'B0S0W8RT3QBSRHK9',
    'B0TTY433034MJ8FY',
    'B14WC53G32Y9VWVX',
    'B14CP5QN168XKTXR',
    'B14CP5QY1236N081',
    'B14CP5QP5CB1XY44',
    'B14CP5QQ311FQ5FQ',
    'B14CP5QR4Y7BG4QN',
    'B14CP5QS1X46M2L6',
    'B14CP5QT2S17K53T',
    'B14D8WQ610Q2S26S',
    'B14D8WQ7113VV6V4',
    'B14D8WQ834BFNP0G',
    'B14D8WQV3Q41N3KH',
    'B14D8WQB45PJ1M7L',

    'C00V1HK6KC0VMDLW',
    'C00V1HK7N1WF5SYR',
    'C00V1HK8PF42N06N',
    'C00V1HK90VSR0PGP',
    'C00V1HKBL941HH9T',
    'C00V1HKCJFNTF1QK',
    'C00V1HKDRW7VD14T',
    'C00V1HKFB2XP76V4',
    'C048GTST9QGLPCSY',
    'C049GTT0V2K86RKC',
    'C049GTT1QTLK1H26',
    'C049GTT277GG78L3',
    'C049GTT302810G4D',
    'C049GTT44SLTW4HV',
    'C049GTT55RYWV004',
    'C049GTT6V4S1V5M5',
    'C01DHQVVS3THVW6G',
    'C01WB9K31B1H3K03',
    'C01WRRQKCJ6YXFM3',
    'C080M7Y73KC7B37N',
    'C080M7Y8JGY35R0P',
    'C080M7Y9YWPX531M',
    'C080M7YV4MM8XG81',
    'C080M7YB7M3V2XRN',
    'C080M7YCDBS7Y13M',
    'C080M7YD74CPT83Y',
    'C080M7YW8XLRMTXK',
]

CHARSET = '0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ'

def safe2real(code: str):
    """Converts a code with unambiguous characters to the real code"""
    code = code.upper()
    code = code.replace('V', 'A')   # A might be confused with 4
    code = code.replace('W', 'E')   # E might be confused with 3
    code = code.replace('X', 'I')   # I might be confused with 1
    code = code.replace('Y', 'O')   # O might be confused with 0
    return code

def generateChecksum(base_code):
    if len(base_code) != 15:
        raise RuntimeError("Bad argument to checksum")
    # Mystery/bug: Why is this ord('7') in particular? 55, 0o55 and 0x55 are all unrelated to anything in base 33. The check looks like it's trying to do c - 'A', but does the weirdest thing instead.
    cksum = reduce(lambda cksum, c: (cksum + (ord(c) - ord('0') if ord(c) <= ord('9') else ord(c) - ord('7'))) % 33, base_code, 0)
    return CHARSET[cksum]

def validate(code):
    if len(code) != 16: return False
    # Make uppercase if necessary, then change X and Y back to their base 33 equivalents
    unmangled_code = safe2real(code)
    theirs = unmangled_code[15]
    mine = generateChecksum(unmangled_code[0:15])
    return theirs == mine

def parse(code):
    serial = None
    external_validator = None
    code_type = get_type(code)
    unmangled_code = safe2real(code)
    if code_type == 'NUM':
        serial = int(code[0:8])
        external_validator = int(code[8:])
    elif code_type == '_A_':
        serial = int(unmangled_code[1:8], 33)
        external_validator = int(unmangled_code[8:15], 33)
    elif code_type == '_B_' or code_type == '_C_':
        serial = int(unmangled_code[1:8], 30)
        external_validator = int(unmangled_code[8:15], 30)
    else:
        print(" unknown card generation '%s'" % code[0])
        return

    print(" ser#: {:010d} / Ext. validator: {:010d}".format(serial, external_validator))

def get_type(code: str) -> str:
    if code[0].isnumeric():
        return 'NUM'
    elif code[0] == 'A':
        return '_A_'
    elif code[0] == 'B':
        return '_B_'
    elif code[0] == 'C':
        return '_C_'
    else:
        return 'UNK'

if __name__ == '__main__':
    import sys
    args = None
    if len(sys.argv) == 1:
        args = EXAMPLE_CODES
    else:
        args = sys.argv[1:]
    seen_chars = {}
    for s in args:
        code_type = get_type(s)
        if not code_type in seen_chars:
            seen_chars[code_type] = set()
        for c in s[1:]:
            seen_chars[code_type].add(c)
        if code_type == '_A_':
            valid = validate(s)
            print('{} {}'.format(s, "☑ " if valid else "☒ "), end='')
            if not valid:
                print()
                continue
            parse(s)
        else:
            print('{} ??'.format(s), end='')
            parse(s)
    for ct, cc in seen_chars.items():
        cc = sorted(list(cc))
        print('Found characters in type {} codes: '.format(ct), end='')
        for c in CHARSET:
            if c in cc:
                print(c, end='')
            else:
                print('-', end='')
        print()