#!/usr/bin/env python3
"""
Discover ASC API key in creds/ + auto-detect Xcode project/scheme/bundle,
then emit derived CI environment variables to $GITHUB_ENV.

There is NO config file. This script reads only:
  - INPUT_* env vars (the composite action's `with:` overrides)
  - creds/AuthKey_<KEY_ID>_Issuer_<UUID>.p8 (the only required file)
  - the Xcode project (via auto_detect.py + xcodebuild)
  - the App Store Connect API (for team_id and app_store_apple_id)

Precedence for every CFG_* value:
    1. INPUT_* env var (composite action input)
    2. Auto-detected value (xcodebuild, ASC API, glob)
    3. Built-in default ("Release", "false", "en-US", whatsNew boilerplate)
    4. Empty string (downstream step skips or applies its own fallback)

Built-in defaults emitted when nothing else applies:
    CFG_CONFIGURATION=Release, CFG_USES_NON_EXEMPT=false,
    CFG_RUN_TESTS=false, CFG_LOCALE=en-US,
    CFG_PROFILE_NAME="<scheme> CI", CFG_WHATS_NEW=<boilerplate>.

The ASC .p8 key is located by globbing ``creds/AuthKey_*.p8`` with filename
regex AuthKey_<KEY_ID>[_Issuer_<ISSUER_UUID>].p8. If multiple match, we fail
with an instruction to remove the unwanted one(s) — there is no longer a
config-based disambiguation channel.

Writes to $GITHUB_ENV:
    ASC_KEY_ID, ASC_ISSUER_ID, ASC_KEY_P8_PATH,
    CFG_PROJECT, CFG_WORKSPACE, CFG_SCHEME, CFG_CONFIGURATION,
    CFG_BUNDLE_ID, CFG_TEAM_ID, CFG_APP_STORE_APPLE_ID, CFG_PROFILE_NAME,
    CFG_USES_NON_EXEMPT, CFG_WHATS_NEW, CFG_WHATS_NEW_FILE, CFG_LOCALE,
    CFG_RUN_TESTS, CFG_TEST_COMMAND, CFG_TEST_DESTINATION

CFG_WHATS_NEW_FILE points at a file under $RUNNER_TEMP holding the raw
multi-line release notes. Downstream steps MUST prefer the file over the
env-var copy: GitHub Actions YAML's `${{ }}` substitution serializes
multi-line strings in ways that can mangle embedded newlines, while a file
path is a single-line string that survives interpolation untouched.
"""

from __future__ import annotations

import os
from pathlib import Path

import auto_detect
from cfg_io import fail, log
from cfg_resolve import (
    derive_team_if_empty,
    emit,
    find_p8,
    lookup_app_id_via_api,
)


DEFAULT_WHATS_NEW = (
    "- Improved performance: faster, smoother app experience\n"
    "- Bug fixes: minor issues resolved for seamless use\n"
    "- Enhanced security: updated to protect your data\n"
)


def _pick(input_val: str, *, auto_val: str = "", default: str = "") -> tuple[str, str]:
    """Apply precedence: input > auto > default > empty."""
    if input_val:
        return input_val, "input"
    if auto_val:
        return auto_val, "auto-detect"
    if default:
        return default, "default"
    return "", "empty"


def emit_credentials(env_file: Path, workspace: Path) -> dict:
    """Discover .p8, derive ASC_KEY_ID / ISSUER / PATH, emit to env_file.

    SECURITY: the raw .p8 contents are never emitted to $GITHUB_ENV.
    Downstream scripts read the key from the on-disk path (ASC_KEY_PATH),
    which is a single-line string that cannot be dumped by GH Actions'
    automatic env-var display when a step group expands. As belt-and-braces
    defense, every non-empty line of the key body is registered with
    ``::add-mask::`` so if it ever surfaces in a log it is redacted to ``***``.

    Returns {'key_id', 'issuer_id', 'key_path'} for downstream use.
    """
    p8_path, key_id_from_file, issuer_from_file = find_p8(workspace)
    try:
        p8_contents = p8_path.read_text()
    except OSError as exc:
        fail(f"cannot read {p8_path}: {exc}")

    if not issuer_from_file:
        fail(
            "ASC issuer id unknown: rename the .p8 to "
            "AuthKey_<KEY_ID>_Issuer_<UUID>.p8 so the issuer id is in the filename."
        )

    # Register every non-empty line of the key with the GH Actions log masker
    # BEFORE emitting anything else. ::add-mask:: is line-oriented: GitHub
    # scans subsequent log output for exact substring matches and rewrites
    # them to ***. Masking line-by-line catches accidental echoes (e.g.
    # `set -x`, `env`, `cat key.p8`) regardless of newline handling.
    for line in p8_contents.splitlines():
        stripped = line.strip()
        if stripped:
            # Emit to stdout — GH Actions reads the workflow command from
            # any step's stdout, not just dedicated steps.
            print(f"::add-mask::{stripped}")

    emit(env_file, "ASC_KEY_ID", str(key_id_from_file))
    emit(env_file, "ASC_ISSUER_ID", str(issuer_from_file))
    emit(env_file, "ASC_KEY_P8_PATH", str(p8_path))
    # NB: ASC_KEY_P8 (raw key contents) is deliberately NOT emitted.
    # Downstream steps read the key from ASC_KEY_P8_PATH / ASC_KEY_PATH.
    log(f"ASC_KEY_ID={key_id_from_file} (source: filename)")
    log(f"ASC_ISSUER_ID={issuer_from_file} (source: filename)")
    log(f"ASC_KEY_P8_PATH={p8_path}")
    return {
        "key_id": str(key_id_from_file),
        "issuer_id": str(issuer_from_file),
        "key_path": str(p8_path),
    }


