#!/usr/bin/env python3
"""Writes IptvAppSwift/Resources/Acknowledgements.json, the Acknowledgements page in Settings.

Every library the app ships, at the version Package.resolved pins, with its licence text copied
verbatim from the package's own files -- or, where a package's licence file only gives the
address its terms are published at, as the Cast SDK's does, that address for the page to link
to. VLCKit is the exception: Popvue ships its own build of it, the local package in
Vendor/MobileVLCKit, made by Scripts/vlckit/build-vlckit.sh, so its version is the tag that
script builds and its licence is VLCKit's COPYING from that tag, kept in Scripts/vlckit-licences.
Its entry also carries where its source and libVLC's can be had, as the LGPL asks: the VLCKit
tag, and the libVLC release tag its binary names. Run it again whenever a package is added or
updated, or VLCKit rebuilt:

    python3 Scripts/generate_acknowledgements.py <SourcePackages>

where <SourcePackages> is the folder Xcode resolved the packages into, holding `checkouts` and
`artifacts` (DerivedData/IptvAppSwift-*/SourcePackages). The script stops rather than write a
list with a licence missing.

The MobileVLCKit binary links in libVLC's contrib libraries too -- FFmpeg, dav1d, FreeType and
some forty more -- whose licences the package doesn't carry. Scripts/vlckit-licences holds them,
copied from the contrib tarballs of the VLCKit version its manifest names, and they follow
VLCKit's LGPL text: each library's own licence text, or, for one used under the LGPL, a line
naming it, the LGPL text above standing for it; one Popvue's build alters first says so, as its
licence asks. The manifest pins the VLCKit tag and the libVLC build the binary reports
("3.0.21-53": vlc's tag, then VLCKit's patches and Popvue's); when either changes -- VLCKit
updated, a patch added or dropped -- the script stops until the list and the texts are taken
again from the new build (contrib/src/<package>/rules.mak at the libVLC revision its build
script pins, and the libraries the binary really links).
"""

import json
import plistlib
import re
import sys
from pathlib import Path

REPO = Path(__file__).resolve().parent.parent
RESOLVED = REPO / "IptvAppSwift.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved"
OUTPUT = REPO / "IptvAppSwift/Resources/Acknowledgements.json"
VLCKIT_LICENCES = REPO / "Scripts/vlckit-licences"
VLCKIT_BUILD = REPO / "Scripts/vlckit/build-vlckit.sh"
VLCKIT_BINARY = REPO / "Vendor/MobileVLCKit/MobileVLCKit.xcframework/ios-arm64/MobileVLCKit.framework/MobileVLCKit"


def read(path: Path) -> str:
    """A licence file as written, only the blank lines around it trimmed."""
    if not path.is_file():
        sys.exit(f"missing licence file: {path}")
    return path.read_text(encoding="utf-8").strip("\n")


def licence_link(text: str):
    """The address a licence file gives in place of a licence: a single line, a web address."""
    stripped = text.strip()
    if "\n" not in stripped and re.fullmatch(r"https?://\S+", stripped):
        return stripped
    return None


def vlckit_version() -> str:
    """The VLCKit release Popvue's build is made from: the tag build-vlckit.sh checks out. The
    binary itself names only libVLC's version."""
    match = re.search(r'^VLCKIT_TAG="([^"]+)"$', VLCKIT_BUILD.read_text(encoding="utf-8"), re.MULTILINE)
    if not match:
        sys.exit(f"no VLCKIT_TAG in {VLCKIT_BUILD.relative_to(REPO)}")
    return match.group(1)


def libvlc_version() -> tuple:
    """libVLC's version as Popvue's MobileVLCKit names itself ("LibVLC/3.0.21"), and the build
    it reports ("3.0.21-53-ge2283f330b": vlc's 3.0.21 tag, then 53 patches, VLCKit's and
    Popvue's) less the commit, which changes with every build as the patches are applied
    afresh."""
    if not VLCKIT_BINARY.is_file():
        sys.exit(f"missing {VLCKIT_BINARY.relative_to(REPO)}: build it with Scripts/vlckit/build-vlckit.sh")
    binary = VLCKIT_BINARY.read_bytes()
    version = re.search(rb"LibVLC/(\d+)\.(\d+)\.(\d+)", binary)
    builds = {match.group(1) for match in re.finditer(rb"(\d+\.\d+\.\d+-\d+)-g[0-9a-f]{7,}", binary)}
    if not version or len(builds) != 1:
        sys.exit(f"no single libVLC version in {VLCKIT_BINARY.relative_to(REPO)}")
    parts = tuple(part.decode() for part in version.groups())
    build = builds.pop().decode()
    if not build.startswith(".".join(parts) + "-"):
        sys.exit(f"libVLC {'.'.join(parts)} reports the build {build}")
    return parts, build


