#!python
# -*- coding: utf-8 -*-
#
# Copyright (c) 2021-2026 The WfCommons Team.
#
# This program 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 Foundation, either version 3 of the License, or
# (at your option) any later version.

import os
import pathlib
import subprocess
import time
import signal
import sys
import argparse
import re
import json
import logging
import pandas as pd
import psutil
import shutil

from io import StringIO
from filelock import FileLock
from pathos.helpers import mp as multiprocessing

from abc import ABC, abstractmethod
from typing import List, Optional, IO

int32_max = 2147483647
this_dir = pathlib.Path(__file__).resolve().parent


# Configure logging
logging.basicConfig(
    level=logging.INFO,  # Change this to control the verbosity
    format="[WfBench][%(asctime)s][%(levelname)s] %(message)s",
    datefmt="%H:%M:%S",
    handlers=[logging.StreamHandler()]
)

def log_info(msg: str):
    """
    Log an info message to stderr

    :param msg:
    :type msg: str
    """
    logging.info(msg)

def log_debug(msg: str):
    """
    Log a debug message to stderr

    :param msg:
    :type msg: str
    """
    logging.debug(msg)

def log_error(msg: str):
    """
    Log a error message to stderr

    :param msg:
    :type msg: str
    """
    logging.error(msg)

# Utility process class
#######################

class ProcessHandle:
    def __init__(self, proc: multiprocessing.Process | subprocess.Popen):
        self._proc = proc

    @property
    def pid(self):
        return self._proc.pid

    def terminate(self):
        self._proc.terminate()

    def terminate_along_with_children(self):
        if isinstance(self._proc, multiprocessing.Process):
            self._proc.terminate()
            return
        try:
            pgid = os.getpgid(self._proc.pid)
            os.killpg(pgid, signal.SIGKILL)
        except ProcessLookupError:
            pass  # group leader already gone, try children directly
        except PermissionError:
            pass
        finally:
            # Catch any re-parented children (ppid=1) that psutil can still see
            try:
                for child in psutil.Process(self._proc.pid).children(recursive=True):
                    try:
                        child.kill()
                    except psutil.NoSuchProcess:
                        pass
            except psutil.NoSuchProcess:
                pass

    def wait(self):
        if isinstance(self._proc, multiprocessing.Process):
            self._proc.join()
        else:
            self._proc.wait()

    def is_alive(self):
        if isinstance(self._proc, multiprocessing.Process):
            return self._proc.is_alive()
        else:
            return self._proc.poll() is None

# Benchmark classes
###################

class Benchmark(ABC):
    @abstractmethod
    def run(self) -> multiprocessing.Process:
        pass

class IOReadBenchmark:
    def __init__(self):
        self.to_read : dict[str, (IO, int)] = {}

    def add_read_operation(self, filepath: str, opened_file: IO, num_bytes: int):
        self.to_read[filepath] = (opened_file, num_bytes)

    def run(self) -> ProcessHandle | None:
        if len(self.to_read) <= 0:
            return None
        p = multiprocessing.Process(target=self.benchmark_function, args=())
        p.start()
        return ProcessHandle(p)

    def benchmark_function(self):
        for filepath, (opened_file, bytes_to_read) in self.to_read.items():
            log_debug(f"Reading {bytes_to_read} bytes from {filepath}...")
            opened_file.read(bytes_to_read)


class IOWriteBenchmark:
    def __init__(self):
        self.to_write : dict[str, (IO, int)] = {}

    def add_write_operation(self, filepath: str, opened_file: IO, num_bytes: int):
        self.to_write[filepath] = (opened_file, num_bytes)

    def run(self) -> ProcessHandle | None:
        if len(self.to_write) <= 0:
            return None
        p = multiprocessing.Process(target=self.benchmark_function, args=())
        p.start()
        return ProcessHandle(p)

    def benchmark_function(self):
        for filepath, (opened_file, bytes_to_write) in self.to_write.items():
            log_debug(f"Writing {bytes_to_write} bytes to {filepath}...")
            opened_file.write(os.urandom(int(bytes_to_write)))
            opened_file.flush()


