#!/usr/bin/env python3

# Copyright 2018 The Chromium Authors
# Use of this source code is governed by a BSD-style license that can be
# found in the LICENSE file.

"""
If should_use_hermetic_xcode.py emits "1", and the current toolchain is out of
date:
  * Downloads the hermetic mac toolchain
    * Requires CIPD authentication. Run `cipd auth-login`, use Google account.
  * Accepts the license.
    * If xcode-select and xcodebuild are not passwordless in sudoers, requires
      user interaction.
  * Downloads standalone binaries from [a possibly different version of Xcode].

The toolchain version can be overridden by setting MAC_TOOLCHAIN_REVISION with
the full revision, e.g. 9A235.
"""

import argparse
import os
import platform
import plistlib
import shutil
import subprocess
import sys


def LoadPList(path):
    """Loads Plist at |path| and returns it as a dictionary."""
    with open(path, 'rb') as f:
        return plistlib.load(f)


# Universal binaries from Xcode 26.6 (17F113) along with the macOS 26.5 SDK
# (25F70, which is likely macOS 26.5 25F71) and the Metal toolchain (17F109).
# This universal toolchain is required on Intel x86 Macs, as Xcode 27+ is
# ARM-only.
# DO NOT UPDATE unless you know what you are doing.
UNIVERSAL_MAC_BINARIES_LABEL = (
    'infra_internal/ios/xcode/xcode_binaries/mac-amd64'
)
UNIVERSAL_MAC_BINARIES_TAG = 'c5txl4HTukDs4xiCybqz3zK9jxr4N4PwzHRNOhjBDOQC'

# This contains binaries for the latest Xcode.
# TODO: starting with Xcode 27, this would be ARM only. Change the below cipd
# package to mac-arm64 and update the instance id accordingly.
# To build these packages, see comments in build/xcode_binaries.yaml.
#
# To update the version numbers, open Xcode's "About Xcode" or run `xcodebuild
# -version` for the Xcode version, and run `xcrun --show-sdk-version` and `xcrun
# --show-sdk-build-version` for the SDK version. To update the _TAG, use the
# output of the `cipd create` command mentioned in xcode_binaries.yaml; it's the
# part after the colon. Or check the CIPD site
# (https://chrome-infra-packages.appspot.com/) at the path given in
# MAC_BINARIES_LABEL.
MAC_BINARIES_LABEL = 'infra_internal/ios/xcode/xcode_binaries/mac-amd64'
MAC_BINARIES_TAG = 'c5txl4HTukDs4xiCybqz3zK9jxr4N4PwzHRNOhjBDOQC'

# The toolchain will not be downloaded if the minimum OS version is not met. 19
# is the Darwin major version number for macOS 10.15. Xcode 26.4 17E192 only
# runs on macOS 26.2 and newer, but some bots are still running older OS
# versions. macOS 10.15.4, the OS minimum through Xcode 12.4, still seems to
# work.
MAC_MINIMUM_OS_VERSION = [19, 4]

BASE_DIR = os.path.abspath(os.path.dirname(__file__))
TOOLCHAIN_ROOT = os.path.join(BASE_DIR, 'mac_files')

# Always integrity-check the entire SDK. Mac SDK packages are complex and often
# hit edge cases in cipd (eg https://crbug.com/1033987,
# https://crbug.com/915278), and generally when this happens it requires manual
# intervention to fix.
# Note the trailing \n!
PARANOID_MODE = '$ParanoidMode CheckIntegrity\n'


def PlatformMeetsHermeticXcodeRequirements():
    if sys.platform != 'darwin':
        return True
    needed = MAC_MINIMUM_OS_VERSION
    major_version = [
        int(v) for v in platform.release().split('.')[: len(needed)]
    ]
    return major_version >= needed


def _UseHermeticToolchain():
    current_dir = os.path.dirname(os.path.realpath(__file__))
    script_path = os.path.join(current_dir, 'mac/should_use_hermetic_xcode.py')
    proc = subprocess.Popen([script_path, 'mac'], stdout=subprocess.PIPE)
    return '1' in proc.stdout.readline().decode()


def RequestCipdAuthentication():
    """Requests that the user authenticate to access Xcode CIPD packages."""

    print('Access to Xcode CIPD package requires authentication.')
    print('-----------------------------------------------------------------')
    print()
    print('You appear to be a Googler.')
    print()
    print('I\'m sorry for the hassle, but you may need to do a one-time manual')
    print('authentication. Please run:')
    print()
    print('    cipd auth-login')
    print()
    print('and follow the instructions.')
    print()
    print('NOTE: Use your google.com credentials, not chromium.org.')
    print()
    print('-----------------------------------------------------------------')
    print()
    sys.stdout.flush()


