#!/usr/bin/env python3

# PCSX2 - PS2 Emulator for PCs
# Copyright (C) 2024 PCSX2 Dev Team
#
# PCSX2 is free software: you can redistribute it and/or modify it under the terms
# of the GNU General Public License as published by the Free Software Found-
# ation, either version 3 of the License, or (at your option) any later version.
#
# PCSX2 is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY;
# without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR
# PURPOSE.  See the GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License along with PCSX2.
# If not, see <https://www.gnu.org/licenses/>.

import sys
import os
import re
from subprocess import Popen, PIPE
from os.path import exists

gamecount = [0]

# =================================================================================================

def deletionChoice(source_extension):                               # Choose to delete source files

    yesno = {
        "n"   : 0,
        "no"  : 0,
        "y"   : 1,
        "yes" : 1,
    }

    print("╟-------------------------------------------------------------------------------╢")
    print(f"║ Do you want to delete the original {source_extension.upper()} files as they are converted?")
    choice = input("║ Type Y or N then press ENTER: ").lower()

    if (not choice in yesno):
        exitInvalidOption()

    return (yesno[choice])

# -------------------------------------------------------------------------------------------------

def blockSizeChoice(is_cd, decompressing):                          # Choose block size

    if (decompressing):
        return 0

    sizes = {
        "1" : 16384,
        "2" : 131072,
        "3" : 262144,
    } if not is_cd else {
        "1": 17136,
        "2": 132192,
        "3": 264384,
    }

    print("╟-------------------------------------------------------------------------------╢")
    print("║ Please pick the block size you would like to use:")
    print("║")
    print("║   1 - 16 kB  (bigger files, faster access/less CPU, choose this if unsure)")
    print("║   2 - 128 kB (balanced)")
    print("║   3 - 256 kB (smaller files, slower access/more CPU)")
    print("║")
    blocksize = input("║ Type the number corresponding to your selection then press ENTER: ")

    if (not blocksize in sizes):
        exitInvalidOption()

    return (sizes[blocksize])

# =================================================================================================

def checkSuccess(compressing, fname, extension, error_code): # Ensure file created properly

    target_fname = f"{fname}.{extension}"
    if (error_code):

        print("╠===============================================================================╣")

        if (compressing):
            print(f"║ Compression to {extension.upper()} failed for the following:{(37 - len(extension)) * ' '}║")
        else:
            print(f"║ Extraction to {extension.upper()} failed for the following:{(38 - len(extension)) * ' '}║")

        print(f"║ {target_fname}{(77 - len(target_fname)) * ' '}║")
        print("╚===============================================================================╝")

        sys.exit(1)

    print(f"║ {target_fname} created.{(69 - len(target_fname)) * ' '}║")

# -------------------------------------------------------------------------------------------------

def checkProgramMissing(program):

    if (sys.platform.startswith('win32') and exists(f"./{program}.exe")):
        return                                                      # Windows

    else:                                                           # Linux, macOS
        from shutil import which
        if (which(program) is not None):
            return

    print("╠===============================================================================╗")
    print(f"║                   {program} failed, {program} is missing.{(39 - (len(program) * 2)) * ' '}║")
    print("╚===============================================================================╝")
    sys.exit(1)

# -------------------------------------------------------------------------------------------------

def checkBinCueMismatch(bin_files, cue_files):                      # Ensure all bins and cues match

    if (len(bin_files) != len(cue_files)):                          # Ensure numerical parity
        exitBinCueMismatch()

    for fname in bin_files:                                         # Ensure filename parity
        if (f"{fname[:-4]}.cue" not in cue_files):
            exitBinCueMismatch()

# -------------------------------------------------------------------------------------------------

def checkDuplicates(source_files, target_extensions, crash_protection_type=0):

    dupe_options = {
        "s"          : 0,
        "skip"       : 0,
        "o"          : 1,
        "overwrite"  : 1,
    }

    dupe_files = []
    dupe_names = []
    target_files = []

    for extension in target_extensions:
        target_files[len(target_files):] = returnFilteredPwdContents(extension)

    for fname in source_files:
        for extension in target_extensions:
            target_fname = f"{fname[:-4]}.{extension}"
            if (target_fname in target_files):
                dupe_files.append(target_fname)

    match crash_protection_type:
        case 0:
            pass
        case 1:                                                     # Skip any dupe files no matter what
            [dupe_names.append(fname[:-4]) for fname in dupe_files if fname[:-4] not in dupe_names]
            return dupe_names
        case 2:                                                     # Only skip if intermediate .iso present
            [dupe_names.append(fname[:-4]) for fname in dupe_files if fname[:-4] not in dupe_names and fname[-4:] == ".iso"]
        case _:
            pass

    if (not dupe_files):
        return dupe_names

    print("╟-------------------------------------------------------------------------------╢")
    print("║ The following files were found which would be overwritten:")

    for fname in dupe_files:
        print(f"║  - {fname}")

    print("║")
    print("║ You may choose to OVERWRITE or SKIP all of these.")
    if (crash_protection_type == 2):
        # chdman CLI just crashes trying to overwrite a .iso file
        print("║ NOTE: chdman cannot overwrite .iso files, which are used as an intermediate format.")
        print("║       These will be skipped regardless.")
    choice = input("║ Press 'O' to overwrite or 'S' to skip and press ENTER: ").lower()

    if (choice in dupe_options):
        if (not dupe_options[choice]):                              # Skip
            [dupe_names.append(fname[:-4]) for fname in dupe_files if fname[:-4] not in dupe_names]
        return dupe_names
    else:
        exitInvalidOption()