class CPUBenchmark:
    def __init__(self, cpu_threads: Optional[int] = 5,
                 mem_threads: Optional[int] = 5,
                 core: Optional[int] = None,
                 total_mem: Optional[int] = None):
        self.cpu_threads = cpu_threads
        self.mem_threads = mem_threads
        self.core = core
        self.total_mem = total_mem
        self.work = None

    def set_work(self, work: int):
        self.work = work

    def set_infinite_work(self):
        self.work = int32_max # "infinite"

    def run(self) -> list[ProcessHandle | None]:
        if self.work is None or self.work <= 0:
            return [None, None]

        total_mem = f"{self.total_mem}B" if self.total_mem else f"{100.0 / os.cpu_count()}%"
        cpu_work_per_thread = int(1000000 * self.work / self.cpu_threads) if self.cpu_threads != 0 else int32_max ** 2
        cpu_samples = min(cpu_work_per_thread, int32_max)
        cpu_ops = (cpu_work_per_thread + int32_max - 1) // int32_max
        if cpu_ops > int32_max:
            log_info("Exceeded maximum allowed value of cpu work.")
            cpu_ops = int32_max


        # Start CPU benchmark, if need be
        cpu_proc_handle = None
        if self.cpu_threads > 0:
            log_debug(f"Running CPU benchmark with {self.cpu_threads} threads for {self.work if self.work < int32_max else 'infinite'} units of work...")
            cpu_prog = ["stress-ng", "--monte-carlo", f"{self.cpu_threads}",
                        "--monte-carlo-method", "pi",
                        "--monte-carlo-rand", "lcg",
                        "--monte-carlo-samples", f"{cpu_samples}",
                        "--monte-carlo-ops", f"{cpu_ops}",
                        "--quiet"]
            cpu_proc = subprocess.Popen(cpu_prog, preexec_fn=os.setsid)
            cpu_proc_handle = ProcessHandle(cpu_proc)

            # NOTE: might be a good idea to use psutil to set the affinity (works across platforms)
            if self.core:
                os.sched_setaffinity(cpu_proc.pid, {self.core})

        # Start Memory benchmark, if need be
        mem_proc_handle = None
        if self.mem_threads > 0:
            # NOTE: add a check to use creationflags=subprocess.CREATE_NEW_PROCESS_GROUP for Windows
            log_debug(f"Running memory benchmark with {self.mem_threads} threads...")
            mem_prog = ["stress-ng", "--vm", f"{self.mem_threads}",
                        "--vm-bytes", f"{total_mem}", "--vm-keep", "--quiet"]
            mem_proc = subprocess.Popen(mem_prog, preexec_fn=os.setsid)
            mem_proc_handle = ProcessHandle(mem_proc)
            if self.core:
                os.sched_setaffinity(mem_proc.pid, {self.core})

        return [cpu_proc_handle, mem_proc_handle]


