#!/usr/bin/env python3
"""Read complete Xcode settings once and select the requested application's source plist."""
from __future__ import annotations

import json
import os
from pathlib import Path
import re
import subprocess
import sys

APP_TYPE = "com.apple.product-type.application"
_VARIABLE = re.compile(r"\$\(([^)]+)\)|\$\{([^}]+)\}")


class SettingsFailed(RuntimeError):
    def __init__(self, message: str, code: int = 1):
        super().__init__(message)
        self.code = code


def diagnostic(text: str) -> str:
    for key, value in os.environ.items():
        if len(value) >= 4 and re.search(r"SECRET|TOKEN|PASSWORD|PROXY|PRIVATE_KEY", key, re.I):
            text = text.replace(value, "[redacted]")
    text = re.sub(r"https?://[^\s]+", "[URL redacted]", text)
    text = re.sub(r"\x1b\[[0-?]*[ -/]*[@-~]", "", text)
    return "\n".join("xcodebuild: " + line for line in text[-8000:].splitlines()[-60:])


def read_settings(project: str, workspace: str, scheme: str, configuration: str, logs: Path) -> list:
    if not scheme or not (workspace or project):
        raise SettingsFailed("Project/workspace and scheme are required for source plist resolution")
    command = ["xcodebuild", "-workspace" if workspace else "-project", workspace or project,
               "-scheme", scheme, "-configuration", configuration or "Release", "-sdk", "iphoneos",
               "-destination", "generic/platform=iOS", "-disableAutomaticPackageResolution", "-skipPackageUpdates",
               "CODE_SIGNING_ALLOWED=NO", "CODE_SIGNING_REQUIRED=NO", "CODE_SIGN_IDENTITY=", "DEVELOPMENT_TEAM=",
               "-showBuildSettings", "-json"]
    logs.mkdir(parents=True, exist_ok=True, mode=0o700)
    os.chmod(logs, 0o700)
    output, errors = logs / "build-settings.json", logs / "build-settings.stderr"
    # File sinks consume the entire producer output. An early pipe reader can hide its actual status.
    with output.open("wb") as stdout, errors.open("wb") as stderr:
        os.chmod(output, 0o600)
        os.chmod(errors, 0o600)
        timed_out = False
        try:
            result = subprocess.run(command, stdout=stdout, stderr=stderr, timeout=300, check=False)
            code = result.returncode if result.returncode >= 0 else 128 - result.returncode
        except subprocess.TimeoutExpired:
            timed_out, code = True, 124
    if code:
        detail = diagnostic(errors.read_text(errors="replace"))
        if detail:
            print(detail, file=sys.stderr)
        reason = "timed out after 300 seconds" if timed_out else f"failed with exit {code}"
        raise SettingsFailed(f"xcodebuild -showBuildSettings {reason}", code)
    try:
        settings = json.loads(output.read_text())
    except (ValueError, UnicodeError):
        raise SettingsFailed("xcodebuild returned invalid build-settings JSON") from None
    if not isinstance(settings, list):
        raise SettingsFailed("xcodebuild build-settings response must be a target list")
    return settings


def application(settings: list, bundle_id: str) -> dict:
    candidates = []
    for row in settings:
        values = row.get("buildSettings") if isinstance(row, dict) else None
        if not isinstance(values, dict) or values.get("PRODUCT_TYPE") != APP_TYPE:
            continue
        if not bundle_id or values.get("PRODUCT_BUNDLE_IDENTIFIER") == bundle_id:
            candidates.append(values)
    if len(candidates) != 1:
        raise SettingsFailed("Expected exactly one application target matching the configured bundle identifier")
    return candidates[0]


def source_plist(settings: dict, checkout: Path) -> Path | None:
    value = str(settings.get("INFOPLIST_FILE") or "").strip()
    if not value:
        if settings.get("GENERATE_INFOPLIST_FILE") == "YES":
            return None
        raise SettingsFailed("The application has no source Info.plist and does not enable generated Info.plist")
    for _ in range(5):
        expanded = _VARIABLE.sub(lambda match: str(settings.get(match[1] or match[2], match[0])), value)
        if expanded == value:
            break
        value = expanded
    if _VARIABLE.search(value) or "\n" in value or "\r" in value:
        raise SettingsFailed("The application's Info.plist path contains an unresolved or invalid setting")
    path = Path(value)
    if not path.is_absolute():
        source = settings.get("SRCROOT") or settings.get("PROJECT_DIR")
        if not source:
            raise SettingsFailed("The application target did not provide its source root")
        path = Path(source) / path
    path = path.resolve()
    if "\n" in str(path) or "\r" in str(path):
        raise SettingsFailed("The resolved source plist cannot be represented in the job environment")
    if not path.is_relative_to(checkout.resolve()) or not path.is_file():
        raise SettingsFailed("The application source Info.plist is missing or outside the checkout")
    return path


def main() -> int:
    try:
        logs = Path(os.environ["RUNNER_TEMP"]) / "swift-app-build-settings"
        rows = read_settings(os.environ.get("PROJECT", ""), os.environ.get("WORKSPACE", ""),
                             os.environ.get("SCHEME", ""), os.environ.get("CONFIGURATION", "Release"), logs)
        selected = source_plist(application(rows, os.environ.get("BUNDLE_ID", "")), Path.cwd())
        value = str(selected) if selected else ""
        with Path(os.environ["GITHUB_ENV"]).open("a") as stream:
            stream.write(f"SWIFT_APP_SOURCE_INFOPLIST={value}\n")
        print(value)
        return 0
    except SettingsFailed as exc:
        print(f"::error title=Application build settings::{exc}", file=sys.stderr)
        return exc.code
    except (KeyError, OSError):
        print("::error title=Application build settings::Required settings tooling or private output is unavailable",
              file=sys.stderr)
        return 1


if __name__ == "__main__":
    raise SystemExit(main())
