#!/usr/bin/env python3
#
# Copyright 2025 Google LLC
#
# Use of this source code is governed by a BSD-style license that can be
# found in the LICENSE file.
#
import argparse
import hashlib
import os
import shutil
import subprocess
import sys


def add_common_cmake_args(parser):
  """Adds common arguments used for building with CMake."""
  print(f"Running with Python executable: {sys.executable}")

  parser.add_argument("--cc", required=True, help="Path to the C compiler.")
  parser.add_argument("--cxx", required=True, help="Path to the C++ compiler.")
  parser.add_argument(
      "--cxx_flags",
      default=[],
      action="append",
      help="C++ compiler flags. Can be specified multiple times.")
  parser.add_argument(
      "--ld_flags",
      default=[],
      action="append",
      help="Linker flags. Can be specified multiple times.")
  parser.add_argument(
      "--output_path", required=True, help="Path to the output library.")
  parser.add_argument(
      "--depfile_path", required=True, help="Path to the depfile to generate.")
  parser.add_argument(
      "--target_os", required=True, help="Target OS for cross-compilation.")
  parser.add_argument(
      "--target_cpu", required=True, help="Target CPU for cross-compilation.")
  parser.add_argument(
      "--win_sdk", default="", help="Path to the Windows SDK.")
  parser.add_argument(
      "--win_sdk_version", default="", help="Version of the Windows SDK.")
  parser.add_argument(
      "--win_vc", default="", help="Path to Visual C++.")
  parser.add_argument(
      "--win_toolchain_version",
      default="",
      help="Version of the MSVC toolchain.")
  parser.add_argument(
      "--build_type", default="Release", help="CMake build type.")
  parser.add_argument(
      "--build_dir",
      required=True,
      help="A short name for the build directory.")
  parser.add_argument("--is_clang", action=argparse.BooleanOptionalAction)
  parser.add_argument(
      "--enable_rtti", action=argparse.BooleanOptionalAction, help="Enable RTTI.")

def add_next_batch_to_command(base_cmd, workset):
  if sys.platform != 'win32':
    batch = list(workset)
    return base_cmd + batch, batch
  # Windows has a limit of about 8100 characters on command line commands.
  # Thus we batch our commands to fit under this. 100 is usually short enough
  # but with fluctuations on the order returned by ninja and set(), this can
  # still sometimes be too long, so we ensure we don't go over the limit.
  BATCH_SIZE = 100
  batch = list(workset)[:BATCH_SIZE]
  cmd = base_cmd + batch

  while len(' '.join(cmd)) > 8100:
    assert(len(batch) > 1)
    batch.pop()
    cmd = base_cmd + batch

  return cmd, batch