def _detect_project_workspace(
    workspace: Path,
    project: tuple[str, str],
    ws_val: tuple[str, str],
) -> tuple[tuple[str, str], tuple[str, str]]:
    """Run auto_detect.auto_detect_project when both are unset."""
    if project[0] or ws_val[0]:
        return project, ws_val
    auto_proj, auto_ws = auto_detect.auto_detect_project(workspace)
    if auto_ws:
        return project, (auto_ws, "auto-detect")
    if auto_proj:
        return (auto_proj, "auto-detect"), ws_val
    return project, ws_val


def resolve_xcode(workspace: Path, inp: dict) -> dict:
    """Resolve project / workspace / scheme / configuration / profile_name.

    Inputs win; otherwise we shell out to xcodebuild via auto_detect.
    """
    project = _pick(inp["PROJECT"])
    ws_val = _pick(inp["WORKSPACE"])
    project, ws_val = _detect_project_workspace(workspace, project, ws_val)

    configuration = _pick(inp["CONFIGURATION"], default="Release")

    scheme = _pick(inp["SCHEME"])
    if not scheme[0] and (project[0] or ws_val[0]):
        detected = auto_detect.auto_detect_scheme(workspace, project[0], ws_val[0])
        if detected:
            scheme = (detected, "auto-detect")

    default_profile = f"{scheme[0]} CI" if scheme[0] else ""
    profile_name = _pick(inp["PROFILE_NAME"], default=default_profile)
    return {
        "project": project,
        "workspace": ws_val,
        "scheme": scheme,
        "configuration": configuration,
        "profile_name": profile_name,
    }


def _detect_bundle(
    bundle: tuple[str, str], workspace: Path, xcode: dict,
) -> tuple[str, str]:
    """Auto-detect PRODUCT_BUNDLE_IDENTIFIER when bundle is unresolved."""
    if bundle[0] or not xcode.get("scheme"):
        return bundle
    detected = auto_detect.auto_detect_bundle_id(
        workspace,
        xcode.get("project", ""),
        xcode.get("workspace", ""),
        xcode["scheme"],
        xcode.get("configuration", "Release"),
    )
    if detected:
        return detected, "auto-detect"
    return bundle


def _resolve_apple_id(
    inp: dict, bundle: tuple[str, str], creds: dict, scripts_dir: Path,
) -> tuple[str, str]:
    """Resolve App Store numeric app id: input -> ASC API lookup -> empty."""
    if inp["APP_STORE_APPLE_ID"]:
        return inp["APP_STORE_APPLE_ID"], "input"
    if not bundle[0]:
        return "", "empty"
    os.environ["ASC_KEY_ID"] = creds["key_id"]
    os.environ["ASC_ISSUER_ID"] = creds["issuer_id"]
    os.environ["ASC_KEY_PATH"] = creds["key_path"]
    return lookup_app_id_via_api(bundle[0], scripts_dir), "api-lookup"


def resolve_app(
    inp: dict,
    creds: dict,
    scripts_dir: Path,
    *,
    workspace: Path,
    xcode: dict,
) -> dict:
    """Resolve bundle_id / team_id / app_store_apple_id."""
    bundle = _pick(inp["BUNDLE_ID"])
    bundle = _detect_bundle(bundle, workspace, xcode)

    team = _pick(inp["TEAM_ID"])
    if not team[0]:
        derived_val, derived_src = derive_team_if_empty(team[0], team[1], creds)
        if derived_val:
            log(f"derived team_id={derived_val} from ASC key {creds['key_id']}")
            team = (derived_val, derived_src)

    apple = _resolve_apple_id(inp, bundle, creds, scripts_dir)
    return {"bundle_id": bundle, "team_id": team, "app_store_apple_id": apple}