# =================================================================================================

def printInitialStatus(decompressing, target_fname):

    if (gamecount[0] != 0):
        print("╟-------------------------------------------------------------------------------╢")
    gamecount[0] += 1

    if (decompressing):
        print(f"║ Extracting to {target_fname}... ({gamecount[0]}){(58 - len(target_fname) - len(str(gamecount[0]))) * ' '}║")
    else:
        print(f"║ Compressing to {target_fname}... ({gamecount[0]}){(57 - len(target_fname) - len(str(gamecount[0]))) * ' '}║")

# -------------------------------------------------------------------------------------------------

def printSkip(target_fname):

    if (gamecount[0] != 0):
        print("╟-------------------------------------------------------------------------------╢")
    gamecount[0] += 1
    print(f"║ Skipping creation of {target_fname}{(57 - len(target_fname)) * ' '}║")

# =================================================================================================

def createCommandList(mode, source_fname, target_fname, blocksize=0):

    match mode:
        case 1:
            return [["maxcso", f"--block={blocksize}", source_fname]]
        case 2:
            return [["chdman", "createraw", "-us", "2048", "-hs", f"{blocksize}", "-f", "-i", source_fname, "-o", target_fname]]
        case 3:
            return [["chdman", "createcd", "-hs", f"{blocksize}", "-i", source_fname, "-o", f"{source_fname[:-4]}.chd"]]
        case 4:
            return [["maxcso", "--decompress", source_fname],
                    ["chdman", "createraw", "-us", "2048", "-hs", f"{blocksize}", "-f", "-i", f"{source_fname[:-4]}.iso", "-o", f"{source_fname[:-4]}.chd"]]
        case 5:
            return [["chdman", "extractraw", "-i", source_fname, "-o", f"{source_fname[:-4]}.iso"],
                    ["maxcso", f"--block={blocksize}", f"{source_fname[:-4]}.iso"]]
        case 6:
            return [["chdman", "extractraw", "-i", source_fname, "-o", target_fname]]
        case 7:
            return [["chdman", "extractcd", "-i", source_fname, "-o", target_fname]]
        case 8:
            return [["maxcso", "--decompress", source_fname]]
        case _:
            print("You have somehow chosen an invalid mode, and this was not correctly caught by the program.\nPlease report this as a bug.")
            sys.exit(1)

# =================================================================================================

def returnFilteredPwdContents(file_extension):                      # Get files in pwd with extension

    extension_pattern = r".*\." + file_extension.lower()
    extension_reg = re.compile(extension_pattern)
    return [fname for fname in os.listdir('.') if extension_reg.match(fname)]

# -------------------------------------------------------------------------------------------------

def deleteFile(fname):                                              # Delete a file in pwd

    print(f"║ Deleting {fname}...{(66 - len(fname)) * ' '}║")
    os.remove(f"./{fname}")

# =================================================================================================

def exitInvalidOption():

    print("╠===============================================================================╗")
    print("║                              Invalid option.                                  ║")
    print("╚===============================================================================╝")
    sys.exit(1)

# -------------------------------------------------------------------------------------------------

def exitBinCueMismatch():

    print("╠===============================================================================╗")
    print("║                   All BIN files must have a matching CUE.                     ║")
    print("╚===============================================================================╝")
    sys.exit(1)

# =================================================================================================
# /////////////////////////////////////////////////////////////////////////////////////////////////
# =================================================================================================

options = {                                                         # Options listings
    1 : "Convert ISO to CSO",
    2 : "Convert ISO to CHD",
    3 : "Convert CUE/BIN to CHD",
    4 : "Convert CSO to CHD",
    5 : "Convert DVD CHD to CSO",
    6 : "Extract DVD CHD to ISO",
    7 : "Extract CD CHD to CUE/BIN",
    8 : "Extract CSO to ISO",
    9 : "Exit script",
}

