"""Build the page's apps for real, and print what each build is. (Windows only)

    python builds.py              the five small builds: hello.py and pond.py
    python builds.py model        three builds from the Python that has the AI library:
                                  hello.py, pond.py with no option added, and pond.py
                                  with the AI model inside

Run it with the Python that has PyInstaller: the one in your venv. For each build
it prints the seconds the build took, the size of the result, the seconds from the
start of the app to its window (the first start, then later starts), the most
memory the app held while its window was open for a while, and the size of a zip
of the app.

Each app is started with a PATH that holds only Windows' own folders, and with no
PYTHON settings. So it cannot borrow the Python that is on this computer.

The commands are the page's commands, with three options added so that every build
goes into its own folder inside checks/made/ and nothing is left beside your files.
The folder has the row's name: checks/made/hello_one_folder/dist/hello, and so on.

When Smart App Control blocks a new .exe, that is counted and printed, and the app
is built again: a new file gets a new answer. It gives up after five tries.

"model" needs what the model game needs: the folder "model" beside pond.py, and
    pip install "decider-ai[gguf]" --only-binary llama-cpp-python
        --extra-index-url https://abetlen.github.io/llama-cpp-python/whl/cpu
"""
import ctypes
import ctypes.wintypes
import os
import pathlib
import platform
import shutil
import subprocess
import sys
import time

CHECKS = pathlib.Path(__file__).resolve().parent
ASSETS = CHECKS.parent                           # hello.py and pond.py: one folder up
MADE = CHECKS / "made"
WM_CLOSE = 0x0010                                # what Windows sends when you click the X
BLOCKED = 4551                                   # "An Application Control policy has blocked this file"

# name of the row, the script, the window's title, the page's options, and how many
# seconds the window stays open before its memory is read
SMALL = [
    ("hello, one folder", "hello.py", "hello", [], 5),
    ("hello, one folder, no console", "hello.py", "hello", ["--windowed"], 5),
    ("hello, one file, no console", "hello.py", "hello", ["--onefile", "--windowed"], 5),
    ("pond, one folder, no console", "pond.py", "pond", ["--windowed"], 5),
    ("pond, one file, no console", "pond.py", "pond", ["--onefile", "--windowed"], 5),
]
BIG = [
    # The same two scripts, built by a Python that has the AI library installed.
    # hello.py imports none of it. pond.py has one import of it, inside a function.
    ("hello, from the model's environment", "hello.py", "hello", [], 5),
    ("pond, from the model's environment, no console", "pond.py", "pond", ["--windowed"], 5),
    ("pond with the model, one folder", "pond.py", "pond",
     ["--windowed", "--add-data", str(ASSETS / "model") + ":model",
      "--collect-binaries", "llama_cpp"], 40),
]


class Counters(ctypes.Structure):
    """What Windows keeps about one process's memory (PROCESS_MEMORY_COUNTERS)."""
    _fields_ = [("cb", ctypes.wintypes.DWORD), ("PageFaultCount", ctypes.wintypes.DWORD),
                ("PeakWorkingSetSize", ctypes.c_size_t), ("WorkingSetSize", ctypes.c_size_t),
                ("QuotaPeakPagedPoolUsage", ctypes.c_size_t), ("QuotaPagedPoolUsage", ctypes.c_size_t),
                ("QuotaPeakNonPagedPoolUsage", ctypes.c_size_t), ("QuotaNonPagedPoolUsage", ctypes.c_size_t),
                ("PagefileUsage", ctypes.c_size_t), ("PeakPagefileUsage", ctypes.c_size_t)]


def most_memory(window):
    """The most memory, in MB, that the process that owns this window has held."""
    pid = ctypes.wintypes.DWORD()
    ctypes.windll.user32.GetWindowThreadProcessId(window, ctypes.byref(pid))
    kernel32 = ctypes.windll.kernel32
    kernel32.OpenProcess.restype = ctypes.wintypes.HANDLE
    process = kernel32.OpenProcess(0x1000 | 0x0400, False, pid.value)
    counters = Counters()
    counters.cb = ctypes.sizeof(Counters)
    read = ctypes.windll.psapi.GetProcessMemoryInfo
    read.argtypes = [ctypes.wintypes.HANDLE, ctypes.POINTER(Counters), ctypes.wintypes.DWORD]
    read(process, ctypes.byref(counters), counters.cb)
    kernel32.CloseHandle(process)
    return counters.PeakWorkingSetSize / 1e6


def plain_environment():
    """What an app finds on a computer with no Python: only Windows' own folders."""
    keep = ["SystemRoot", "SystemDrive", "windir", "TEMP", "TMP", "USERPROFILE",
            "LOCALAPPDATA", "APPDATA", "ProgramData", "COMSPEC", "USERNAME",
            "NUMBER_OF_PROCESSORS", "PROCESSOR_ARCHITECTURE", "HOMEDRIVE", "HOMEPATH"]
    environment = {name: os.environ[name] for name in keep if name in os.environ}
    windows = os.environ["SystemRoot"]
    environment["PATH"] = windows + "\\System32;" + windows
    return environment