def discover_dependencies(build_dir, targets):
  """Runs ninja -tinputs recursively to discover all targets, then uses
  ninja -tdeps to get all source files and object files for those targets.
  Returns a tuple of (list of source files, list of object files)."""
  # The worklist contains ninja targets we need to find the inputs for.
  worklist = set(targets)
  # We keep track of all ninja targets we've ever seen to avoid cycles and
  # redundant processing.
  seen_targets = set(targets)

  source_files = set()

  ninja = shutil.which("ninja");
  if not ninja:
    print("Error: ninja not found in PATH.")
    sys.exit(1)

  while len(worklist) > 0:
    cmd, current_batch = add_next_batch_to_command([ninja, "-C", build_dir, "-tinputs"],
                                                   worklist)
    worklist = worklist.difference(current_batch)

    inputs = subprocess.check_output(cmd).decode("utf-8").splitlines()
    # inputs looks like:
    #   /home/user/skia/third_party/externals/dawn/src/tint/utils/text/styled_text_theme.cc
    #   /home/user/skia/third_party/externals/dawn/src/tint/utils/text/unicode.cc
    #   cmake_object_order_depends_target_tint_api_common
    #   cmake_object_order_depends_target_tint_lang_core
    #   src/tint/CMakeFiles/tint_api_common.dir/api/common/vertex_pulling_config.cc.o
    #   src/tint/CMakeFiles/tint_lang_core.dir/lang/core/binary_op.cc.o
    #   src/tint/libtint_api_common.a
    #   src/tint/libtint_lang_core.a
    # Some of these are files (which we can add to the source file list), some are more targets,
    # which we should recursively get targets for to make sure we have the whole dependency graph.
    for line in inputs:
      line = line.strip()
      if os.path.isabs(line):
        # Absolute paths are source files.
        source_files.add(line)
        continue
      if line not in seen_targets:
        worklist.add(line)
        seen_targets.add(line)

  object_files = []
  for target in seen_targets:
    if target.endswith(".o") or target.endswith(".obj"):
      object_files.append(target)
  object_files.sort()

  # Now that we have all the targets and some of the source files, get the dependencies for them.
  abs_build_dir = os.path.abspath(build_dir)

  worklist = set(seen_targets)
  while len(worklist) > 0:
    cmd, current_batch = add_next_batch_to_command([ninja, "-C", build_dir, "-tdeps"],
                                                   worklist)
    worklist = worklist.difference(current_batch)

    output = subprocess.check_output(cmd).decode("utf-8").splitlines()
    # When a target has deps, which are read from the .d files generated from the "-dkeepdepfile
    # option earlier, the ninja command outputs something like this:
    #   third_party/spirv-tools/source/opt/CMakeFiles/SPIRV-Tools-opt.dir/eliminate_dead_functions_util.cpp.o: #deps 408, deps mtime 1755004530688332629 (VALID)
    #       /home/user/skia/third_party/externals/dawn/third_party/spirv-tools/src/source/opt/eliminate_dead_functions_util.cpp
    #       /usr/include/stdc-predef.h
    #       /home/user/skia/third_party/externals/dawn/third_party/spirv-tools/src/source/opt/eliminate_dead_functions_util.h
    #       /home/user/skia/third_party/externals/dawn/third_party/spirv-tools/src/source/opt/ir_context.h
    #       /usr/include/c++/14/algorithm
    #   These can sometimes be relative files (on Windows) with the base path being the build_dir
    #   src/tint/CMakeFiles/tint_lang_wgsl_sem.dir/lang/wgsl/sem/variable.cc.obj: #deps 300, deps mtime 7816349331123163 (VALID)
    #     ../../../../../cipd/clang_win/lib/clang/18/include/x86gprintrin.h
    #     ../../../../../cipd/win_toolchain/VC/Tools/MSVC/14.39.33519/include/__msvc_bit_utils.hpp
    #     ../../../../../cipd/win_toolchain/VC/Tools/MSVC/14.39.33519/include/cctype
    #     ../../../../../cipd/win_toolchain/win_sdk/Include/10.0.22621.0/ucrt/wchar.h
    #     ../../../third_party/externals/dawn/src/tint/api/common/binding_point.h
    #     ../../../third_party/externals/dawn/src/tint/lang/core/constant/clone_context.h
    # If there's not a match, it's a simple message like:
    # src/tint/libtint_utils_text_generator.a: deps not found
    # We want to aggregate all the indented files
    for line in output:
      if not line.startswith("  ") or "deps not found" in line:
        continue
      line = line.strip()
      if os.path.isabs(line):
        source_files.add(line)
      else:
        dep = os.path.normpath(os.path.join(abs_build_dir, line))
        source_files.add(dep)

  result = list(source_files)
  result.sort()
  return result, object_files


def write_depfile(output_path, depfile_path, dependencies):
  """Generates a .d file that lists all discovered source files as
  dependencies for the given output library."""
  os.makedirs(os.path.dirname(depfile_path), exist_ok=True)
  with open(depfile_path, "w") as f:
    f.write(f"{output_path}:")
    # Make sure to use forward slashes for paths in the depfile, even on
    # Windows, for consistency.
    for dep in dependencies:
      f.write(" \\\n" + dep.replace("\\", "/"))
    f.write("\n")


def quote_if_needed(path):
  """Adds quotes to a path if it contains spaces."""
  if " " in path:
    return f'"{path}"'
  return path


def copy_if_changed(src, dest):
  """Copies the file from src to dest if dest doesn't exist or if the
  contents of src and dest are different."""
  if os.path.exists(dest):
    src_hash = hashlib.sha256(open(src, "rb").read()).hexdigest()
    dest_hash = hashlib.sha256(open(dest, "rb").read()).hexdigest()
    if src_hash == dest_hash:
      # The files are identical, no need to copy.
      return

  # Either the destination does not exist or it is different.
  shutil.copyfile(src, dest)
  os.chmod(dest, 0o755)


def get_cmake_os_cpu(os, cpu):
  # https://stackoverflow.com/a/70498851
  os = os.lower().strip()
  cpu = cpu.lower().strip()
  if os == "android":
    target_cpu_map = {
      "arm": "armeabi-v7a",
      "arm64": "arm64-v8a",
      "x64": "x86_64",
      "x86": "x86",
    }
    return "Android", target_cpu_map[cpu]

  if os == "linux":
    target_cpu_map = {
      "arm": "arm",
      "arm64": "aarch64",
      "x64": "x86_64",
      "x86": "i686",
    }
    return "Linux", target_cpu_map[cpu]

  if os == "mac":
    target_cpu_map = {
      "arm64": "arm64",
      "x64": "x86_64",
    }
    return "Darwin", target_cpu_map[cpu]

  if os == "win":
    target_cpu_map = {
      "arm64": "ARM64",
      "x64": "AMD64",
    }
    return "Windows", target_cpu_map[cpu]

  if os == "wasm":
    return "wasm", "wasm"

  print("Unsupported OS")
  sys.exit(1)


