#!/usr/bin/env ruby
# frozen_string_literal: true

# Verifies the source-rebuilt Mpv XCFramework that replaces only the Mpv
# binary inside media_kit_libs_* during Zyryn App Store delivery. The other
# media-kit frameworks and every non-App-Store build remain untouched.

require 'digest'
require 'fileutils'
require 'json'
require 'open3'
require 'rubygems/version'
require 'set'
require 'tmpdir'

UPSTREAM_BUILD_COMMIT = '4286f5557bdccc0747030e3c376ce5cd160a96a0'
MPV_SOURCE_SHA256 = '29abc44f8ebee013bb2f9fe14d80b30db19b534c679056e4851ceadf5a5e8bf6'
PRIVACY_PATCH_SHA256 = '84ec1534dd2813d916f4f47e9774255b8b6814b740258d7e7759811eb63c2565'
RECIPE_PATCH_SHA256 = 'd772bfe86a168b68895323b56d9e817acd67fcb3f6e6f94408d5b3a27a486438'
EXPORT_DIGEST = '6d632684656f4edde052ec3eac979d3c87fc50e8aee47e98a692b713c4435b3e'
LICENSE_SHA256 = 'dc626520dcd53a22f727af3ee42c770e56c97a64fe3adb063799d8ab032fe551'
COPYRIGHT_SHA256 = '160139ae889eacb76789123463ae04a7c851cd62692ab2b5851bc02eb0efd5fc'

POLICIES = {
  'ios' => {
    platform: 'ios',
    deployment_floor: Gem::Version.new('15.0'),
    variants: {
      nil => ['arm64'],
      'simulator' => %w[arm64 x86_64]
    },
    install_name: '@rpath/Mpv.framework/Mpv',
    dependencies: %w[
      @rpath/Ass.framework/Ass
      @rpath/Avcodec.framework/Avcodec
      @rpath/Avfilter.framework/Avfilter
      @rpath/Avformat.framework/Avformat
      @rpath/Avutil.framework/Avutil
      @rpath/Swresample.framework/Swresample
      @rpath/Swscale.framework/Swscale
      @rpath/Uchardet.framework/Uchardet
    ]
  },
  'macos' => {
    platform: 'macos',
    deployment_floor: Gem::Version.new('13.5'),
    variants: { nil => %w[arm64 x86_64] },
    install_name: '@rpath/Mpv.framework/Versions/A/Mpv',
    dependencies: %w[
      @rpath/Ass.framework/Versions/A/Ass
      @rpath/Avcodec.framework/Versions/A/Avcodec
      @rpath/Avfilter.framework/Versions/A/Avfilter
      @rpath/Avformat.framework/Versions/A/Avformat
      @rpath/Avutil.framework/Versions/A/Avutil
      @rpath/Swresample.framework/Versions/A/Swresample
      @rpath/Swscale.framework/Versions/A/Swscale
      @rpath/Uchardet.framework/Versions/A/Uchardet
    ]
  }
}.freeze

def abort_verification(message)
  warn "[appstore] custom libmpv verification failed: #{message}"
  exit 1
end

def capture(*command)
  stdout, stderr, status = Open3.capture3(*command)
  return stdout if status.success?

  abort_verification("command failed: #{command.join(' ')}\n#{stderr.strip}")
end

def exported_symbols_digest(binary)
  symbols = capture('/usr/bin/nm', '-gjU', binary)
            .lines.map(&:strip).reject(&:empty?).uniq.sort
  Digest::SHA256.hexdigest(symbols.map { |symbol| "#{symbol}\n" }.join)
end

def each_architecture(binary, architectures)
  if architectures.length == 1
    yield architectures.fetch(0), binary
    return
  end

  Dir.mktmpdir('zyryn-libmpv-thin') do |directory|
    architectures.each do |architecture|
      thin = File.join(directory, architecture)
      capture('/usr/bin/lipo', binary, '-thin', architecture, '-output', thin)
      yield architecture, thin
    end
  end
end

def verify_minos(binary, floor)
  load_commands = capture('/usr/bin/otool', '-arch', 'all', '-l', binary)
  current_command = nil
  versions = load_commands.lines.each_with_object([]) do |line, found|
    if (match = line.match(/^\s+cmd\s+(LC_(?:BUILD_VERSION|VERSION_MIN_\S+))$/))
      current_command = match[1]
      next
    end
    if current_command == 'LC_BUILD_VERSION'
      match = line.match(/^\s+minos\s+([0-9]+(?:\.[0-9]+){1,2})$/)
      found << match[1] if match
    elsif current_command&.start_with?('LC_VERSION_MIN_')
      match = line.match(/^\s+version\s+([0-9]+(?:\.[0-9]+){1,2})$/)
      found << match[1] if match
    end
    current_command = nil if line.start_with?('Load command ')
  end
  abort_verification("no Mach-O minimum OS command in #{binary}") if versions.empty?

  versions.each do |version|
    next if Gem::Version.new(version) <= floor

    abort_verification("#{binary} requires OS #{version}, above app floor #{floor}")
  end
end

def verify_signability(framework)
  Dir.mktmpdir('zyryn-libmpv-sign') do |directory|
    copy = File.join(directory, 'Mpv.framework')
    capture('/usr/bin/ditto', framework, copy)
    capture('/usr/bin/codesign', '--force', '--sign', '-', '--timestamp=none', copy)
    capture('/usr/bin/codesign', '--verify', '--strict', copy)
  end
end