def size_of(path):
    """Bytes and number of files: of one file, or of everything in a folder."""
    if path.is_file():
        return path.stat().st_size, 1
    files = [file for file in path.rglob("*") if file.is_file()]
    return sum(file.stat().st_size for file in files), len(files)


def window_of(title):
    return ctypes.windll.user32.FindWindowW(None, title)


def start(exe, title, stay=1.5, longest=300):
    """Start the app and wait for its window. Give back the seconds, or why not.
    The window stays open for `stay` seconds. Then it is closed the way a person does."""
    began = time.perf_counter()
    try:
        # DEVNULL: what a console app prints must not land in this check's own output.
        program = subprocess.Popen([str(exe)], stdout=subprocess.DEVNULL,
                                   stderr=subprocess.DEVNULL, env=plain_environment())
    except OSError as problem:
        if problem.winerror == BLOCKED:
            return None, "blocked"
        raise
    while not window_of(title):
        if program.poll() is not None:
            return None, "it ended with code " + str(program.returncode) + " and no window"
        if time.perf_counter() - began > longest:
            program.kill()
            return None, "no window in " + str(longest) + " s"
        time.sleep(0.01)
    seconds = time.perf_counter() - began
    time.sleep(stay)
    memory = most_memory(window_of(title))
    ctypes.windll.user32.PostMessageW(window_of(title), WM_CLOSE, 0, 0)
    try:
        program.wait(timeout=60)
    except subprocess.TimeoutExpired:
        program.kill()
        return seconds, "it did not close, so it was stopped"
    return seconds, f"exit code {program.returncode}, at most {memory:,.0f} MB of memory"


def build(row, script, options):
    """Run PyInstaller once. Give back the seconds and the app it made."""
    # "hello, one folder" builds into made/hello_one_folder
    out = MADE / row.replace(",", "").replace("'", "").replace(" ", "_")
    shutil.rmtree(out, ignore_errors=True)
    command = [sys.executable, "-m", "PyInstaller", "--noconfirm",
               "--distpath", str(out / "dist"), "--workpath", str(out / "work"),
               "--specpath", str(out)] + options + [str(ASSETS / script)]
    began = time.perf_counter()
    done = subprocess.run(command, capture_output=True, text=True)
    seconds = time.perf_counter() - began
    if done.returncode != 0:
        print(done.stderr[-1500:])
        raise SystemExit("PyInstaller failed on " + script)
    name = pathlib.Path(script).stem
    whole = out / "dist" / (name + ".exe")       # --onefile: the app is this one file
    exe = whole
    if not whole.exists():
        whole = out / "dist" / name              # one folder: the app is this folder
        exe = whole / (name + ".exe")
    return seconds, whole, exe


def one_row(name, script, title, options, stay):
    blocked = 0
    for attempt in range(5):
        build_seconds, whole, exe = build(name, script, options)
        first, how = start(exe, title)
        if how == "blocked":
            blocked += 1
            continue
        break
    else:
        print(f"{name}: Smart App Control blocked all five builds.")
        return blocked, 5
    if first is None:
        print(f"{name}: the first start FAILED: {how}")
        return blocked, attempt + 1
    later = []
    for _ in range(3):
        seconds, how = start(exe, title)
        if seconds is not None:
            later.append(seconds)
    # One more start, with the window open for a while, to read the memory.
    seconds, how = start(exe, title, stay)
    size, files = size_of(whole)
    middle = sorted(later)[len(later) // 2] if later else float("nan")
    shown = " ".join(["python", "-m", "PyInstaller"] + [
        "model:model" if word.endswith(":model") else word for word in options] + [script])
    print(f"{name}\n    {shown}")
    print(f"    build {build_seconds:6.1f} s   size {size / 1e6:8.1f} MB in {files:5d} files   "
          f"first start {first:5.2f} s   later starts {middle:5.2f} s")
    print(f"    open for {stay} s: {how}")
    began = time.perf_counter()
    zipped = shutil.make_archive(str(whole.parent / (whole.stem + "_zip")), "zip",
                                 root_dir=str(whole.parent), base_dir=whole.name)
    print(f"    as a zip: {pathlib.Path(zipped).stat().st_size / 1e6:8.1f} MB, "
          f"made in {time.perf_counter() - began:.0f} s")
    if blocked:
        print(f"    Smart App Control blocked {blocked} of {attempt + 1} builds of this row. "
              f"The numbers are from the build that ran.")
    return blocked, attempt + 1


def main():
    if sys.platform != "win32":
        print(__doc__)
        return 2
    rows = BIG if sys.argv[1:] == ["model"] else SMALL
    import PyInstaller
    print(f"Python {platform.python_version()}, PyInstaller {PyInstaller.__version__}, "
          f"{platform.system()} {platform.release()} build {platform.version()}, "
          f"{os.cpu_count()} logical cores")
    blocked = builds = 0
    for name, script, title, options, stay in rows:
        row_blocked, row_builds = one_row(name, script, title, options, stay)
        blocked += row_blocked
        builds += row_builds
    print(f"{builds} builds in all. Smart App Control blocked {blocked} of them at the first start.")
    return 0


if __name__ == "__main__":
    sys.exit(main())