def get_windows_settings(args):
  """The Windows toolchain requires a lot of setup for cmake to use it.
     This encapsulates all that setup.
  """

  assert(args.win_vc)
  assert(args.win_sdk)
  assert(args.win_sdk_version)

  win_cfgs, win_cxx, win_ld  = [], [], []

  # Set the Windows SDK version for CMake.
  win_cfgs.append(
      f"-DCMAKE_VS_WINDOWS_TARGET_PLATFORM_VERSION={args.win_sdk_version}")

  # Explicitly tell CMake where to find the Resource Compiler, Manifest Tool, and Archiver.
  rc_exe_path = os.path.join(args.win_sdk, "bin", args.win_sdk_version,
                             args.target_cpu, "rc.exe")
  win_cfgs.append(f"-DCMAKE_RC_COMPILER={rc_exe_path.replace(os.sep, '/')}")
  mt_exe_path = os.path.join(args.win_sdk, "bin", args.win_sdk_version,
                             args.target_cpu, "mt.exe")
  win_cfgs.append(f"-DCMAKE_MT={mt_exe_path.replace(os.sep, '/')}")

  ar_exe_path = os.path.join(args.win_vc, "Tools", "MSVC",
                             args.win_toolchain_version, "bin", "Hostx64",
                             args.target_cpu, "lib.exe")
  win_cfgs.append(f"-DCMAKE_AR={ar_exe_path.replace(os.sep, '/')}")

  # On Windows, we need to explicitly tell clang where to find the toolchain
  # headers and libraries.
  um_lib_path = os.path.join(args.win_sdk, "Lib", args.win_sdk_version, "um", args.target_cpu)
  ucrt_lib_path = os.path.join(args.win_sdk, "Lib", args.win_sdk_version, "ucrt", args.target_cpu)
  msvc_lib_path = os.path.join(args.win_vc, "Tools", "MSVC", args.win_toolchain_version, "lib", args.target_cpu)

  win_ld += [
    f"/LIBPATH:{quote_if_needed(um_lib_path.replace(os.sep, '/'))}",
    f"/LIBPATH:{quote_if_needed(ucrt_lib_path.replace(os.sep, '/'))}",
    f"/LIBPATH:{quote_if_needed(msvc_lib_path.replace(os.sep, '/'))}",
  ]

  # Skia builds with exceptions and RTTI disabled so we must also build Dawn that way.
  win_cxx += [
      "-D_HAS_EXCEPTIONS=0",
      "/GR-",
      "/w",  # Dawn's warnings are noisy
  ]

  # Skia uses a hermetic toolchain, so we need to tell clang where to
  # find the MSVC headers and libraries. If we pass the MSVC style flags (/I)
  # to clang, then abseil fails to compile with errors about using a
  # reinterpret_cast in a static_assert.
  if args.is_clang:
    win_cxx += [
        "-imsvc",
        quote_if_needed(os.path.join(args.win_vc, "Tools", "MSVC", args.win_toolchain_version, "include")),
        "-imsvc",
        quote_if_needed(os.path.join(args.win_sdk, "Include", args.win_sdk_version, "ucrt")),
        "-imsvc",
        quote_if_needed(os.path.join(args.win_sdk, "Include", args.win_sdk_version, "shared")),
        "-imsvc",
        quote_if_needed(os.path.join(args.win_sdk, "Include", args.win_sdk_version, "um")),
        "-imsvc",
        quote_if_needed(os.path.join(args.win_sdk, "Include", args.win_sdk_version, "winrt")),
    ]
  else:
    win_cxx += [
        "/I" + quote_if_needed(os.path.join(args.win_vc, "Tools", "MSVC", args.win_toolchain_version, "include")),
        "/I" + quote_if_needed(os.path.join(args.win_sdk, "Include", args.win_sdk_version, "ucrt")),
        "/I" + quote_if_needed(os.path.join(args.win_sdk, "Include", args.win_sdk_version, "shared")),
        "/I" + quote_if_needed(os.path.join(args.win_sdk, "Include", args.win_sdk_version, "um")),
        "/I" + quote_if_needed(os.path.join(args.win_sdk, "Include", args.win_sdk_version, "winrt")),
    ]

  # We want to build Dawn (and its dependencies) with /MT so we can statically
  # link it into Skia.
  win_cfgs.append("-DCMAKE_MSVC_RUNTIME_LIBRARY=MultiThreaded")
  win_cfgs.append("-DABSL_MSVC_STATIC_RUNTIME=ON")

  return win_cfgs, win_cxx, win_ld