usage = 'usage: verify_custom_libmpv.rb <ios|macos> <package-root>'
platform = ARGV.fetch(0) { abort_verification(usage) }
package_root = File.expand_path(ARGV.fetch(1) { abort_verification(usage) })
policy = POLICIES.fetch(platform) { abort_verification("unsupported platform: #{platform}") }
xcframework = File.join(package_root, 'Mpv.xcframework')
info_plist = File.join(xcframework, 'Info.plist')
license_path = File.join(package_root, 'COPYING.LGPL-2.1.txt')
copyright_path = File.join(package_root, 'Copyright.txt')
provenance_path = File.join(package_root, 'provenance.json')

abort_verification("XCFramework not found: #{xcframework}") unless File.directory?(xcframework)
abort_verification("provenance not found: #{provenance_path}") unless File.file?(provenance_path)

{
  license_path => LICENSE_SHA256,
  copyright_path => COPYRIGHT_SHA256
}.each do |path, expected|
  abort_verification("required notice not found: #{path}") unless File.file?(path)

  actual = Digest::SHA256.file(path).hexdigest
  abort_verification("notice digest #{actual}, expected #{expected}: #{path}") unless actual == expected
end

provenance = JSON.parse(File.read(provenance_path))
expected_provenance = {
  'upstream_build_commit' => UPSTREAM_BUILD_COMMIT,
  'mpv_version' => '0.36.0',
  'mpv_source_sha256' => MPV_SOURCE_SHA256,
  'privacy_patch' => 'scripts/appstore/patches/libmpv-0.36.0-no-fstatfs.patch',
  'privacy_patch_sha256' => PRIVACY_PATCH_SHA256,
  'recipe_patch' => 'scripts/appstore/patches/libmpv-darwin-v0.6.0-xcode26.patch',
  'recipe_patch_sha256' => RECIPE_PATCH_SHA256,
  'meson_version' => '1.2.1',
  'meson_wheel_sha256' => '08f83fc17513e99cd6e82c7554c1f58af70425211887f8f9c7363b2a90209462',
  'platform' => platform
}
expected_provenance.each do |key, expected|
  actual = provenance[key]
  next if actual == expected

  abort_verification("provenance #{key}=#{actual.inspect}, expected #{expected.inspect}")
end

libraries = JSON.parse(capture('/usr/bin/plutil', '-convert', 'json', '-o', '-', info_plist))
                .fetch('AvailableLibraries')
actual_variants = libraries.to_h do |library|
  [library['SupportedPlatformVariant'], Array(library.fetch('SupportedArchitectures')).sort]
end
expected_variants = policy.fetch(:variants).transform_values(&:sort)
unless actual_variants == expected_variants
  abort_verification("unexpected XCFramework slices: #{actual_variants}, expected #{expected_variants}")
end

libraries.each do |library|
  supported_platform = library.fetch('SupportedPlatform')
  expected_platform = policy.fetch(:platform)
  unless supported_platform == expected_platform
    abort_verification("unexpected supported platform: #{supported_platform}")
  end

  framework = File.join(xcframework, library.fetch('LibraryIdentifier'), library.fetch('LibraryPath'))
  binary = File.join(framework, 'Mpv')
  abort_verification("framework binary not found: #{binary}") unless File.file?(binary)

  architectures = capture('/usr/bin/lipo', '-archs', binary).split.sort
  expected_architectures = Array(library.fetch('SupportedArchitectures')).sort
  unless architectures == expected_architectures
    abort_verification("#{binary} architectures #{architectures}, expected #{expected_architectures}")
  end

  install_names = capture('/usr/bin/otool', '-D', binary).lines.map(&:strip)
  expected_install_name = policy.fetch(:install_name)
  unless install_names.include?(expected_install_name)
    abort_verification("#{binary} install name is not #{expected_install_name}")
  end

  linked_paths = capture('/usr/bin/otool', '-L', binary).lines.each_with_object([]) do |line, paths|
    match = line.match(/^\s+(@rpath\/\S+)/)
    paths << match[1] if match
  end.to_set
  expected_links = (policy.fetch(:dependencies) + [expected_install_name]).to_set
  unless linked_paths == expected_links
    abort_verification("#{binary} @rpath dependencies #{linked_paths.to_a.sort}, expected #{expected_links.to_a.sort}")
  end

  undefined = capture('/usr/bin/nm', '-u', binary)
  forbidden = undefined.scan(/_(?:f?statfs|f?statvfs)\b/).to_set
  abort_verification("#{binary} imports #{forbidden.to_a.sort.join(', ')}") unless forbidden.empty?

  each_architecture(binary, architectures) do |architecture, thin|
    digest = exported_symbols_digest(thin)
    next if digest == EXPORT_DIGEST

    abort_verification("#{binary} #{architecture} exported ABI digest #{digest}, expected #{EXPORT_DIGEST}")
  end

  verify_minos(binary, policy.fetch(:deployment_floor))
  stale_signatures = Dir.glob(File.join(framework, '**', '_CodeSignature'))
  abort_verification("stale code signature in #{framework}") unless stale_signatures.empty?
  _stdout, _stderr, signature_status = Open3.capture3('/usr/bin/codesign', '-d', binary)
  abort_verification("cache binary must be unsigned before archive signing: #{binary}") if signature_status.success?
  verify_signability(framework)
end

puts '[appstore] verified custom libmpv source identity, LGPL notices, slices, ' \
     'deployment targets, exported ABI, linked frameworks, signability, and privacy API removal'