def resolve_testflight(inp: dict) -> dict:
    """Resolve whats_new / locale from inputs, with built-in defaults."""
    return {
        "whats_new": _pick(inp["WHATS_NEW"], default=DEFAULT_WHATS_NEW),
        "locale": _pick(inp["LOCALE"], default="en-US"),
    }


def resolve_tests(inp: dict) -> dict:
    """Resolve run_tests / test_command / test_destination from inputs."""
    return {
        "run_tests": _pick(inp["RUN_TESTS"], default="false"),
        "test_command": _pick(inp["TEST_COMMAND"]),
        "test_destination": _pick(inp["TEST_DESTINATION"]),
    }


def resolve_ios(inp: dict) -> dict:
    """Resolve ios.uses_non_exempt_encryption from inputs."""
    return {"uses_non_exempt": _pick(inp["USES_NON_EXEMPT"], default="false")}


def collect_inputs() -> dict:
    """Read all INPUT_* env vars the composite action passes in."""
    names = (
        "PROJECT", "WORKSPACE", "SCHEME", "CONFIGURATION", "PROFILE_NAME",
        "BUNDLE_ID", "TEAM_ID", "APP_STORE_APPLE_ID",
        "USES_NON_EXEMPT", "WHATS_NEW", "LOCALE",
        "RUN_TESTS", "TEST_COMMAND", "TEST_DESTINATION",
    )
    return {name: os.environ.get(f"INPUT_{name}", "") for name in names}


def main() -> None:
    workspace = Path(os.environ.get("GITHUB_WORKSPACE", "."))
    scripts_dir = Path(__file__).resolve().parent
    env_file_path = os.environ.get("GITHUB_ENV")
    if not env_file_path:
        fail("GITHUB_ENV not set; this script must run inside a GitHub Actions step")
    env_file = Path(env_file_path)

    inputs = collect_inputs()
    creds = emit_credentials(env_file, workspace)
    xc = resolve_xcode(workspace, inputs)
    xc_values = {k: v[0] for k, v in xc.items()}
    app = resolve_app(
        inputs, creds, scripts_dir,
        workspace=workspace, xcode=xc_values,
    )
    tf = resolve_testflight(inputs)
    ios = resolve_ios(inputs)
    tests = resolve_tests(inputs)

    derived = [
        ("CFG_PROJECT", xc["project"]),
        ("CFG_WORKSPACE", xc["workspace"]),
        ("CFG_SCHEME", xc["scheme"]),
        ("CFG_CONFIGURATION", xc["configuration"]),
        ("CFG_BUNDLE_ID", app["bundle_id"]),
        ("CFG_TEAM_ID", app["team_id"]),
        ("CFG_APP_STORE_APPLE_ID", app["app_store_apple_id"]),
        ("CFG_PROFILE_NAME", xc["profile_name"]),
        ("CFG_USES_NON_EXEMPT", ios["uses_non_exempt"]),
        ("CFG_WHATS_NEW", tf["whats_new"]),
        ("CFG_LOCALE", tf["locale"]),
        ("CFG_RUN_TESTS", tests["run_tests"]),
        ("CFG_TEST_COMMAND", tests["test_command"]),
        ("CFG_TEST_DESTINATION", tests["test_destination"]),
    ]
    for name, (value, src) in derived:
        emit(env_file, name, value)
        shown = value if name != "CFG_WHATS_NEW" else value.replace("\n", " / ")
        log(f"{name}={shown!r} (source: {src})")

    # Persist whatsNew to a file under $RUNNER_TEMP so downstream steps can
    # read raw multi-line content without any GitHub Actions ${{ }} YAML
    # interpolation mangling embedded newlines.
    whats_new_value = tf["whats_new"][0]
    runner_temp = os.environ.get("RUNNER_TEMP") or str(workspace)
    whats_new_path = Path(runner_temp) / "whats_new.txt"
    whats_new_path.write_text(whats_new_value)
    emit(env_file, "CFG_WHATS_NEW_FILE", str(whats_new_path))
    log(
        f"CFG_WHATS_NEW_FILE={whats_new_path} "
        f"(length={len(whats_new_value)}, "
        f"newlines={whats_new_value.count(chr(10))})"
    )


if __name__ == "__main__":
    main()
