#!/usr/bin/env python3

# This file is a part of custom-install.py.
#
# Copyright (c) 2019 Ian Burgwin
# This file is licensed under The MIT License (MIT).
# You can find the full license text in LICENSE.md in the root of this project.

from argparse import ArgumentParser
from os import makedirs, scandir
from os.path import dirname, join
from random import randint
from hashlib import sha256
from sys import exit, platform
from tempfile import TemporaryDirectory
from typing import BinaryIO
import subprocess

from pyctr.crypto import CryptoEngine, Keyslot
from pyctr.types.cia import CIAReader, CIASection
from pyctr.types.ncch import NCCHSection
from pyctr.util import roundup

# used to run the save3ds_fuse binary next to the script
script_dir: str = dirname(__file__)

# missing contents are replaced with 0xFFFFFFFF in the cmd file
CMD_MISSING = b'\xff\xff\xff\xff'

# the size of each file and directory in a title's contents are rounded up to this
TITLE_ALIGN_SIZE = 0x8000

# size to read at a time when copying files
READ_SIZE = 0x200000


def copy_with_progress(src: BinaryIO, dst: BinaryIO, size: int, path: str):
    left = size
    cipher = crypto.create_ctr_cipher(Keyslot.SD, crypto.sd_path_to_iv(path))
    while left > 0:
        to_read = min(READ_SIZE, left)
        data = cipher.encrypt(src.read(READ_SIZE))
        dst.write(data)
        left -= to_read
        total_read = size - left
        print(f' {(total_read / size) * 100:>5.1f}%  {total_read / 1048576:>.1f} MiB / {size / 1048576:.1f} MiB',
              end='\r', flush=True)
    print()


parser = ArgumentParser(description='Manually install a CIA to the SD card for a Nintendo 3DS system.')
parser.add_argument('cia', help='CIA files', nargs='+')
parser.add_argument('-m', '--movable', help='movable.sed file', required=True)
parser.add_argument('-b', '--boot9', help='boot9 file')
parser.add_argument('--sd', help='path to SD root')
parser.add_argument('--skip-contents', help="don't add contents, only add title info entry", action='store_true')

args = parser.parse_args()

# set up the crypto engine to encrypt contents as they are written to the SD
crypto = CryptoEngine(boot9=args.boot9)
crypto.setup_sd_key_from_file(args.movable)

# try to find the path to the SD card contents
print('Finding path to install to...')
sd_path = join(args.sd, 'Nintendo 3DS', crypto.id0.hex())
id1s = []
for d in scandir(sd_path):
    if d.is_dir() and len(d.name) == 32:
        try:
            id1_tmp = bytes.fromhex(d.name)
        except ValueError:
            continue
        else:
            id1s.append(d.name)

# check the amount of id1 directories
if len(id1s) > 1:
    exit(f'There are multiple id1 directories for id0 {crypto.id0.hex()}, please remove extra directories')
elif len(id1s) == 0:
    exit(f'Could not find a suitable id1 directory for id0 {crypto.id0.hex()}')

sd_path = join(sd_path, id1s[0])

title_info_entries = {}
# for use with a finalize program on the 3DS
finalize_entries = []

