#!/usr/bin/env python3
import argparse
import getpass
import json
import os
import subprocess
import sys
import textwrap

SIGNAL_BUNDLEID = "org.whispersystems.signal"
SIGNAL_APPGROUP = "group.org.whispersystems.signal.group"
SIGNAL_APPGROUP_STAGING = "group.org.whispersystems.signal.group.staging"

SIGNAL_DEBUG_PAYLOAD_NAME = "dbPayload.txt"
SIGNAL_DEBUG_PAYLOAD_DBPATH_KEY = "dbPath"
SIGNAL_DEBUG_PAYLOAD_PASSPHRASE_KEY = "key"
SIGNAL_FALLBACK_DATABASE_PATH = "grdb/signal.sqlite"

quietMode=False
def failWithError(string):
    print("Error: " + string, file=sys.stderr)
    exit(1)

def printInfo(string = ""):
    if quietMode == False:
        print(string)

def runCommand(cmd):
    result = subprocess.run(cmd.split(), text=True, capture_output=True)
    if result.returncode != 0:
        failWithError("Failed to run \"" + cmd + "\". Status: " + str(result.returncode) + "\n" + result.stderr)
    return result.stdout


class Simulator:
    def __init__(self, searchString, useStaging):

        # Get JSON list of simulators matching searchString
        cmd = "xcrun simctl list -j devices " + searchString
        resultString = runCommand(cmd)
        simDict = json.loads(resultString)
        devicesByRuntime = simDict["devices"]

        # Parse all candidates
        candidates = []
        for runtime, devices in devicesByRuntime.items():
            os = self.parseOSFromRuntime(runtime)
            for device in devices:
                udid = device.get("udid")
                rawDevice = device.get("deviceTypeIdentifier")
                name = device.get("name")
                if udid != None:
                    deviceType = self.parseDeviceTypeFromRaw(rawDevice)
                    candidates.append({"os": os, "type": deviceType, "udid": udid, "name": name})

        # Select a candidate
        selectedCandidate = None

        if len(candidates) == 0:
            failWithError("Could not find a \"" + searchString + "\" simulator")
        elif len(candidates) == 1:
            selectedCandidate = candidates[0]
        else:
            if quietMode:
                failWithError("Multiple simulator candidates. Interactive selection not supported in quiet mode")
            for idx, candidate in enumerate(candidates):
                printInfo("{}:\t{:40}\t{} {} ({})".format(idx, candidate["name"], candidate["type"], candidate["os"], candidate["udid"]))

            while selectedCandidate == None:
                try:
                    idx = int(input("Select a simulator: "))
                    selectedCandidate = candidates[idx]
                except (ValueError, IndexError):
                    pass

        self.udid = selectedCandidate["udid"]
        self.groupID = SIGNAL_APPGROUP_STAGING if useStaging else SIGNAL_APPGROUP
        self.groupContainer = self.fetchGroupContainer(self.udid, self.groupID)
        printInfo("Selected simulator: " + selectedCandidate["name"] + " (" + selectedCandidate["udid"] + ")")
        printInfo("Using groupID: " + self.groupID)
        printInfo()

    def parseDebugPayload(self):
        path = self.groupContainer + "/" + SIGNAL_DEBUG_PAYLOAD_NAME
        try:
            fd = open(path, 'r')
            data = fd.read()
            payload = json.loads(data)
            return payload
        except IOError:
            return None

    def databasePath(self):
        debugPayload = self.parseDebugPayload()

        if debugPayload and SIGNAL_DEBUG_PAYLOAD_DBPATH_KEY in debugPayload:
            payloadPath = debugPayload[SIGNAL_DEBUG_PAYLOAD_DBPATH_KEY]
            if os.path.isfile(payloadPath):
                return payloadPath
            else:
                printInfo("Debug payload " + payloadPath[-50:] + " not found. Falling back the standard path.")

        return (self.groupContainer + "/" + SIGNAL_FALLBACK_DATABASE_PATH)

    def passphraseIfAvailable(self):
        debugPayload = self.parseDebugPayload()
        if debugPayload and SIGNAL_DEBUG_PAYLOAD_PASSPHRASE_KEY in debugPayload:
            return debugPayload[SIGNAL_DEBUG_PAYLOAD_PASSPHRASE_KEY]
        else:
            return None

    @staticmethod
    def parseOSFromRuntime(runtime):
        lastPeriodIdx = runtime.rfind('.')
        hypenatedOS = runtime[lastPeriodIdx+1:]
        return hypenatedOS.replace("-", ".")

    @staticmethod
    def parseDeviceTypeFromRaw(rawDevice):
        lastPeriodIdx = rawDevice.rfind('.')
        hypenatedOS = rawDevice[lastPeriodIdx+1:]
        return hypenatedOS.replace("-", " ")

    @staticmethod
    def fetchGroupContainer(udid, groupID):
        cmd = "xcrun simctl get_app_container {} {} {}".format(udid, SIGNAL_BUNDLEID, groupID)
        result = runCommand(cmd)
        return result.rstrip()