def vlckit_inclusions(vlckit_version: str, libvlc: tuple, libvlc_build: str) -> list:
    """The licences of the libraries inside MobileVLCKit, in the manifest's order: a library's
    texts headed "FFmpeg 4.4.4", its further ones "FFmpeg 4.4.4: <label>". One used under the
    LGPL first has its licence's name, the LGPL text before them standing for it; one Popvue's
    build alters first has the note saying so and how, as zlib's and libpng's licences ask of an
    altered version, and its licence texts all take their labels."""
    manifest = json.loads(read(VLCKIT_LICENCES / "manifest.json"))
    pinned = (manifest["vlckit"], manifest["libvlc"], manifest["libvlcBuild"])
    if pinned != (vlckit_version, ".".join(libvlc), libvlc_build):
        sys.exit(f"{VLCKIT_LICENCES.relative_to(REPO)} is for VLCKit {manifest['vlckit']} "
                 f"(libVLC {manifest['libvlcBuild']}), Popvue's MobileVLCKit is VLCKit "
                 f"{vlckit_version} (libVLC {libvlc_build}): take its libraries' licences again")
    texts = []
    for library in manifest["libraries"]:
        heading = f"{library['name']} {library['version']}"
        own = library.get("texts", [])
        if not library.get("lgpl") and not own:
            sys.exit(f"no licence text for {heading}")
        # The page's own words about the library, under its bare heading: the licence it is
        # used under, and that Popvue's build alters it.
        lead = []
        if library.get("lgpl"):
            lead.append(library["licence"])
        if "altered" in library:
            lead.append(library["altered"])
        if lead:
            texts.append({"title": heading, "body": "\n\n".join(lead)})
        for index, text in enumerate(own):
            title = heading
            if lead or index > 0:
                if "label" not in text:
                    sys.exit(f"{heading}: {text['file']} has no label")
                title = f"{heading}: {text['label']}"
            texts.append({"title": title, "body": read(VLCKIT_LICENCES / text["file"])})
    return texts


def google_cast_notices(artifacts: Path) -> list:
    """The open-source notices the Cast SDK ships: one text holding them all, and an index
    giving each component's offset and length in it."""
    folder = artifacts / "google-cast-sdk/GoogleCast/OpenSourceLicenses"
    index = plistlib.loads((folder / "oss_licenses_index.plist").read_bytes())
    blob = (folder / "oss_licenses.bin").read_bytes()
    notices = []
    for entry in sorted(index, key=lambda e: e["name"].lower()):
        start, size = entry["offset"], entry["size"]
        if start + size > len(blob):
            sys.exit(f"Cast notice out of range: {entry['name']}")
        notices.append({
            "title": entry["name"],
            "body": blob[start:start + size].decode("utf-8").strip("\n"),
        })
    return notices