for c in args.cia:
    # parse the cia
    print('Reading CIA...')
    cia = CIAReader(c)
    tid_parts = (cia.tmd.title_id[0:8], cia.tmd.title_id[8:16])

    try:
        print(f'Installing {cia.contents[0].exefs.icon.get_app_title().short_desc}...')
    except:
        print('Installing...')

    # this includes the sizes for all the files that would be in the title, plus each directory
    #   except the separate directories for DLC contents, which don't count towards the size.
    # five "1"s are here for the tidlow and content directories, the cmd file and its directory, and the tmd file
    sizes = [1] * 5

    if cia.tmd.save_size:
        # one for the data directory, one for the 00000001.sav file
        sizes.extend((1, cia.tmd.save_size))

    for record in cia.content_info:
        sizes.append(record.size)

    # this calculates the size to put in the Title Info Entry
    title_size = sum(roundup(x, TITLE_ALIGN_SIZE) for x in sizes)

    # checks if this is dlc, which has some differences
    is_dlc = tid_parts[0] == '0004008c'

    # this checks if it has a manual (index 1) and is not DLC
    has_manual = (not is_dlc) and (1 in cia.contents)

    # this gets the extdata id from the extheader, stored in the storage info area
    try:
        with cia.contents[0].open_raw_section(NCCHSection.ExtendedHeader) as e:
            e.seek(0x200 + 0x30)
            extdata_id = e.read(8)
    except KeyError:
        # not an executable title
        extdata_id = b'\0' * 8

    # cmd content id, starts with 1 for non-dlc contents
    cmd_id = len(cia.content_info) if is_dlc else 1
    cmd_filename = f'{cmd_id:08x}.cmd'

    # get the title root where all the contents will be
    title_root = join(sd_path, 'title', *tid_parts)
    content_root = join(title_root, 'content')
    # generate the path used for the IV
    title_root_cmd = f'/title/{"/".join(tid_parts)}'
    content_root_cmd = title_root_cmd + '/content'

    makedirs(join(content_root, 'cmd'), exist_ok=True)
    if cia.tmd.save_size:
        makedirs(join(title_root, 'data'), exist_ok=True)
    if is_dlc:
        # create the separate directories for every 256 contents
        for x in range(((len(cia.content_info) - 1) // 256) + 1):
            makedirs(join(content_root, f'{x:08x}'))

    # maybe this will be changed in the future
    tmd_id = 0

    tmd_filename = f'{tmd_id:08x}.tmd'

    if not args.skip_contents:
        # write the tmd
        enc_path = content_root_cmd + '/' + tmd_filename
        print(f'Writing {enc_path}...')
        with cia.open_raw_section(CIASection.TitleMetadata) as s, open(join(content_root, tmd_filename), 'wb') as o:
            copy_with_progress(s, o, cia.sections[CIASection.TitleMetadata].size, enc_path)

        # write each content
        for c in cia.content_info:
            content_filename = c.id + '.app'
            if is_dlc:
                dir_index = format((c.cindex // 256), '08x')
                enc_path = content_root_cmd + f'/{dir_index}/{content_filename}'
                out_path = join(content_root, dir_index, content_filename)
            else:
                enc_path = content_root_cmd + '/' + content_filename
                out_path = join(content_root, content_filename)
            print(f'Writing {enc_path}...')
            with cia.open_raw_section(c.cindex) as s, open(out_path, 'wb') as o:
                copy_with_progress(s, o, c.size, enc_path)

        # generate a blank save
        if cia.tmd.save_size:
            enc_path = title_root_cmd + '/data/00000001.sav'
            out_path = join(title_root, 'data', '00000001.sav')
            cipher = crypto.create_ctr_cipher(Keyslot.SD, crypto.sd_path_to_iv(enc_path))
            # in a new save, the first 0x20 are all 00s. the rest can be random
            data = cipher.encrypt(b'\0' * 0x20)
            print(f'Generating blank save at {enc_path}...')
            with open(out_path, 'wb') as o:
                o.write(data)
                o.write(b'\0' * (cia.tmd.save_size - 0x20))

        # generate and write cmd
        enc_path = content_root_cmd + '/cmd/' + cmd_filename
        out_path = join(content_root, 'cmd', cmd_filename)
        print(f'Generating {enc_path}')
        highest_index = 0
        content_ids = {}

        for record in cia.content_info:
            highest_index = record.cindex
            with cia.open_raw_section(record.cindex) as s:
                s.seek(0x100)
                cmac_data = s.read(0x100)

            id_bytes = bytes.fromhex(record.id)[::-1]
            cmac_data += record.cindex.to_bytes(4, 'little') + id_bytes

            c = crypto.create_cmac_object(Keyslot.CMACSDNAND)
            c.update(sha256(cmac_data).digest())
            content_ids[record.cindex] = (id_bytes, c.digest())

        # add content IDs up to the last one
        ids_by_index = [CMD_MISSING] * (highest_index + 1)
        installed_ids = []
        cmacs = []
        for x in range(len(ids_by_index)):
            try:
                info = content_ids[x]
            except KeyError:
                cmacs.append(bytes.fromhex('4D495353494E4720434F4E54454E5421'))
            else:
                ids_by_index[x] = info[0]
                cmacs.append(info[1])
                installed_ids.append(info[0])
        installed_ids.sort(key=lambda x: int.from_bytes(x, 'little'))

        final = (cmd_id.to_bytes(4, 'little')
                 + len(ids_by_index).to_bytes(4, 'little')
                 + len(installed_ids).to_bytes(4, 'little')
                 + (1).to_bytes(4, 'little'))
        c = crypto.create_cmac_object(Keyslot.CMACSDNAND)
        c.update(final)
        final += c.digest()

        final += b''.join(ids_by_index)
        final += b''.join(installed_ids)
        final += b''.join(cmacs)

        cipher = crypto.create_ctr_cipher(Keyslot.SD, crypto.sd_path_to_iv(enc_path))
        print(f'Writing {enc_path}')
        with open(out_path, 'wb') as o:
            o.write(cipher.encrypt(final))

    # this starts building the title info entry
    title_info_entry_data = [
        # title size
        title_size.to_bytes(8, 'little'),
        # title type, seems to usually be 0x40
        0x40.to_bytes(4, 'little'),
        # title version
        int(cia.tmd.title_version).to_bytes(2, 'little'),
        # ncch version
        cia.contents[0].version.to_bytes(2, 'little'),
        # flags_0, only checking if there is a manual
        (1 if has_manual else 0).to_bytes(4, 'little'),
        # tmd content id, always starting with 0
        (0).to_bytes(4, 'little'),
        # cmd content id
        cmd_id.to_bytes(4, 'little'),
        # flags_1, only checking save data
        (1 if cia.tmd.save_size else 0).to_bytes(4, 'little'),
        # extdataid low
        extdata_id[0:4],
        # reserved
        b'\0' * 4,
        # flags_2, only using a common value
        0x100000000.to_bytes(8, 'little'),
        # product code
        cia.contents[0].product_code.encode('ascii').ljust(0x10, b'\0'),
        # reserved
        b'\0' * 0x10,
        # unknown
        randint(0, 0xFFFFFFFF).to_bytes(4, 'little'),
        # reserved
        b'\0' * 0x2c
    ]

    title_info_entries[cia.tmd.title_id] = b''.join(title_info_entry_data)

    with cia.open_raw_section(CIASection.Ticket) as t:
        ticket_data = t.read()

    finalize_entry_data = [
        # title id
        bytes.fromhex(cia.tmd.title_id)[::-1],
        # common key index
        ticket_data[0x1F1:0x1F2],
        # has seed
        cia.contents[0].flags.uses_seed.to_bytes(1, 'little'),
        # magic
        b'TITLE\0',
        # encrypted titlekey
        ticket_data[0x1BF:0x1CF],
        # seed, if needed
        cia.contents[0].seed if cia.contents[0].flags.uses_seed else (b'\0' * 0x10)
    ]

    finalize_entries.append(b''.join(finalize_entry_data))

with open(join(args.sd, 'cifinish.bin'), 'wb') as o:
    # magic, version, title count
    o.write(b'CIFINISH' + (1).to_bytes(4, 'little') + len(finalize_entries).to_bytes(4, 'little'))

    # add each entry to cifinish.bin
    for entry in finalize_entries:
        o.write(entry)

with TemporaryDirectory(suffix='-custom-install') as tempdir:
    # set up the common arguments for the two times we call save3ds_fuse
    save3ds_fuse_common_args = [
        join(script_dir, 'bin', platform, 'save3ds_fuse'),
        '-b', crypto.b9_path,
        '-m', args.movable,
        '--sd', args.sd,
        '--db', 'sdtitle',
        tempdir
    ]

    # extract the title database to add our own entry to
    print('Extracting Title Database...')
    subprocess.run(save3ds_fuse_common_args + ['-x'])

    for title_id, entry in title_info_entries.items():
        # write the title info entry to the temp directory
        with open(join(tempdir, title_id), 'wb') as o:
            o.write(entry)

    # import the directory, now including our title
    print('Importing into Title Database...')
    subprocess.run(save3ds_fuse_common_args + ['-i'])

print('\nFINAL STEP:\nRun custom-install-finalize through homebrew launcher.')
print('This will install a ticket and seed if required.')