def preparePassphrase(passphrase):
    if len(passphrase) > 0 and passphrase[0] == 'x':
        return passphrase
    else:
        return "x'" + passphrase + "'"


parser = argparse.ArgumentParser(
        formatter_class=argparse.RawDescriptionHelpFormatter,
        description=textwrap.dedent('''\
                SQLCipher Command Line Interface

                    If providing a simulatorID (or accepting the default "Booted" simulator), passphrase retrieval
                    can be simplified by navigating to Signal Settings > Debug UI > Misc > Save plaintext database key.
                    If a database key could not be found and one was not provided through an argument, you'll be prompted
                    to enter one.

                    Alternatively, you can provide a sqlcipher path directly via command line arguments. In this case,
                    you'll be required to provide a database key through an argument or stdin.
                '''),
        usage="%(prog)s [--simulator simID [--staging] | --path dbPath] [--passphrase passphrase] [--quiet]")

group = parser.add_mutually_exclusive_group()
group.add_argument("--simulator", metavar="SIM", help="A string identifiying a simulator instance. (default: %(default)s).", default="booted")
group.add_argument("--path", help="An sqlcipher path")
parser.add_argument("--passphrase", metavar="PASS", help="The passphrase encrypting the database")
parser.add_argument("--staging", action='store_true', help="If a simulator is being targeted, specifies that the staging database should be used")
parser.add_argument("remainder", nargs=argparse.REMAINDER, metavar="--", help="All subsequent args will be interpreted as SQL. You probably want quotes here. Be careful with \"*\" since your shell will probably replace it.")
parser.add_argument("--quiet", action='store_true', help="Suppress non-failing output")
args = parser.parse_args()

quietMode=args.quiet
dbPath = None
passphrase = None

if args.path:
    dbPath = args.path
elif args.simulator:
    target = Simulator(args.simulator, args.staging)
    dbPath = target.databasePath()
    passphrase = target.passphraseIfAvailable()

if dbPath == None:
    failWithError("No valid database path")
elif os.path.isfile(dbPath) == False:
    failWithError("Not valid path " + dbPath)

if args.passphrase:
    passphrase = args.passphrase
if passphrase == None:
    passphrase = getpass.getpass("Please enter the passphrase. Alternatively, set up a plaintext database key in Debug UI > Misc > Save plaintext database key. Then, rerun the command. ")

if passphrase == None or len(passphrase) == 0:
    failWithError("No valid sqlcipher passphrase")

passphrase = preparePassphrase(passphrase)
sqlArgs = args.remainder
if len(sqlArgs) > 0 and sqlArgs[0] == "--":
    sqlArgs.pop(0)
sqlArgString = " ".join(sqlArgs)

allArgs = [
    "sqlcipher",
    "-cmd", "PRAGMA key = \"" + passphrase + "\";",
    "-cmd", "PRAGMA cipher_plaintext_header_size = 32;",
    dbPath
]
if len(sqlArgString) > 0:
    allArgs.append(sqlArgString)

os.execvp("sqlcipher", allArgs)