# -------------------------------------------------------------------------------------------------

sources = {                                                         # Source file extensions
    1 : "iso",
    2 : "iso",
    3 : "cue/bin",
    4 : "cso",
    5 : "chd",
    6 : "chd",
    7 : "chd",
    8 : "cso",
}

# -------------------------------------------------------------------------------------------------

targets = {                                                         # Target file extensions
    1 : ["cso"],
    2 : ["chd"],
    3 : ["chd"],
    4 : ["iso", "chd"],
    5 : ["iso", "cso"],
    6 : ["iso"],
    7 : ["cue", "bin"],
    8 : ["iso"],
}

# # -------------------------------------------------------------------------------------------------

reqs = {                                                            # Selection dependencies
    1 : ["maxcso"],
    2 : ["chdman"],
    3 : ["chdman"],
    4 : ["maxcso", "chdman"],
    5 : ["maxcso", "chdman"],
    6 : ["chdman"],
    7 : ["chdman"],
    8 : ["maxcso"],
}

# -------------------------------------------------------------------------------------------------

print("╔===============================================================================╗")
print("║  CSO/CHD/ISO/CUEBIN Conversion by Refraction, RedDevilus and TheTechnician27  ║")
print("║                           (Version Jul 16 2024)                               ║")
print("╠===============================================================================╣")
print("║                                                                               ║")
print("║ PLEASE NOTE: This will affect all files in this folder!                       ║")
print("║ Be sure to run this from the same directory as the files you wish to convert. ║")
print("║                                                                               ║")

for number, message in options.items():
    print("║ ", number, " - ", message, f"{(70 - len(message)) * ' '}║")

print("║                                                                               ║")
print("╠===============================================================================╝")
#print("║")
mode = input("║ Type the number corresponding to your selection then press ENTER: ")

# -------------------------------------------------------------------------------------------------

try:
    mode = int(mode)

except ValueError:
    exitInvalidOption()

# -------------------------------------------------------------------------------------------------

if (mode < 9 and mode > 0):

    for program in reqs[mode]:                                  # Check for dependencies
        checkProgramMissing(program)

    delete = deletionChoice(sources[mode])                      # Choose to delete source files
    blocksize = blockSizeChoice(mode == 3, mode > 5)            # Choose block size if compressing

    match mode:
        case 3:
            bin_files = returnFilteredPwdContents("bin")        # Get all BIN files in pwd
            source_files = returnFilteredPwdContents("cue")     # Get all CUE files in pwd
            checkBinCueMismatch(bin_files, source_files)
            dupe_list = checkDuplicates(source_files, targets[mode], 1)
        case 5:
            source_files = returnFilteredPwdContents(sources[mode]) # Get source files in pwd
            dupe_list = checkDuplicates(source_files, targets[mode], 2)
        case 6:
            source_files = returnFilteredPwdContents(sources[mode]) # Get source files in pwd
            dupe_list = checkDuplicates(source_files, targets[mode], 1)
        case _:
            source_files = returnFilteredPwdContents(sources[mode]) # Get source files in pwd
            dupe_list = checkDuplicates(source_files, targets[mode])


    print("╠===============================================================================╗")

    # ---------------------------------------------------------------------------------------------

    for fname in source_files:

        target_fname = f"{fname[:-4]}.{targets[mode][0]}"
        commands = createCommandList(mode, fname, target_fname, blocksize)
        if (fname[:-4] in dupe_list):
            printSkip(target_fname)
            continue

        printInitialStatus(mode > 5, f"{fname[:-4]}.{targets[mode][-1]}")

        for step, command in enumerate (commands):

            process = Popen(commands[step], stdout=PIPE, stderr=PIPE)   # Execute process
            stdout, stderr = process.communicate()                      # Suppress output
            checkSuccess(mode < 6, fname[:-4],                          # Ensure target creation
                         targets[mode][step], process.returncode)

            if (step == 1):                                             # Delete intermediate file
                deleteFile(f"{fname[:-4]}.iso")

        if (delete):                                                    # Delete source requested
            deleteFile(fname)
            if (mode == 3):
                deleteFile(f"{fname[:-4]}.bin")

# ===== EXIT SCRIPT ===============================================================================

elif (mode == 9):
    print("╠===============================================================================╗")
    print("║                                Goodbye! :)                                    ║")
    print("╚===============================================================================╝")
    sys.exit(0)

# ===== EXIT SCRIPT WITH ERROR ====================================================================

else:
    exitInvalidOption()

# -------------------------------------------------------------------------------------------------

print("╠===============================================================================╣")
print("║                               Process complete!                               ║")
print("╚===============================================================================╝")
sys.exit(0)