def main() -> None:
    if len(sys.argv) != 2:
        sys.exit(__doc__)
    packages = Path(sys.argv[1]).expanduser()
    checkouts, artifacts = packages / "checkouts", packages / "artifacts"
    pins = {pin["identity"]: pin for pin in json.loads(RESOLVED.read_text())["pins"]}

    def version(identity: str) -> str:
        return pins[identity]["state"]["version"]

    def website(identity: str) -> str:
        location = pins[identity]["location"]
        return location[:-4] if location.endswith(".git") else location

    core = checkouts / "realm-core"
    # The Cast SDK's licence file is only the address of Google's terms, which are a web page
    # rather than a text to copy: the page links to it instead.
    cast_licence = read(checkouts / "google-cast-sdk/LICENSE")
    cast_terms = licence_link(cast_licence)
    cast_texts = [] if cast_terms else [{"title": "Google Cast SDK", "body": cast_licence}]
    vlckit = vlckit_version()
    libvlc, libvlc_build = libvlc_version()
    libraries = [
        {
            "id": "alamofire",
            "name": "Alamofire",
            "version": version("alamofire"),
            "licence": "MIT License",
            "website": website("alamofire"),
            "texts": [{"title": "Alamofire", "body": read(checkouts / "Alamofire/LICENSE")}],
        },
        {
            "id": "google-cast-sdk",
            "name": "Google Cast SDK",
            "version": version("google-cast-sdk"),
            "licence": "Google APIs Terms of Service",
            "licenceURL": cast_terms,
            "website": "https://developers.google.com/cast",
            "texts": cast_texts + google_cast_notices(artifacts),
        },
        {
            "id": "keychainaccess",
            "name": "KeychainAccess",
            "version": version("keychainaccess"),
            "licence": "MIT License",
            "website": website("keychainaccess"),
            "texts": [{"title": "KeychainAccess", "body": read(checkouts / "KeychainAccess/LICENSE")}],
        },
        {
            "id": "kingfisher",
            "name": "Kingfisher",
            "version": version("kingfisher"),
            "licence": "MIT License",
            "website": website("kingfisher"),
            "texts": [{"title": "Kingfisher", "body": read(checkouts / "Kingfisher/LICENSE")}],
        },
        {
            "id": "realm-core",
            "name": "Realm Core",
            "version": version("realm-core"),
            "licence": "Apache License 2.0",
            "website": website("realm-core"),
            "texts": [
                {"title": "Realm Core", "body": read(core / "LICENSE")},
                {"title": "Third-party notices", "body": read(core / "THIRD-PARTY-NOTICES")},
                {"title": "IntelRDFPMathLib20U2", "body": read(core / "src/external/IntelRDFPMathLib20U2/eula.txt")},
                {"title": "JSON for Modern C++", "body": read(core / "src/external/json/LICENSE.MIT")},
                {"title": "jsonsl", "body": read(core / "src/external/jsonsl/LICENSE")},
                {"title": "MPark.Variant", "body": read(core / "src/external/mpark/LICENSE.md")},
                {"title": "S2 Geometry", "body": read(core / "src/external/s2/LICENSE-2.0.txt")},
            ],
        },
        {
            "id": "realm-swift",
            "name": "Realm Swift",
            "version": version("realm-swift"),
            "licence": "Apache License 2.0",
            "website": website("realm-swift"),
            "texts": [{"title": "Realm Swift", "body": read(checkouts / "realm-swift/LICENSE")}],
        },
        {
            "id": "vlckit",
            "name": "VLCKit",
            "version": vlckit,
            "licence": "GNU Lesser General Public License 2.1",
            "website": "https://code.videolan.org/videolan/VLCKit",
            # VLCKit's tag holds the build that names the exact libVLC revision and the patches
            # it applies; libVLC's release tag is that revision (VLCKit 3.6.0's TESTEDHASH,
            # dd8bfdba, is vlc's 3.0.21), where its branch would have moved on since. The
            # manifest's libvlcBuild pins it: a build on a later revision counts more commits.
            "sources": [
                {
                    "name": f"VLCKit {vlckit}",
                    "url": f"https://code.videolan.org/videolan/VLCKit/-/tree/{vlckit}",
                },
                {
                    "name": f"libVLC {'.'.join(libvlc)}",
                    "url": f"https://code.videolan.org/videolan/vlc/-/tree/{'.'.join(libvlc)}",
                },
            ],
            "texts": [{"title": "VLCKit", "body": read(VLCKIT_LICENCES / "vlckit.txt")}]
                     + vlckit_inclusions(vlckit, libvlc, libvlc_build),
        },
    ]
    # Keys with nothing to say are left out rather than written as null.
    libraries = [{key: value for key, value in library.items() if value is not None} for library in libraries]
    document = {"libraries": libraries}
    OUTPUT.parent.mkdir(parents=True, exist_ok=True)
    OUTPUT.write_text(json.dumps(document, indent=2, ensure_ascii=False) + "\n", encoding="utf-8")
    print(f"wrote {OUTPUT.relative_to(REPO)}: {len(libraries)} libraries, "
          f"{sum(len(l['texts']) for l in libraries)} texts")


if __name__ == "__main__":
    main()