class GPUBenchmark:

    @staticmethod
    def get_available_gpus():
        if "CUDA_VISIBLE_DEVICES" in os.environ:
            return [int(i) for i in os.environ["CUDA_VISIBLE_DEVICES"].split(",")]
        elif "ROCR_VISIBLE_DEVICES" in os.environ:
            return [int(i) for i in os.environ["ROCR_VISIBLE_DEVICES"].split(",")]
        elif shutil.which("nvidia-smi") is not None:
            proc = subprocess.Popen(["nvidia-smi", "--query-gpu=utilization.gpu", "--format=csv"], stdout=subprocess.PIPE,
                                    stderr=subprocess.PIPE)
            stdout, _ = proc.communicate()
            df = pd.read_csv(StringIO(stdout.decode("utf-8")), sep=" ")
            return df[df["utilization.gpu"] <= 5].index.to_list()
        elif shutil.which("amd-smi") is not None:
            proc = subprocess.Popen(["amd-smi", "monitor", "-u", "--csv"], stdout=subprocess.PIPE,
                                    stderr=subprocess.PIPE)
            stdout, _ = proc.communicate()
            df = pd.read_csv(StringIO(stdout.decode("utf-8")), sep=",")
            return df[df["gfx"] <= 5].index.to_list()
        else:
            log_error("No supported GPU monitoring tool found.")
            return []

    def __init__(self):
        self.work = None
        self.duration = None
        self.device = None

    def set_device(self):
        available_gpus = self.get_available_gpus()  # checking for available GPUs
        if not available_gpus:
            log_error("No GPU available")
            sys.exit(1)
        self.device = available_gpus[0]
        log_debug(f"GPU benchmark instantiated for device {self.device}")

    def set_work(self, work: int):
        self.work = 1000000 * work

    def set_time(self, duration: float):
        self.duration = duration

    def run(self) -> ProcessHandle | None:
        if self.work is None and self.duration is None:
            return None

        if self.duration is not None:
            log_debug(f"Running GPU benchmark for {self.duration} seconds")
            if shutil.which("nvidia-smi") is not None:
                gpu_prog = [
                    f"CUDA_DEVICE_ORDER=PCI_BUS_ID CUDA_VISIBLE_DEVICES={self.device} {this_dir.joinpath('./gpu_benchmark')} {self.work} {self.duration}"]
            else:
                gpu_prog = [
                    f"HIP_DEVICE_ORDER=PCI_BUS_ID ROCR_VISIBLE_DEVICES={self.device} {this_dir.joinpath('./gpu_benchmark')} {self.work} {self.duration}"]
        else:
            log_debug(f"Running GPU benchmark for {self.work} units of work")
            if shutil.which("nvidia-smi") is not None:
                gpu_prog = [
                    f"CUDA_DEVICE_ORDER=PCI_BUS_ID CUDA_VISIBLE_DEVICES={self.device} {this_dir.joinpath('./gpu_benchmark')} {self.work}"]
            else:
                gpu_prog = [
                    f"HIP_DEVICE_ORDER=PCI_BUS_ID ROCR_VISIBLE_DEVICES={self.device} {this_dir.joinpath('./gpu_benchmark')} {self.work}"]
        p = subprocess.Popen(gpu_prog, shell=True)
        return ProcessHandle(p)


# Utility code functions
########################

def lock_core(path_locked: pathlib.Path,
              path_cores: pathlib.Path) -> int:
    """
    Lock cores in use.

    :param path_locked:
    :type path_locked: pathlib.Path
    :param path_cores:
    :type path_cores: pathlib.Path

    :return:
    :rtype: int
    """
    all_cores = set(range(os.cpu_count()))
    path_locked.touch(exist_ok=True)
    path_cores.touch(exist_ok=True)

    while True:
        with FileLock(path_locked) as lock:
            try:
                lock.acquire()
                taken_cores = {
                    int(line) for line in path_cores.read_text().splitlines() if line.strip()
                }
                available = all_cores - taken_cores
                if available:
                    core = available.pop()
                    taken_cores.add(core)
                    path_cores.write_text("\n".join(map(str, taken_cores)))
                    return core

                log_debug("All Cores are taken")
            finally:
                lock.release()
        time.sleep(1)


def unlock_core(path_locked: pathlib.Path,
                path_cores: pathlib.Path,
                core: int) -> None:
    """
    Unlock cores after execution is done.

    :param path_locked:
    :type path_locked: pathlib.Path
    :param path_cores:
    :type path_cores: pathlib.Path
    :param core:
    :type core: int
    """
    with FileLock(path_locked) as lock:
        lock.acquire()
        try:
            taken_cores = {
                int(line) for line in path_cores.read_text().splitlines()
                if int(line) != core
            }
            path_cores.write_text("\n".join(map(str, taken_cores)))
        finally:
            lock.release()