def combine_into_library(args, output_path, build_dir, target_os, object_files):
  """Combine all the object files into a single .a/.lib file so it's easier to
     give this to GN."""

  # Delete generated library if it exists already (otherwise ar sometimes chokes)
  lib_name = os.path.basename(output_path)
  gen_library_path = os.path.join(build_dir, lib_name)
  if os.path.exists(gen_library_path):
    os.remove(gen_library_path)

  assert len(object_files) > 0
  # Use ar/lib to join all the object files that comprise the necessary
  # libraries and any transitive dependencies into one .a file.
  if target_os == "Windows":
    # On Windows, we use lld-link.exe for clang, and lib.exe for MSVC.
    if args.is_clang:
        linker_exe = os.path.join(os.path.dirname(args.cc), "lld-link.exe")
    else:
        # We can't just use ar.exe because it is not shipped with MSVC.
        # We must use lib.exe, which has a different command line.
        linker_exe = os.path.join(args.win_vc, "Tools", "MSVC",
                               args.win_toolchain_version, "bin", "Hostx64",
                               args.target_cpu, "lib.exe")
    # The command line can be too long, so we use a response file.
    response_file_name = "objects.rsp"
    response_file_path = os.path.join(build_dir, response_file_name)
    with open(response_file_path, "w") as f:
      for obj in object_files:
        f.write(f'"{obj}"\n')
    combine_obj_cmd = [
        linker_exe, "/LIB", f"/OUT:{lib_name}", f"@{response_file_name}"
    ]
  else:
    combine_obj_cmd = ["ar", "rcs", lib_name] + object_files
  subprocess.run(combine_obj_cmd, cwd=build_dir, check=True)

  copy_if_changed(gen_library_path, os.path.join(os.getcwd(), output_path))



def get_third_party_locations():
  """Return CMake configure arguments to point to or disable third_party deps"""
  def verify_and_get(subpath):
    third_party_dir = os.path.join(os.path.dirname(os.path.realpath(__file__)), "..", "externals")
    third_party_dir = os.path.abspath(third_party_dir)
    path = os.path.join(third_party_dir, subpath)
    if not os.path.exists(path):
      print(f"Third party path {path} not found - did you sync your DEPS?")
      sys.exit(1)
    return path

  return [
    # Actually downloading the 3p repos is handled by DEPS / tools/git-sync-deps
    "-DDAWN_FETCH_DEPENDENCIES=OFF",
    # Necessary 3p deps
    f"-DDAWN_ABSEIL_DIR={verify_and_get('abseil-cpp')}",
    f"-DDAWN_EGL_REGISTRY_DIR={verify_and_get('egl-registry')}",
    f"-DDAWN_GLSLANG_DIR={verify_and_get('glslang')}",
    f"-DDAWN_JINJA2_DIR={verify_and_get('jinja2')}",
    f"-DDAWN_MARKUPSAFE_DIR={verify_and_get('markupsafe')}",
    f"-DDAWN_OPENGL_REGISTRY_DIR={verify_and_get('opengl-registry')}",
    f"-DDAWN_SPIRV_HEADERS_DIR={verify_and_get('spirv-headers')}",
    f"-DDAWN_SPIRV_TOOLS_DIR={verify_and_get('spirv-tools')}",
    f"-DDAWN_VULKAN_HEADERS_DIR={verify_and_get('vulkan-headers')}",
    f"-DDAWN_VULKAN_UTILITY_LIBRARIES_DIR={verify_and_get('vulkan-utility-libraries')}",
    f"-DDAWN_WEBGPU_HEADERS_DIR={verify_and_get('webgpu-headers')}",
    f"-DDAWN_SWIFTSHADER_DIR={verify_and_get('swiftshader')}",

    # Disable unnecessary deps
    "-DDAWN_BUILD_BENCHMARKS=OFF",
    "-DDAWN_BUILD_PROTOBUF=OFF",
    "-DDAWN_BUILD_SAMPLES=OFF",
    "-DDAWN_BUILD_TESTS=OFF",
    "-DDAWN_USE_GLFW=OFF",
    "-DTINT_BUILD_BENCHMARKS=OFF",
    "-DTINT_BUILD_IR_BINARY=OFF",
    "-DTINT_BUILD_TESTS=OFF",
    "-DDAWN_USE_X11=OFF",

    # Explicitly mark third_party deps as not here to make debugging easier
    "-DDAWN_EMDAWNWEBGPU_DIR=NOT_SYNCED_BY_SKIA",
    "-DDAWN_GLFW_DIR=NOT_SYNCED_BY_SKIA",
    "-DDAWN_LPM_DIR=NOT_SYNCED_BY_SKIA",
    "-DDAWN_PROTOBUF_DIR=NOT_SYNCED_BY_SKIA",
  ]