def PrintError(message):
    # Flush buffers to ensure correct output ordering.
    sys.stdout.flush()
    sys.stderr.write(message + '\n')
    sys.stderr.flush()


def ShouldUseUniversalXcode():
    """Returns True if the machine is running Intel x86 macOS.

    Xcode 27+ is ARM-only, so Intel x86 Macs must use universal Xcode 26.6.
    """
    return sys.platform == 'darwin' and platform.machine() == 'x86_64'


def InstallXcodeBinaries():
    """Installs the Xcode binaries needed to build Chrome and accepts the license.

    This is the replacement for InstallXcode that installs a trimmed down version
    of Xcode that is OS-version agnostic.
    """
    # First make sure the directory exists. It will serve as the cipd root. This
    # also ensures that there will be no conflicts of cipd root.
    binaries_root = os.path.join(TOOLCHAIN_ROOT, 'xcode_binaries')
    if not os.path.exists(binaries_root):
        os.makedirs(binaries_root)

    # 'cipd ensure' is idempotent.
    args = ['cipd', 'ensure', '-root', binaries_root, '-ensure-file', '-']

    if ShouldUseUniversalXcode():
        print('Intel x86 Mac detected. Using universal Xcode 26.6 toolchain.')
        sys.stdout.flush()
        label = UNIVERSAL_MAC_BINARIES_LABEL
        tag = UNIVERSAL_MAC_BINARIES_TAG
    else:
        label = MAC_BINARIES_LABEL
        tag = MAC_BINARIES_TAG

    p = subprocess.Popen(
        args,
        universal_newlines=True,
        stdin=subprocess.PIPE,
        stdout=subprocess.PIPE,
        stderr=subprocess.PIPE,
    )
    stdout, stderr = p.communicate(input=PARANOID_MODE + label + ' ' + tag)
    if p.returncode != 0:
        print(stdout)
        print(stderr)
        RequestCipdAuthentication()
        return 1

    if sys.platform != 'darwin':
        return 0

    # Accept the license for this version of Xcode if it's newer than the
    # currently accepted version.
    cipd_xcode_version_plist_path = os.path.join(
        binaries_root, 'Contents/version.plist'
    )
    cipd_xcode_version_plist = LoadPList(cipd_xcode_version_plist_path)
    cipd_xcode_version = cipd_xcode_version_plist['CFBundleShortVersionString']

    cipd_license_path = os.path.join(
        binaries_root, 'Contents/Resources/LicenseInfo.plist'
    )
    cipd_license_plist = LoadPList(cipd_license_path)
    cipd_license_version = cipd_license_plist['licenseID']

    should_overwrite_license = True
    current_license_path = '/Library/Preferences/com.apple.dt.Xcode.plist'
    if os.path.exists(current_license_path):
        current_license_plist = LoadPList(current_license_path)
        xcode_version = current_license_plist.get(
            'IDEXcodeVersionForAgreedToGMLicense'
        )
        if xcode_version is not None and xcode_version.split(
            '.'
        ) >= cipd_xcode_version.split('.'):
            should_overwrite_license = False

    if not should_overwrite_license:
        return 0

    # Use puppet's sudoers script to accept the license if its available.
    license_accept_script = '/usr/local/bin/xcode_accept_license.sh'
    if os.path.exists(license_accept_script):
        args = [
            'sudo',
            license_accept_script,
            cipd_xcode_version,
            cipd_license_version,
        ]
        subprocess.check_call(args)
        return 0

    # Otherwise manually accept the license. This will prompt for sudo.
    print('Accepting new Xcode license. Requires sudo.')
    sys.stdout.flush()
    args = [
        'sudo',
        'defaults',
        'write',
        current_license_path,
        'IDEXcodeVersionForAgreedToGMLicense',
        cipd_xcode_version,
    ]
    subprocess.check_call(args)
    args = [
        'sudo',
        'defaults',
        'write',
        current_license_path,
        'IDELastGMLicenseAgreedTo',
        cipd_license_version,
    ]
    subprocess.check_call(args)
    args = ['sudo', 'plutil', '-convert', 'xml1', current_license_path]
    subprocess.check_call(args)

    return 0


def main():
    if not _UseHermeticToolchain():
        print('Skipping Mac toolchain installation for mac')
        return 0

    parser = argparse.ArgumentParser(description='Download hermetic Xcode.')
    args = parser.parse_args()

    if not PlatformMeetsHermeticXcodeRequirements():
        print('OS version does not support toolchain.')
        return 0

    return InstallXcodeBinaries()


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