def get_parser() -> argparse.ArgumentParser:
    parser = argparse.ArgumentParser()
    parser.add_argument("--name", default=None, required=True, help="Task name.")
    parser.add_argument("--rundir", help="Run directory.")
    parser.add_argument("--percent-cpu", default=0.5, type=float,
                        help="percentage related to the number of CPU threads.")
    parser.add_argument("--path-lock", default=None, help="Path to lock file.")
    parser.add_argument("--path-cores", default=None, help="Path to cores file.")
    parser.add_argument("--cpu-work", default=None, type=int, help="Amount of CPU work.")
    parser.add_argument("--num-chunks", default=10, type=int, help="Number of chunks used for pipelining I/O and "
                                                         "computation throughout the execution (fewer chunks may be used "
                                                         "if amounts of work and or input/output file sizes are too small).")
    parser.add_argument("--gpu-work", default=None, type=int, help="Amount of GPU work.")
    parser.add_argument("--time-limit", default=None, type=int, help="Time limit (in seconds) to complete "
                                                     "the computational portion of the benchmark (overrides CPU and GPU works). "
                                                     "Is only approximate since I/O time may make the overall time longer.")
    parser.add_argument("--mem", type=float, default=None, help="Maximum memory consumption (in MB).")
    parser.add_argument("--output-files", help="Output file names with sizes in bytes as a JSON dictionary "
                                               "(e.g., --output-files \"{\\\"file1\\\": 1024, \\\"file2\\\": 2048}\") OR "
                                               "a path to a .json file that contains that dictionary.")
    parser.add_argument("--input-files", help="Input files names as a JSON array "
                                              "(e.g., --input-files \"[\\\"file3\\\", \\\"file4\\\"]\") OR "
                                              "a path to a .json file that contains that array.")
    parser.add_argument("--verbose", action="store_true", help="Enable all log messages.")
    parser.add_argument("--debug", action="store_true", help="Enable debug log messages.")
    parser.add_argument("--with-flowcept", action="store_true", default=False, help="Enable Flowcept monitoring.")
    parser.add_argument("--workflow_id", default=None, help="Id to group tasks in a workflow.")

    return parser


def begin_flowcept(workflow_id, name, used):
    log_debug("Running with Flowcept.")
    from flowcept import Flowcept, FlowceptTask
    # TODO: parametrize to allow storing individual tasks
    f = Flowcept(workflow_id=workflow_id,
                 bundle_exec_id=workflow_id,
                 start_persistence=False, save_workflow=False)
    f.start()
    t = FlowceptTask(task_id=f"{workflow_id}_{name}", workflow_id=workflow_id, used=used)
    return f, t


def end_flowcept(flowcept, flowcept_task):
    flowcept_task.end()
    flowcept.stop()


def compute_num_chunks(time_limit, cpu_work, gpu_work, num_chunks):
    # Compute the (feasible number of chunks)
    min_chunk_size_time = 1 # At least 1 second per chunk, if we're doing time-based
    # TODO: Pick reasonable factors below so that a chunk takes about min_chunk_size_time sec on a reasonable machine
    min_chunk_size_cpu_work = 2500 * min_chunk_size_time  # 1s on Henri's MacBook Pro
    min_chunk_size_gpu_work = 2000 * min_chunk_size_time # unknown.....

    if time_limit:
        num_chunks = min(num_chunks, time_limit // min_chunk_size_time)
    else:
        if cpu_work:
            num_chunks_cpu = min(num_chunks, cpu_work // min_chunk_size_cpu_work)
        else:
            num_chunks_cpu = None
        if gpu_work:
            num_chunks_gpu = min(num_chunks, gpu_work // min_chunk_size_gpu_work)
        else:
            num_chunks_gpu = None

        if num_chunks_cpu is None:
            num_chunks = num_chunks_gpu
        elif num_chunks_gpu is None:
            num_chunks = num_chunks_cpu
        else:
            num_chunks = min(num_chunks_cpu, num_chunks_gpu)

    num_chunks = max(num_chunks, 1)  # The above computations may say "zero"
    return num_chunks

def kill_current_handles(handles: list[ProcessHandle]):
    for handle in handles:
        if handle is not None and handle.is_alive():
            handle.terminate_along_with_children()


def run(workflow_id, name, with_flowcept, verbose, debug, rundir, path_lock, path_cores,
        time_limit, cpu_work, percent_cpu, mem, gpu_work, num_chunks,
        input_files, output_files):
    """Main function."""

    if with_flowcept:
        flowcept, flowcept_task = begin_flowcept(workflow_id, name, locals())
    else:
        flowcept = None
        flowcept_task = None

    if verbose:
        logging.getLogger().setLevel(logging.NOTSET)
    if debug:
        logging.getLogger().setLevel(logging.DEBUG)

    if rundir:
        rundir = pathlib.Path(rundir)
    else:
        rundir = pathlib.Path(os.getcwd())

    if path_lock and path_cores:
        path_locked = pathlib.Path(path_lock)
        path_cores = pathlib.Path(path_cores)
        core = lock_core(path_locked, path_cores)
    else:
        path_locked = None
        path_cores = None
        core = None

    # Compute the (feasible) number of chunks based on the arguments
    num_chunks = compute_num_chunks(time_limit, cpu_work, gpu_work, num_chunks)
    log_debug(f"Executing benchmark with {num_chunks} chunks.")

    # At this point we know the number of chunks, and we can just iterate as follows (N = num_chunks + 2)
    #    step 0   sep 1        step 2                step N-3     step N-2      step N-1
    #    READ     READ         READ            ...   READ             -           -
    #      -      COMPUTE_CPU  COMPUTE_CPU     ...   COMPUTE_CPU  COMPUTE_CPU     -
    #      -      COMPUTE_GPU  COMPUTE_GPU     ...   COMPUTE_GPU  COMPUTE_GPU     -
    #      -          -        WRITE           ...   WRITE        WRITE         WRITE
    #   (Intermediate READ and WRITE steps may do nothing for some files if there is too little data)

    # Construct a list of benchmark steps, where each step is a list of IO benchmarks (Read or Write)
    # and a list of non-IO benchmarks (CPU, GPU). Initially these are all "do nothing" benchmarks
    N = num_chunks + 2
    steps = [{"io_read_benchmark": IOReadBenchmark(),
              "io_write_benchmark": IOWriteBenchmark(),
              "cpu_benchmark": CPUBenchmark(cpu_threads=int(10 * percent_cpu),
                                            mem_threads=int(10 - 10 * percent_cpu),
                                            core=core,
                                            total_mem=mem * 1000 * 1000 if mem else None),
              "gpu_benchmark": GPUBenchmark()} for i in range(N)]

    min_chunk_size_data = 1000  # 1KB per chunk at a minimum for each input / output file, otherwise the file
    # is read/written all at once at the beginning/end

    # Augment I/O read benchmarks for each input file

    if input_files and input_files.endswith(".json"):
        try:
            with open(input_files, "r") as f:
                input_files = json.load(f)
        except FileNotFoundError as e:
            log_error(f"--input-files JSON file not found: {e}")
            sys.exit(1)
        except json.JSONDecodeError as e:
            log_error(f"Failed to decode JSON in file '{input_files}': {e}")
            sys.exit(1)
    else:
        cleaned_input = "[]" if input_files is None else re.sub(r'\\+', '', input_files)
        try:
            input_files = json.loads(cleaned_input)
        except json.JSONDecodeError as e:
            log_error(f"Failed to decode --input-files JSON string argument: {e}")
            sys.exit(1)
    if not isinstance(input_files, list):
        log_error(f"--input-files must be a list JSON object, got {type(input_files).__name__}")
        sys.exit(1)

    for file_path in input_files:
        file_size = os.path.getsize(file_path)
        # If file is zero-size, do nothing
        if file_size == 0:
            continue
        opened_file = open(rundir / file_path, "rb")
        # If file is "small" only read it at the beginning
        if file_size < num_chunks * min_chunk_size_data:
            steps[0]["io_read_benchmark"].add_read_operation(file_path, opened_file, file_size)
            continue
        # Otherwise, read it in chunks
        for step in range(0, N-2):
            num_bytes = file_size // num_chunks + (file_size % num_chunks > step)
            steps[step]["io_read_benchmark"].add_read_operation(file_path, opened_file, num_bytes)

    # Augment I/O write benchmarks for each output file
    if output_files and output_files.endswith(".json"):
        try:
            with open(output_files, "r") as f:
                output_files = json.load(f)
        except FileNotFoundError as e:
            log_error(f"--output-files JSON file not found: {e}")
            sys.exit(1)
        except json.JSONDecodeError as e:
            log_error(f"Failed to decode JSON in file '{output_files}': {e}")
            sys.exit(1)
    else:
        cleaned_output = "{}" if output_files is None else re.sub(r'\\+', '', output_files)
        try:
            output_files = json.loads(cleaned_output)
        except json.JSONDecodeError as e:
            log_error(f"Failed to decode --output-files JSON string argdument: {e}")
            sys.exit(1)
    if not isinstance(output_files, dict):
        log_error(f"--output-files must be a dict JSON object, got {type(input_files).__name__}")
        sys.exit(1)

    for file_path, file_size in output_files.items():
        # Open the file for writing no matter what (it should be created)
        opened_file = open(rundir / file_path, "wb")
        # If file is zero-size, do nothing
        if file_size == 0:
            continue
        # If file is "small" only write it at the end
        if file_size < num_chunks * min_chunk_size_data:
            steps[N-1]["io_write_benchmark"].add_write_operation(file_path, opened_file, file_size)
            continue
        # Otherwise, write it in chunks
        for step in range(2, N):
            num_bytes = file_size // num_chunks + (file_size % num_chunks > (step - 2))
            steps[step]["io_write_benchmark"].add_write_operation(file_path, opened_file, num_bytes)

    # Augment CPU benchmark with computation (if need be)
    if cpu_work:
        if time_limit:
            for step in range(1, N-1):
                steps[step]["cpu_benchmark"].set_infinite_work()
        else:
            for step in range(1, N-1):
                chunk_work = int(cpu_work) // num_chunks + (int(cpu_work) % num_chunks > step - 1)
                steps[step]["cpu_benchmark"].set_work(chunk_work)

    # Augment GPU benchmark with computation (if need be)
    if gpu_work:
        if time_limit:
            for step in range(1, N - 1):
                steps[step]["gpu_benchmark"].set_device()
                steps[step]["gpu_benchmark"].set_work(int(gpu_work))
                steps[step]["gpu_benchmark"].set_time(float(time_limit))
        else:
            for step in range(1, N - 1):
                chunk_work = int(gpu_work) // num_chunks + (int(gpu_work) % num_chunks > step - 1)
                steps[step]["gpu_benchmark"].set_device()
                steps[step]["gpu_benchmark"].set_work(chunk_work)

    # All benchmarks have been specified, we can just go through the steps blindly
    # log_info(f"Starting {args.name} Benchmark")

    current_proc_handles = []
    try:
        for step_index, step in enumerate(steps):
            log_debug(f"**** STEP {step_index} ***")
            io_read_process = step["io_read_benchmark"].run()
            current_proc_handles += [io_read_process]
            io_write_process = step["io_write_benchmark"].run()
            [cpu_benchmark_process, memory_benchmark_process] = step["cpu_benchmark"].run()
            current_proc_handles += [cpu_benchmark_process, memory_benchmark_process]
            gpu_benchmark_process = step["gpu_benchmark"].run()
            current_proc_handles += [gpu_benchmark_process]
            current_proc_handles[:] = [io_read_process, cpu_benchmark_process, memory_benchmark_process, gpu_benchmark_process]

            # If time based, sleep the required amount of time and kill the process
            if time_limit:
                if cpu_benchmark_process is not None or gpu_benchmark_process is not None:
                    time.sleep(float(time_limit) / num_chunks)
                    if cpu_benchmark_process is not None:
                        cpu_benchmark_process.terminate_along_with_children()
                    if gpu_benchmark_process is not None:
                        gpu_benchmark_process.terminate()

            # Wait for the I/O processes to be done
            if io_read_process is not None:
                io_read_process.wait()
            if io_write_process is not None:
                io_write_process.wait()

            # Wait for the CPU process to be done
            if cpu_benchmark_process is not None:
                cpu_benchmark_process.wait()

            # Kill the Memory process
            if memory_benchmark_process is not None:
                memory_benchmark_process.terminate_along_with_children()
                memory_benchmark_process.wait()

            # Wait for the GPU Process to be done
            if gpu_benchmark_process is not None:
                gpu_benchmark_process.wait()
    except KeyboardInterrupt:
        log_debug("Detected Keyboard interrupt: cleaning up processes...")
        kill_current_handles(current_proc_handles)
    finally:
        log_debug("Aborting: cleaning up processes...")
        kill_current_handles(current_proc_handles)


    # Cleanups
    if core:
        unlock_core(path_locked, path_cores, core)

    if with_flowcept:
        end_flowcept(flowcept, flowcept_task)

    log_info(f"{name} benchmark completed")

def main():
    # Parse command-line argument
    parser = get_parser()
    args = parser.parse_args()

    # Sanity checks
    if not args.time_limit and (not args.cpu_work and not args.gpu_work):
        log_error("At least one of --time-limit, --cpu-work, or --gpu-work should be provided.")
        sys.exit(1)
    if args.time_limit and (not args.cpu_work and not args.gpu_work):
        log_error("In addition to --time-limit, at least one of --cpu-work or --gpu-work must be provided (to specify which kind of work should be done).")
        sys.exit(1)

    run(**vars(args))
    
if __name__ == "__main__":
    main()
