#!/usr/bin/env python3
# ==========================================================================
#              ____        _ _     _     _____           _
#             | __ ) _   _(_) | __| |   |_   _|__   ___ | |___
#             |  _ \| | | | | |/ _` |_____| |/ _ \ / _ \| / __|
#             | |_) | |_| | | | (_| |_____| | (_) | (_) | \__ \
#             |____/ \__,_|_|_|\__,_|     |_|\___/ \___/|_|___/
#
#                           --- Build-Tools ---
#                https://www.nntb.no/~dreibh/system-tools/
# ==========================================================================
#
# Unified Build Tool
# Copyright (C) 2021-2026 by Thomas Dreibholz
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program.  If not, see <http://www.gnu.org/licenses/>.
#
# Contact: thomas.dreibholz@gmail.com

import glob
import http.client
import os
import pprint
import random
import re
import shutil
import subprocess
import sys
import tempfile
import time
import urllib.error
import urllib.request

if sys.version_info < (3, 9):
   sys.stderr.write('ERROR: ' + sys.argv[0] + ' requires Python 3.9 or later!\n')
   sys.exit(1)

from typing import Any, Final, TextIO, cast

try:
   import distro
except ImportError:
   sys.stderr.write('ERROR: ' + sys.argv[0] + ' requires the Python distro package!\n')
   sys.exit(1)


# ###########################################################################
# #### Constants                                                         ####
# ###########################################################################

TarballFormats : Final[list[str]]     = [ 'xz', 'bz2', 'gz' ]
TarOptions     : Final[dict[str,str]] = {
   'xz':  'J',
   'bz2': 'j',
   'gz':  'z'
}

Systems : Final[list[list[str]]] = [
   # Prefix       System Name         Configuration File
   [ 'cmake',    'CMake',             'cmake_lists_name'      ],
   [ 'autoconf', 'AutoConf/AutoMake', 'autoconf_config_name'  ],
   [ 'other',    'Version File',      'other_file_name'       ],
   [ 'rpm',      'RPM Spec',          'rpm_spec_name'         ],
   [ 'debian',   'Debian Changelog',  'debian_changelog_name' ],
   [ 'port',     'Port Makefile',     'port_makefile_name'    ]
]

DebianCodenames        : list[str] | None = None   # Will be initialised later!
UbuntuCodenames        : list[str] | None = None   # Will be initialised later!

DebhelperLatestVersion : Final[int]           = 13
DebhelperCompatFixes   : Final[dict[str,int]] = {
   'precise': 9,
   'trusty':  9,
   'xenial':  9,
   'bionic': 11,
   'focal':  12
}


# ###########################################################################
# #### Helper Functions                                                  ####
# ###########################################################################


# ###### Print section header ###############################################
def printSection(title : str) -> None:
   now : Final[str] = time.strftime('%Y-%m-%d %H:%M:%S', time.localtime())
   sys.stdout.write('\n\x1b[34m' + (now + ' ====== ' + title + ' ').ljust(132, '=') + '\x1b[0m\n\n')


# ###### Print subsection header ############################################
def printSubsection(title : str) -> None:
   now : Final[str] = time.strftime('%Y-%m-%d %H:%M:%S', time.localtime())
   sys.stdout.write('\n\x1b[34m' + (now + ' ------ ' + title + ' ').ljust(132, '-') + '\x1b[0m\n')


# ###### Show difference between two files ##################################
def showDiff(a : str, b : str) -> None:
   try:
      subprocess.run( [ 'diff', '--color=always', a, b ] )
   except Exception as e:
      sys.stderr.write('ERROR: Diff run failed: ' + str(e) + '\n')
      sys.exit(1)


# ###### Read file and return list of lines #################################
def readTextFile(inputName : str) -> list[str]:

   inputFile : TextIO    = open(inputName, 'r', encoding='utf-8')
   contents  : list[str] = inputFile.readlines()
   inputFile.close()

   return contents


# ###### Write file by list of lines ########################################
def writeTextFile(outputName : str, contents : list[str]) -> None:
   outputFile = open(outputName, 'w', encoding='utf-8')
   for line in contents:
      outputFile.write(line)


# ###### Get system architecture ############################################
def getArchitecture() -> str:
   return os.uname().machine



# ###########################################################################
# #### Packaging                                                         ####
# ###########################################################################


# ###### Obtain distribution codenames ######################################
def obtainDistributionCodenames() -> None:
   global DebianCodenames
   global UbuntuCodenames
   DebianCodenames = getDistributionCodenames()
   UbuntuCodenames = getDistributionCodenames('ubuntu')


# ###### Read packaging configuration from packaging.conf ###################
def readPackagingConfiguration() -> dict[str,Any]:

   # ====== Initialise ======================================================
   packageInfo : dict[str,Any] = { }
   packageInfo['packaging_maintainer']     = None
   packageInfo['packaging_maintainer_key'] = None
   packageInfo['packaging_make_dist']      = None
   packageInfo['packaging_config_name']    = 'packaging.conf'

   # ====== Obtain package configuration ====================================
   re_package_maintainer     : Final[re.Pattern[str]] = re.compile(r'^(MAINTAINER=\")(.*)(\".*$)')
   re_package_maintainer_key : Final[re.Pattern[str]] = re.compile(r'^(MAINTAINER_KEY=\")(.*)(\".*$)')
   re_package_makedist       : Final[re.Pattern[str]] = re.compile(r'^(MAKE_DIST=\")(.*)(\".*$)')
   try:
      packagingConfFile         : TextIO    = open(packageInfo['packaging_config_name'], 'r', encoding='utf-8')
      packagingConfFileContents : list[str] = packagingConfFile.readlines()
      for line in packagingConfFileContents:
         match : re.Match[str] | None = re_package_maintainer.match(line)
         if match is not None:
            packageInfo['packaging_maintainer'] = match.group(2)
         else:
            match = re_package_maintainer_key.match(line)
            if match is not None:
               packageInfo['packaging_maintainer_key'] = match.group(2)
            else:
               match = re_package_makedist.match(line)
               if match is not None:
                  packageInfo['packaging_make_dist'] = match.group(2)
      packagingConfFile.close()
   except Exception as e:
      sys.stderr.write('ERROR: Unable to read ' + packageInfo['packaging_config_name'] + ': ' + str(e) + '\n')
      sys.exit(1)

   if packageInfo['packaging_maintainer'] is None:
      sys.stderr.write('ERROR: Unable to find MAINTAINER in ' + packageInfo['packaging_config_name'] + '!\n')
      sys.exit(1)
   elif packageInfo['packaging_make_dist'] is None:
      sys.stderr.write('ERROR: Unable to find MAKE_DIST in ' + packageInfo['packaging_config_name'] + '!\n')
      sys.exit(1)

   return packageInfo


# ###### Read CMake packaging information ###################################
def readCMakePackagingInformation() -> dict[str,Any]:

   # ====== Initialise ======================================================
   packageInfo : dict[str,Any] = { }

   # ====== Obtain package configuration ====================================
   cmakeListsFile = 'CMakeLists.txt'
   if os.path.isfile(cmakeListsFile):
      re_cmake_project   : Final[re.Pattern[str]] = re.compile(r'[ \t]*[Pp][Rr][Oo][Jj][Ee][Cc][Tt][ \t]*\(([a-zA-Z0-9-+]+)')
      re_cmakefile_major : Final[re.Pattern[str]] = re.compile(r'^[Ss][Ee][Tt]\((BUILD_MAJOR|PROJECT_MAJOR_VERSION)[ \t]*("|)(\d+)("|)[ \t]*\)')
      re_cmakefile_minor : Final[re.Pattern[str]] = re.compile(r'^[Ss][Ee][Tt]\((BUILD_MINOR|PROJECT_MINOR_VERSION)[ \t]*("|)(\d+)("|)[ \t]*\)')
      re_cmakefile_patch : Final[re.Pattern[str]] = re.compile(r'^[Ss][Ee][Tt]\((BUILD_PATCH|PROJECT_PATCH_VERSION)[ \t]*("|)(\d+)(~[a-zA-Z0-9\.+~]+|)("|)[ \t]*\)')

      packageInfo['cmake_lists_name'] = cmakeListsFile
      try:
         cmakeFile         : TextIO           = open(packageInfo['cmake_lists_name'], 'r', encoding='utf-8')
         cmakeFileContents : Final[list[str]] = cmakeFile.readlines()
         for line in cmakeFileContents:
            match = re_cmakefile_major.match(line)
            if match is not None:
               packageInfo['cmake_version_major'] = int(match.group(3))
            else:
               match = re_cmakefile_minor.match(line)
               if match is not None:
                  packageInfo['cmake_version_minor'] = int(match.group(3))
               else:
                  match = re_cmakefile_patch.match(line)
                  if match is not None:
                     packageInfo['cmake_version_patch'] = int(match.group(3))
                     packageInfo['cmake_version_extra'] = match.group(4)
                  else:
                     match = re_cmake_project.match(line)
                     if match is not None:
                        packageInfo['cmake_package_name'] = match.group(1)
         cmakeFile.close()

         if ('cmake_version_major' in packageInfo) and \
            ('cmake_version_minor' in packageInfo) and \
            ('cmake_version_patch' in packageInfo) and \
            ('cmake_version_extra' in packageInfo):
            packageInfo['cmake_version_string'] = \
               str(packageInfo['cmake_version_major']) + '.' + \
                  str(packageInfo['cmake_version_minor']) + '.' + \
                  str(packageInfo['cmake_version_patch']) + packageInfo['cmake_version_extra']

      except Exception as e:
         sys.stderr.write('ERROR: Unable to read ' + packageInfo['cmake_lists_name'] + ': ' + str(e) + '\n')
         sys.exit(1)

      # ====== Check whether information is complete ========================
      if ( ( not 'cmake_package_name' in packageInfo) or
           ( not 'cmake_version_string' in packageInfo ) ):
         sys.stderr.write('ERROR: Cannot find required package versioning details in ' + packageInfo['cmake_lists_name'] + '!\n')
         print(packageInfo)
         sys.exit(1)

   return packageInfo


# ###### Read AutoConf packaging information ################################
def readAutoConfPackagingInformation() -> dict[str,Any]:

   # ====== Initialise ======================================================
   packageInfo : dict[str,Any] = { }

   # ====== Obtain package configuration ====================================
   for autoconfConfigName in [ 'configure.ac', 'configure.in' ]:
      if os.path.isfile(autoconfConfigName):
         break
   if os.path.isfile(autoconfConfigName):
      re_autoconffile_version : Final[re.Pattern[str]] = \
         re.compile(r'^AC_INIT\([ \t]*\[(.*)\][ \t]*,[ \t]*\[(\d).(\d).(\d+)([~+][a-zA-Z0-9\.+]+|)\][ \t]*,[ \t]*\[(.*)\][ \t]*\)')

      packageInfo['autoconf_config_name'] = autoconfConfigName
      try:
         autoconfFile         : TextIO           = open(packageInfo['autoconf_config_name'], 'r', encoding='utf-8')
         autoconfFileContents : Final[list[str]] = autoconfFile.readlines()
         for line in autoconfFileContents:
            match = re_autoconffile_version.match(line)
            if match is not None:
               packageInfo['autoconf_package_name']  = match.group(1)
               packageInfo['autoconf_version_major'] = int(match.group(2))
               packageInfo['autoconf_version_minor'] = int(match.group(3))
               packageInfo['autoconf_version_patch'] = int(match.group(4))
               packageInfo['autoconf_version_extra'] = match.group(5)
               packageInfo['autoconf_version_string'] = \
                  str(packageInfo['autoconf_version_major']) + '.' + \
                  str(packageInfo['autoconf_version_minor']) + '.' + \
                  str(packageInfo['autoconf_version_patch']) + packageInfo['autoconf_version_extra']
               break
         autoconfFile.close()

      except Exception as e:
         sys.stderr.write('ERROR: Unable to read ' + packageInfo['autoconf_config_name'] + ': ' + str(e) + '\n')
         sys.exit(1)

      # ====== Check whether information is complete ========================
      if ( ( not 'autoconf_package_name'   in packageInfo) or
           ( not 'autoconf_version_string' in packageInfo ) ):
         sys.stderr.write('ERROR: Cannot find required package versioning details in ' + packageInfo['autoconf_config_name'] + '!\n')
         sys.exit(1)

   return packageInfo


# ###### Read Debian packaging information ##################################
def readDebianPackagingInformation() -> dict[str,Any]:

   # ====== Initialise ======================================================
   packageInfo         : dict[str,Any] = { }
   debianChangelogName : Final[str]    = 'debian/changelog'
   debianControlName   : Final[str]    = 'debian/control'
   debianRulesName     : Final[str]    = 'debian/rules'

   # ====== Process changelog file ==========================================
   if ( (os.path.isfile(debianChangelogName)) and
        (os.path.isfile(debianControlName)) and
        (os.path.isfile(debianRulesName)) ):
      re_debian_version : Final[re.Pattern[str]] = re.compile(r'^([a-zA-Z0-9-+]+)[ \t]*\((\d+:|)(\d+)\.(\d+)\.(\d+)([a-zA-Z0-9+~\.]*)(-|)(\d[a-zA-Z0-9-+~]*|)\)[ \t]*([a-zA-Z-+]+)[ \t]*;(.*)$')
      re_debian_itp1    : Final[re.Pattern[str]] = re.compile(r'^ * .*ITP.*Closes: #([0-9]+).*$')
      re_debian_itp2    : Final[re.Pattern[str]] = re.compile(r'^ * .*Closes: #([0-9]+).*ITP.*$')

      packageInfo['debian_changelog_name']    = debianChangelogName
      packageInfo['debian_control_name']      = debianControlName
      packageInfo['debian_rules_name']        = debianRulesName
      packageInfo['debian_package_name']      = None
      packageInfo['debian_version_string']    = None
      packageInfo['debian_standards_version'] = None
      packageInfo['debian_codename']          = None
      packageInfo['debian_itp']               = None
      packageInfo['debian_status']            = None

      try:
         debianChangeLogFile         : TextIO           = open(debianChangelogName, 'r', encoding='utf-8')
         debianChangeLogFileContents : Final[list[str]] = debianChangeLogFile.readlines()
         n : int = 0
         for line in debianChangeLogFileContents:
            n = n + 1
            if n == 1:
               match = re_debian_version.match(line)
               if match is not None:
                  packageInfo['debian_package_name']      = match.group(1)
                  packageInfo['debian_version_prefix']    = match.group(2)
                  packageInfo['debian_version_major']     = int(match.group(3))
                  packageInfo['debian_version_minor']     = int(match.group(4))
                  packageInfo['debian_version_patch']     = int(match.group(5))
                  packageInfo['debian_version_extra']     = match.group(6)
                  packageInfo['debian_version_packaging'] = match.group(8)
                  packageInfo['debian_codename']          = match.group(9)
                  packageInfo['debian_version_string']    = \
                     str(packageInfo['debian_version_major']) + '.' + \
                     str(packageInfo['debian_version_minor']) + '.' + \
                     str(packageInfo['debian_version_patch']) + packageInfo['debian_version_extra']
                  packageInfo['debian_urgency'] = 'low'

                  options : list[str] = match.group(9).split(';')
                  for option in options:
                     optionSplit : list[str] = option.strip().split('=')
                     if len(optionSplit) == 2:
                        if optionSplit[0].strip() == 'urgency':
                           packageInfo['debian_urgency'] = optionSplit[1].strip()

            elif n > 1:
               match = re_debian_itp1.match(line)
               if match is None:
                  match = re_debian_itp2.match(line)
               if match is not None:
                  # print('ITP: ' + line)
                  packageInfo['debian_itp'] = int(match.group(1))
                  break
         debianChangeLogFile.close()

      except Exception as e:
         sys.stderr.write('ERROR: Unable to read ' + debianChangelogName + ': ' + str(e) + '\n')
         sys.exit(1)

      if packageInfo['debian_package_name'] is None:
         sys.stderr.write('ERROR: Cannot find required package versioning details in ' + debianChangelogName + '!\n')
         sys.exit(1)

      # ====== Check whether information is complete ========================
      if not 'debian_package_name' in packageInfo:
         sys.stderr.write('ERROR: Cannot find required package versioning details in ' + debianChangelogName + '!\n')
         sys.exit(1)

      # ====== Process control file =========================================
      re_debian_standards_version : Final[re.Pattern[str]] = re.compile(r'^Standards-Version:[ \t]*([0-9\.]*)[ \t]*$')
      try:
         debianControlFile         : TextIO           = open(debianControlName, 'r', encoding='utf-8')
         debianControlFileContents : Final[list[str]] = debianControlFile.readlines()
         for line in debianControlFileContents:
            match = re_debian_standards_version.match(line)
            if match is not None:
               packageInfo['debian_standards_version'] = match.group(1)
         debianControlFile.close()
      except Exception as e:
         sys.stderr.write('ERROR: Unable to read ' + debianControlName + ': ' + str(e) + '\n')
         sys.exit(1)

   return packageInfo


# ###### Read RPM packaging information #####################################
def readRPMPackagingInformation() -> dict[str,Any]:

   # ====== Initialise ======================================================
   packageInfo  : dict[str,Any]    = { }
   rpmSpecNames : Final[list[str]] = glob.glob('rpm/*.spec')

   # ====== Obtain package configuration ====================================
   if len(rpmSpecNames) == 1:
      packageInfo['rpm_spec_name'] = rpmSpecNames[0]
      packageInfo['rpm_packages']  = [ ]

      re_rpm_name    : Final[re.Pattern[str]] = re.compile(r'^(Name:[ \t]*)(\S+)')
      re_rpm_version : Final[re.Pattern[str]] = re.compile(r'^(Version:[ \t]*)(\d+)\.(\d+)\.(\d+)(.*|)')
      re_rpm_release : Final[re.Pattern[str]] = re.compile(r'^(Release:[ \t]*)(\d+)')
      re_rpm_package : Final[re.Pattern[str]] = re.compile(r'^(%package[ \t]+)([a-zA-Z0-9+-]+)')
      try:
         rpmSpecFile         : TextIO           = open(packageInfo['rpm_spec_name'], 'r', encoding='utf-8')
         rpmSpecFileContents : Final[list[str]] = rpmSpecFile.readlines()
         packageInfo['rpm_version_packaging'] = None
         for line in rpmSpecFileContents:
            match : re.Match[str] | None = re_rpm_version.match(line)
            if match is not None:
               packageInfo['rpm_version_major']  = int(match.group(2))
               packageInfo['rpm_version_minor']  = int(match.group(3))
               packageInfo['rpm_version_patch']  = int(match.group(4))
               packageInfo['rpm_version_extra']  = match.group(5)
               packageInfo['rpm_version_string'] = \
                  str(packageInfo['rpm_version_major']) + '.' + \
                  str(packageInfo['rpm_version_minor']) + '.' + \
                  str(packageInfo['rpm_version_patch']) + packageInfo['rpm_version_extra']
            else:
               match = re_rpm_release.match(line)
               if match is not None:
                  packageInfo['rpm_version_packaging'] = int(match.group(2))
               else:
                  match = re_rpm_name.match(line)
                  if match is not None:
                     packageInfo['rpm_package_name'] = match.group(2)
                  else:
                     match = re_rpm_package.match(line)
                     if match is not None:
                        packageInfo['rpm_packages'].append(
                           packageInfo['rpm_package_name'] + '-' + \
                           match.group(2) + '-' + \
                           packageInfo['rpm_version_string'] + '-' + \
                           str(packageInfo['rpm_version_packaging']))

         rpmSpecFile.close()

      except Exception as e:
         sys.stderr.write('ERROR: Unable to read ' + packageInfo['rpm_spec_name'] + ': ' + str(e) + '\n')
         sys.exit(1)

      # ====== Check whether information is complete ========================
      if ( (not 'rpm_package_name'      in packageInfo) or
           (not 'rpm_version_packaging' in packageInfo) or
           (not 'rpm_version_string'    in packageInfo) ):
         sys.stderr.write('ERROR: Cannot find required package versioning details in ' + packageInfo['rpm_spec_name'] + '!\n')
         sys.exit(1)
      packageInfo['rpm_packages'].append(
         packageInfo['rpm_package_name'] + '-' +
         packageInfo['rpm_version_string'] + '-' +
         str(packageInfo['rpm_version_packaging']))
      # print(packageInfo['rpm_packages'])

   elif len(rpmSpecNames) > 1:
      sys.stderr.write('ERROR: More than one spec file found: ' + str(rpmSpecNames) + '!\n')
      sys.exit(1)

   return packageInfo


# ###### Read FreeBSD ports packaging information ###########################
def readFreeBSDPackagingInformation() -> dict[str,Any]:

   # ====== Initialise ======================================================
   packageInfo         : dict[str,Any]    = { }
   port_makefile_names : Final[list[str]] = glob.glob('freebsd/*/Makefile')

   # ====== Obtain package configuration ====================================
   if len(port_makefile_names) > 0:
      packageInfo['port_makefile_name'] = port_makefile_names[0]
      re_freebsd_version : Final[re.Pattern[str]] = re.compile(r'^(DISTVERSION=[ \t]*)(\d+)\.(\d+)\.(\d+)(.*|)')
      try:
         freeBSDMakefileFile         : TextIO           = open(packageInfo['port_makefile_name'], 'r', encoding='utf-8')
         freeBSDMakefileFileContents : Final[list[str]] = freeBSDMakefileFile.readlines()
         for line in freeBSDMakefileFileContents:
            match : re.Match[str] | None = re_freebsd_version.match(line)
            if match is not None:
               packageInfo['port_version_major']  = int(match.group(2))
               packageInfo['port_version_minor']  = int(match.group(3))
               packageInfo['port_version_patch']  = int(match.group(4))
               packageInfo['port_version_extra']  = match.group(5)
               packageInfo['port_version_string'] = \
                  str(packageInfo['port_version_major']) + '.' + \
                  str(packageInfo['port_version_minor']) + '.' + \
                  str(packageInfo['port_version_patch']) + packageInfo['port_version_extra']
         freeBSDMakefileFile.close()

      except Exception as e:
         sys.stderr.write('ERROR: Unable to read ' + packageInfo['port_makefile_name'] + ': ' + str(e) + '\n')
         sys.exit(1)

      # ====== Check whether information is complete ========================
      if not 'port_version_string' in packageInfo:
         sys.stderr.write('ERROR: Cannot find required package versioning details in ' + packageInfo['port_makefile_name'] + '!\n')
         sys.exit(1)

   return packageInfo


# ###### Read other packaging information ###################################
def readOtherPackagingInformation() -> dict[str,Any]:

   # ====== Initialise ======================================================
   packageInfo : dict[str,Any] = { }

   # ====== Obtain package configuration ====================================
   for otherFileName in [ 'version' ]:
      if os.path.isfile(otherFileName):
         break

   if os.path.isfile(otherFileName):
      packageInfo['other_file_name'] = otherFileName
      re_otherfile_version : Final[re.Pattern[str]] = \
         re.compile(r'(\S+) (\d).(\d).(\d+)([~+][a-zA-Z0-9\.+]+|)')
      try:
         otherFile         : TextIO               = open(otherFileName, 'r', encoding='utf-8')
         otherFileContents : Final[list[str]]     = otherFile.readlines()
         line              : str                  = otherFileContents[0]
         match             : re.Match[str] | None = re_otherfile_version.match(line)
         if match is not None:
            packageInfo['other_package_name']  = match.group(1)
            packageInfo['other_version_major'] = int(match.group(2))
            packageInfo['other_version_minor'] = int(match.group(3))
            packageInfo['other_version_patch'] = int(match.group(4))
            packageInfo['other_version_extra'] = match.group(5)
            packageInfo['other_version_string'] = \
               str(packageInfo['other_version_major']) + '.' + \
               str(packageInfo['other_version_minor']) + '.' + \
               str(packageInfo['other_version_patch']) + packageInfo['other_version_extra']
         otherFile.close()

      except Exception as e:
         sys.stderr.write('ERROR: Unable to read ' + otherFileName + ': ' + str(e) + '\n')
         sys.exit(1)

      # ====== Check whether information is complete ========================
      if not 'other_package_name' in packageInfo:
         sys.stderr.write('ERROR: Cannot find required package versioning details in ' + otherFileName + '!\n')
         sys.exit(1)

   return packageInfo


# ###### Read packaging information #########################################
def readPackagingInformation() -> dict[str,Any]:

   # ====== Read information from different packaging system files ==========
   packageInfo : dict[str,Any] = readPackagingConfiguration()

   cmakePackageInfo : Final[dict[str,Any]] = readCMakePackagingInformation()
   if cmakePackageInfo is not None:
      packageInfo.update(cmakePackageInfo)

   autoconfPackageInfo : Final[dict[str,Any]] = readAutoConfPackagingInformation()
   if autoconfPackageInfo is not None:
      packageInfo.update(autoconfPackageInfo)

   debianPackageInfo : Final[dict[str,Any]] = readDebianPackagingInformation()
   if debianPackageInfo is not None:
      packageInfo.update(debianPackageInfo)

   rpmPackageInfo : Final[dict[str,Any]] = readRPMPackagingInformation()
   if rpmPackageInfo is not None:
      packageInfo.update(rpmPackageInfo)

   freeBSDPackageInfo : Final[dict[str,Any]] = readFreeBSDPackagingInformation()
   if freeBSDPackageInfo is not None:
      packageInfo.update(freeBSDPackageInfo)

   otherPackageInfo : Final[dict[str,Any]] = readOtherPackagingInformation()
   if otherPackageInfo is not None:
      packageInfo.update(otherPackageInfo)


   # ====== Obtain master packaging information =============================
   for system in Systems:
      systemPrefix : str = system[0]
      if hasPackagingFor(packageInfo, systemPrefix):
         systemName       : str = system[1]
         systemConfigFile : str = system[2]
         sys.stdout.write('Using master versioning from ' + systemName + '.\n')
         for entry in [ 'package_name', 'version_string',  'version_major',  'version_minor',  'version_patch',  'version_extra' ]:
            assert systemPrefix + '_' + entry in packageInfo
            packageInfo['master_' + entry] = packageInfo[systemPrefix + '_' + entry]
         break
   if not hasPackagingFor(packageInfo, 'master'):
      sys.stderr.write('ERROR: Unable to obtain master packaging information!\n')
      sys.exit(1)


   # ====== Check master packaging information ==============================
   for system in Systems:
      systemPrefix = system[0]
      if hasPackagingFor(packageInfo, systemPrefix):
         systemName       = system[1]
         systemConfigFile = system[2]
         sys.stdout.write(('Version from ' + systemName + ': ').ljust(32, ' ') + \
                          packageInfo[systemPrefix + '_version_string'] + \
                          ' (from ' + packageInfo[systemConfigFile] + ')\n')
         if packageInfo[systemPrefix + '_version_string'] != packageInfo['master_version_string']:
            sys.stderr.write('ERROR: ' + systemName + ' version ' + \
                             packageInfo[systemPrefix + '_version_string'] + ' does not match master version ' + \
                             packageInfo['master_version_string'] + '!\n')
            sys.exit(1)


   # ====== Look for source tarball =========================================
   sourcePackageInfo : dict[str,Any] | None = findSourceTarball(packageInfo)
   if sourcePackageInfo is not None:
      packageInfo.update(sourcePackageInfo)

   return packageInfo


# ###### Find source tarball ################################################
def findSourceTarball(packageInfo : dict[str,Any],
                      quiet       : bool = False) -> dict[str,Any] | None:

   # ====== Initialise ======================================================
   sourceInfo : dict[str,Any] = { }

   # ====== Obtain package configuration ====================================
   tarballPattern : Final[str] = \
      packageInfo['master_package_name'] + '-' + \
      packageInfo['master_version_string'] + '.tar.*'
   if not quiet:
      sys.stdout.write('Looking for tarball ' + tarballPattern + ' ... ')
   tarballs : Final[list[str]] = \
      glob.glob(tarballPattern)   # NOTE: This will also find .tar.xz.asc, etc.!
   for tarball in tarballs:
      extension = os.path.splitext(tarball)[1][1:]
      if extension in TarballFormats:
         sourceInfo['source_tarball_name']   = tarball
         sourceInfo['source_tarball_format'] = extension

         # ====== Check for signature file ==================================
         signature = sourceInfo['source_tarball_name'] + '.asc'
         if os.path.isfile(signature):
            sourceInfo['source_tarball_signature'] = signature

         if not quiet:
            sys.stdout.write('Found ' + sourceInfo['source_tarball_name'] + \
                           ' (format is ' + sourceInfo['source_tarball_format'] + ', signature in ')
            if 'source_tarball_signature' in sourceInfo:
               sys.stdout.write(sourceInfo['source_tarball_signature'] + ').\n')
            else:
               sys.stdout.write('MISSING!).\n')

         return sourceInfo

   if not quiet:
      sys.stdout.write('not found!\n')

   return None


# ###### Check whether specific packaging information is available ##########
def hasPackagingFor(packageInfo : dict[str,Any],
                    variant     : str) -> bool:
   if variant + '_version_string' in packageInfo:
      return True
   return False



# ###########################################################################
# #### Tools                                                             ####
# ###########################################################################


# ###### Show package information ###########################################
def showInformation(packageInfo : dict[str,Any]) -> None:
   pprint.pprint(packageInfo, indent=1)


# ###### Make source tarball ################################################
def makeSourceTarball(packageInfo        : dict[str,Any],
                      skipPackageSigning : bool,
                      summaryFile        : TextIO | None) -> bool:

   printSection('Creating source tarball')

   # ====== Make source tarball =============================================
   if 'source_tarball_name' in packageInfo:
      sourcePackageInfo = findSourceTarball(packageInfo, quiet = True)
      if sourcePackageInfo is None:
         sys.stderr.write('ERROR: Unable to find source tarball!\n')
         return False
      assert sourcePackageInfo is not None
      sys.stdout.write('Tarball is already there: ' + sourcePackageInfo['source_tarball_name'] + \
                       ' (format is ' + sourcePackageInfo['source_tarball_format'] + ')\n')
   else:
      print(packageInfo['packaging_make_dist'])
      result = os.system(packageInfo['packaging_make_dist'])
      if result != 0:
         sys.stderr.write('ERROR: Unable to create source tarball!\n')
         return False
      sourcePackageInfo = findSourceTarball(packageInfo, quiet=(not skipPackageSigning))
      if sourcePackageInfo is None:
         sys.stderr.write('ERROR: Unable to find source tarball!\n')
         return False
      assert sourcePackageInfo is not None
      # The source tarball is new, i.e. an existing signature is obsolete and
      # must be deleted.
      try:
         os.unlink(sourcePackageInfo['source_tarball_name'] + '.asc')
      except FileNotFoundError:
         pass

   if summaryFile is not None:
      summaryFile.write('sourceTarballFile: ' + os.path.abspath(sourcePackageInfo['source_tarball_name']) + '\n')

   # ====== Sign tarball ====================================================
   if skipPackageSigning == False:
      if 'source_tarball_signature' in sourcePackageInfo:
         sys.stdout.write('Signature is already there: ' + sourcePackageInfo['source_tarball_signature'] + '\n')

      else:
         result = os.system('gpg --sign --armor --detach-sign ' + \
                           sourcePackageInfo['source_tarball_name'])
         if result != 0:
            sys.stderr.write('ERROR: Unable to sign source tarball!\n')
            return False
         sourcePackageInfo = findSourceTarball(packageInfo)
         if ( (sourcePackageInfo is None) or
            (not 'source_tarball_name' in sourcePackageInfo) ):
            sys.stderr.write('ERROR: Unable to find signature of source tarball!\n')
         assert sourcePackageInfo is not None

      result = os.system('gpg --verify ' + \
                            sourcePackageInfo['source_tarball_signature'] + ' ' + \
                            sourcePackageInfo['source_tarball_name'])
      if result == 0:
         sys.stderr.write('Signature verified.\n')
      else:
         sys.stderr.write('ERROR: Bad signature! Something is wrong!\n')
         return False

      if summaryFile is not None:
         summaryFile.write('sourceTarballSignatureFile: ' + os.path.abspath(sourcePackageInfo['source_tarball_signature']) + '\n')

   packageInfo.update(sourcePackageInfo)
   return True


# ###### Get modified debian packaging version for given codename ###########
def modifyDebianVersionPackaging(packageInfo : dict[str,Any],
                                 codename    : str) -> str:
   assert DebianCodenames is not None

   # ------- Debian ------------------------------------------------------
   if codename in DebianCodenames:
      # Update codename and package versioning:
      # Drop Ubuntu packaging version:
      if codename == 'unstable':
         newSuffix : str = ''
      else:
         newSuffix = '~' + codename + '1'
      newDebianVersionPackaging : str = re.sub(r'(ubuntu|ppa|)[0-9]+$', newSuffix,
                                           packageInfo['debian_version_packaging'])

   # ------- Ubuntu ------------------------------------------------------
   else:
      # Update codename and package versioning:
      # Drop Ubuntu packaging version:
      newDebianVersionPackaging = re.sub(r'[0-9]+$', '~' + codename + '1',
                                         packageInfo['debian_version_packaging'])

   return newDebianVersionPackaging


# ###### Get Debian/Ubuntu codenames ########################################
def getDistributionCodenames(distribution : str = 'debian') -> list[str]:

   if distribution == 'debian':
      distroInfo : str = 'debian-distro-info'
   else:
      distroInfo = 'ubuntu-distro-info'
   try:
      process : subprocess.Popen[str] = \
         subprocess.Popen([ distroInfo, '--all'],
                          stdout=subprocess.PIPE, universal_newlines=True)
      assert process is not None
      assert process.stdout is not None
      result : Final[list[str]] = process.stdout.readlines()
   except Exception as e:
      sys.stderr.write('ERROR: Unable to obtain Debian codenames: ' + str(e) + '\n')
      sys.exit(1)

   codenames : list[str] = [ codename.strip() for codename in result ]
   if distribution == 'debian':
      codenames += [ 'unstable', 'testing', 'stable', 'oldstable' ]
      codenames += [ codename.strip() + '-backports'        for codename in result]
      codenames += [ codename.strip() + '-backports-sloppy' for codename in result]
   codenames = sorted(codenames)

   # pprint.pprint(codenames, indent=1)
   return codenames


# ###### Get Debian default architecture ####################################
def getDebianDefaultArchitecture() -> str:
   try:
      process : subprocess.Popen[str] = \
         subprocess.Popen([ 'dpkg', '--print-architecture'],
                          stdout=subprocess.PIPE, universal_newlines=True)
      assert process is not None
      assert process.stdout is not None
      result : Final[str] = process.stdout.readlines()[0].strip()
   except Exception as e:
      sys.stderr.write('ERROR: Unable to obtain Debian default architecture: ' + str(e) + '\n')
      sys.exit(1)
   return result


# ###### Get name of Debian DSC file name ###################################
def getDebianDscName(packageInfo            : dict[str,Any],
                     debianVersionPackaging : str | None = None) -> str:

   if debianVersionPackaging is None:
      debianVersionPackaging = packageInfo['debian_version_packaging']
   assert debianVersionPackaging is not None
   dscFileName : Final[str] = \
      str(packageInfo['debian_package_name']) + '_' + \
      str(packageInfo['debian_version_string']) + '-' + \
      debianVersionPackaging + '.dsc'
   return dscFileName


# ###### Get name of Debian source buildinfo name ###########################
def getDebianBuildinfoName(packageInfo            : dict[str,Any],
                           debianVersionPackaging : str | None = None,
                           architecture           : str = 'source') -> str:

   if debianVersionPackaging is None:
      debianVersionPackaging = packageInfo['debian_version_packaging']
   assert debianVersionPackaging is not None
   sourceBuildInfoFileName : Final[str] = \
      str(packageInfo['debian_package_name']) + '_' + \
      str(packageInfo['debian_version_string']) + '-' + \
      debianVersionPackaging + '_' +  \
      architecture + '.buildinfo'
   return sourceBuildInfoFileName


# ###### Get name of Debian source buildinfo name ###########################
def getDebianChangesName(packageInfo            : dict[str,Any],
                         debianVersionPackaging : str | None = None,
                         architecture           : str = 'source') -> str:

   if debianVersionPackaging is None:
      debianVersionPackaging = packageInfo['debian_version_packaging']
   assert debianVersionPackaging is not None
   changesFileName : Final[str] = \
      str(packageInfo['debian_package_name']) + '_' + \
      str(packageInfo['debian_version_string']) + '-' + \
      debianVersionPackaging + '_' + \
      architecture + '.changes'
   return changesFileName


# ###### Get name of Debian control tarball name ############################
def getDebianControlTarballName(packageInfo            : dict[str,Any],
                                debianVersionPackaging : str | None = None) -> str:

   if debianVersionPackaging is None:
      debianVersionPackaging = packageInfo['debian_version_packaging']
   assert debianVersionPackaging is not None
   controlTarballFileName  : Final[str] =\
      str(packageInfo['debian_package_name']) + '_' + \
      str(packageInfo['debian_version_string']) + '-' + \
      debianVersionPackaging + '.debian.tar.' + \
      'xz'   # FIXME: Check packageInfo['source_tarball_format'] with ".tar.gz" package!
   return controlTarballFileName


# ###### Fetch Debian changelog file ########################################
def fetchDebianChangelogAndControl(packageInfo : dict[str,Any],
                                   codename    : str = 'unstable') -> tuple[list[str] | None, list[str] | None]:
   if not hasPackagingFor(packageInfo, 'debian'):
      sys.stderr.write('ERROR: Cannot find required Debian packaging information!\n')
      sys.exit(1)

   assert DebianCodenames is not None
   if codename in DebianCodenames:
      # Debian
      statusPageURL : str = 'https://packages.debian.org/source/' + codename + '/' + packageInfo['debian_package_name']
   else:
      # Ubuntu
      statusPageURL = 'https://packages.ubuntu.com/source/' + codename + '/' + packageInfo['debian_package_name']

   debianNoSuchPackage : bool       = False
   debianVersion       : str | None = None
   debianLocation      : str | None = None
   debianArchive       : str | None = None
   debianArchiveFormat : str | None = None

   # ====== Get package status =================================================
   # NOTE: https://packages.debian.org is well-known for being quite unreliable:
   # HTTP 503 "No healthy backends"; see also
   # https://www.reddit.com/r/debian/comments/1e0foqv/is_packagesdebianorg_inaccessible/
   # => Work-around: Retry with random waiting time:
   maxTrials      : Final[int] = 50
   avgWaitingTime : Final[int] = 15

   re_no_such_package : Final[re.Pattern[str]] = \
      re.compile(r'^.*<p>No such package.</p>.*$')
   re_debian_package : Final[re.Pattern[str]] = \
      re.compile(r'^.*Source Package: ' + packageInfo['debian_package_name'] + r' \(([0-9-+~\.a-z]+)\)')
   re_debian_archive : Final[re.Pattern[str]] = \
      re.compile(r'^.*href="((http|https)://[a-zA-Z0-9\./+-]+/' + \
                  packageInfo['debian_package_name'][0:1] + '/' + \
                  packageInfo['debian_package_name'] + '/' + \
                  r')(' + packageInfo['debian_package_name'] + r'_[0-9-+~\.a-z]+\.debian\.tar\.[a-zA-Z]+)"')
   httpHeders : Final[dict[str,str]] = {
                                          'User-Agent': 'Build-Tool/0.2.0',
                                          'Accept':     '*/*'
                                       }

   for trial in range(0, maxTrials):
      if trial > 0:
         sys.stderr.write('\n')
      sys.stderr.write('Looking for package status on ' + statusPageURL +
                     ' (trial ' + str(trial + 1) + '/' + str(maxTrials) + ') ... ')
      sys.stderr.flush()

      try:
         statusPageRequest : urllib.request.Request   = urllib.request.Request(statusPageURL,
                                                                               headers = httpHeders)
         statusPage        : http.client.HTTPResponse = urllib.request.urlopen(statusPageRequest)

         for statusPageLine in statusPage:
            line  = statusPageLine.decode('utf-8')

            match = re_no_such_package.match(line)
            if match is not None:
               debianNoSuchPackage = True
            else:
               match = re_debian_package.match(line)
               if match is not None:
                  debianVersion = match.group(1)
               else:
                  match = re_debian_archive.match(line)
                  if match is not None:
                     debianLocation = match.group(1)
                     debianArchive  = match.group(3)

         statusPage.close()

         # A status page has been downloaded successfully -> Done!
         # NOTE: The status page may say "No such package", if there is no package.
         #       There is *no* HTTP 404 in this case!
         break

      except urllib.error.HTTPError as e:
         sys.stderr.write('not found (HTTP ' + str(e.code) + ')')
         if trial + 1 < maxTrials:
            time.sleep(random.uniform(0, 2 * avgWaitingTime))

   if debianNoSuchPackage == True:
      sys.stderr.write('not in Debian!\n')
      return (None, None)

   if ( (debianVersion is None) or (debianLocation is None) or (debianArchive is None) ):
      sys.stderr.write('ERROR: Unable to determinate package status in Debian (https://packages.ubuntu.com/ may be malfunctioning)!\n')
      sys.exit(1)

   sys.stderr.write('Version in ' + codename + ' is ' + debianVersion + '\n')


   # ====== Determine necessary compression option =============================
   debianArchiveFormat  = debianArchive[len(debianArchive) - 2 : len(debianArchive)]
   tarCompressionOption = TarOptions[debianArchiveFormat]


   # ====== Fetch debian archive ===============================================
   archiveFileURL  : Final[str]                               = debianLocation + debianArchive
   result          : tuple[list[str] | None,list[str] | None] = (None, None)
   sys.stderr.write('Looking for \"debian\" archive at ' + archiveFileURL + ' ... ')
   sys.stderr.flush()
   try:
      archiveFileRequest : urllib.request.Request   = urllib.request.Request(archiveFileURL,
                                                                             headers = httpHeders)
      archiveFile        : http.client.HTTPResponse = urllib.request.urlopen(archiveFileRequest)
      debianArchiveFile  : tempfile._TemporaryFileWrapper[bytes] = \
         tempfile.NamedTemporaryFile(delete = False)

      shutil.copyfileobj(cast(TextIO, archiveFile), debianArchiveFile)
      debianArchiveFile.close()
      archiveFile.close()
      sys.stderr.write('found!\n')

      debianChangelog : list[str] = [ ]
      debianControl   : list[str] = [ ]
      try:
         # ------ Extract debian/changelog ----------------------------------
         process : subprocess.Popen[str] = \
            subprocess.Popen([ 'tar', 'x' + tarCompressionOption + 'fO', debianArchiveFile.name, 'debian/changelog'],
                             stdout=subprocess.PIPE, universal_newlines=True)
         if ( (process is not None) and (process.stdout is not None) ):
            debianChangelog = process.stdout.readlines()

         # ------ Extract debian/control ------------------------------------
         process = \
            subprocess.Popen([ 'tar', 'x' + tarCompressionOption + 'fO', debianArchiveFile.name, 'debian/control'],
                             stdout=subprocess.PIPE, universal_newlines=True)
         if ( (process is not None) and (process.stdout is not None) ):
            debianControl = process.stdout.readlines()

         os.unlink(debianArchiveFile.name)
         result = (debianChangelog, debianControl)

      except Exception as e:
         sys.stderr.write('ERROR: Failed to extract debian/changelog from ' + debianArchiveFile.name + ': ' + str(e) + '\n')
         sys.exit(1)

   except urllib.error.HTTPError as e:
      sys.stderr.write('not found (HTTP ' + str(e.code) + ')!\n')

   return result


# ###### Filter Debian changelog ############################################
# Filter Debian changelog: obtain entries until ITP entry.
def filterDebianChangelog(changeLogContents : list[str]) -> list[str]:

   re_begin_of_entry  : Final[re.Pattern[str]] = re.compile(r'^[a-zA-Z].*$')
   re_end_of_entry    : Final[re.Pattern[str]] = re.compile(r'^ --.*$')
   re_empty           : Final[re.Pattern[str]] = re.compile(r'^$')
   re_item            : Final[re.Pattern[str]] = re.compile(r'^ *')
   re_item_is_itp     : Final[re.Pattern[str]] = re.compile(r'^(.*Closes:.*ITP.*|.*ITP.*Closes:.*)$')

   resultingChangelog : list[str] = [ ]
   entries            : int       = 0
   entryContentLines  : int       = 0
   entryContent       : str       = ''
   entryIsITP         : bool      = False

   for line in changeLogContents:

      # ====== Begin of entry ===============================================
      if entryContentLines == 0:
         if re_begin_of_entry.match(line):
            if entries == 0:
               entryContent = line
            else:
               entryContent = '\n' + line
            entryContentLines = 1

      # ====== Within entry =================================================
      else:
         # ------ End of entry ----------------------------------------------
         if re_end_of_entry.match(line):
            entryContent = entryContent + line
            if entryContentLines > 1:
               entries = entries + 1

               # ------ Print entry -----------------------------------------
               if not entryIsITP:
                  resultingChangelog.append(entryContent)

               # ------ Print entry with ITP --------------------------------
               # Special case: The ITP package for Debian must only contain the
               #               ITP entry with ITP item and nothing else!
               else:
                  splittedITPEntry = entryContent.splitlines()
                  i = 0
                  for itpLine in splittedITPEntry:
                     i = i + 1
                     if (i <= 2) or (i >= len(splittedITPEntry) - 2):
                        resultingChangelog.append(itpLine + '\n')
                     elif re_item_is_itp.match(itpLine) is not None:
                        resultingChangelog.append(itpLine + '\n')
                  break   # ITP -> done!

               entryContent = ''
               entryIsITP   = False

            entryContentLines = 0

         # ------ Part of entry ---------------------------------------------
         else:
            if re_item.match(line):
               entryContent      = entryContent + line
               entryContentLines = entryContentLines + 1
               if re_item_is_itp.match(line):
                  entryIsITP = True

   return resultingChangelog


# ###### Merge Debian changelogs ###########################################
# This function merges debian/changelog entries.
# 1. Import old entries from distributor's changelog
# 2a. Get all newer entries from PPA changelog
# 2b. Merge newer entries into a single entry
def mergeDebianChangelogs(ppaChangelogContents         : list[str],
                          distributorChangelogContents : list[str]) -> list[str]:

   # ====== Merge changelogs ================================================
   re_entry_header : Final[re.Pattern[str]] = re.compile(r'^([a-zA-Z0-9-]+ \([0-9a-zA-Z\.~+]+-)')
   re_entry_footer : Final[re.Pattern[str]] = re.compile(r'^ -- .*')
   re_empty        : Final[re.Pattern[str]] = re.compile(r'^\S*$')

   topics : Final[list[dict[str,Any]]] = [
      { 'regexp': re.compile(r'^  \* .*ew upstream (version|release).*$'), 'count': 0, 'max': 1 },
      { 'regexp': re.compile(r'^  \* .*standards version.*$'),             'count': 0, 'max': 1 },
      { 'regexp': re.compile(r'^  \* .*debian/compat:.*$'),                'count': 0, 'max': 0 }
   ]

   # ------ Get latest entry from distribution changelog --------------------
   latestDistributionEntry : str                  = distributorChangelogContents[0]
   match                   : re.Match[str] | None = re_entry_header.match(latestDistributionEntry)
   if match is None:
      sys.stderr.write('ERROR: Bad distributor changelog header!\n')
      sys.stderr.write('First distributor entry: "' + latestDistributionEntry.strip() + '"\n')
      sys.exit(1)
   latestDistributionEntry = match.group(1)

   # ------ Join all new entries from PPA changelog -------------------------
   resultingChangelogContents        : list[str]  = [ ]
   entries                           : int        = 0
   insideEntry                       : bool       = False
   foundLatestDistributionEntryInPPA : bool       = False
   firstFooter                       : str | None = None

   for line in ppaChangelogContents:

      # ------ Begin of an entry --------------------------------------------
      match = re_entry_header.match(line)
      if match is not None:

         # ------ Done? -----------------------------------------------------
         if match.group(1) == latestDistributionEntry:
            foundLatestDistributionEntryInPPA = True
            break

         # ------ Add this entry --------------------------------------------
         entries = entries + 1
         if entries == 1:
            resultingChangelogContents.append(line)
            resultingChangelogContents.append('\n')

         continue

      # ------ Empty line ---------------------------------------------------
      match = re_empty.match(line)
      if match is not None:
         continue

      # ------ End of an entry ----------------------------------------------
      match = re_entry_footer.match(line)
      if match is not None:
         if entries == 1:
            firstFooter = line
         continue

      # ------ Topics -------------------------------------------------------
      skip = False
      for topic in topics:
         match = topic['regexp'].match(line)
         if match is not None:
            topic['count'] = topic['count'] + 1
            if topic['count'] > topic['max']:
               skip = True
               break
      if skip == True:
         continue

      # ------ Add line to output -------------------------------------------
      if line[0] == ' ':
         resultingChangelogContents.append(line)
         continue

      # ------ Something is wrong -------------------------------------------
      sys.stderr.write('ERROR: Unexpected line: ' + line + '!\n')
      sys.exit(1)


   resultingChangelogContents.append('\n')
   if firstFooter is not None:
      resultingChangelogContents.append(firstFooter)

   if foundLatestDistributionEntryInPPA == False:
      # Distributor changelog is equal to PPA changelog?
      if ppaChangelogContents[0] != latestDistributionEntry:
         sys.stderr.write('ERROR: Did not find the latest distribution entry in the PPA changelog!\n')
         sys.stderr.write('Latest distributor entry: "' + latestDistributionEntry.strip() + '"\n')
         sys.stderr.write('First PPA entry:          "' + ppaChangelogContents[0].strip() + '"\n')
         sys.exit(1)

   for line in distributorChangelogContents:
      resultingChangelogContents.append(line)

   #for line in resultingChangelogContents:
      #sys.stdout.write(line)

   return resultingChangelogContents



# ###### Make Debian source package #########################################
def makeSourceDeb(packageInfo        : dict[str,Any],
                  codenames          : list[str],
                  skipPackageSigning : bool,
                  summaryFile        : TextIO | None) -> bool:

   assert DebianCodenames is not None

   # ====== Make sure that the source tarball is available ==================
   if makeSourceTarball(packageInfo, skipPackageSigning, summaryFile) == False:
      return False
   if len(codenames) == 0:
      codenames = [ packageInfo['debian_codename'] ]


   # ====== Build for each distribution codename ============================
   changesFiles : dict[str,str]     = { }
   dscFiles     : dict[str,str]     = { }
   re_launchpad : Final[re.Pattern[str]] = re.compile(r'^.*\(LP: #[0-9]+')
   re_dhcompat  : Final[re.Pattern[str]] = re.compile(r'^(Build-Depends:[ \t]*|[ \t]*)(debhelper-compat \(= [0-9]+\))(.*)$')
   re_parallel  : Final[re.Pattern[str]] = re.compile(r' --parallel')
   re_vcs       : Final[re.Pattern[str]] = re.compile(r'^(Vcs-[a-zA-Z]+)(:[ t]*)(.*)$')

   printSection('Creating source Debian packages')

   # Make sure to have the original packages with their Debian names:
   originalTarball   : Final[str] = packageInfo['debian_package_name'] + '_' + \
                                       packageInfo['debian_version_string'] + '.orig.tar.' + \
                                       packageInfo['source_tarball_format']
   originalSignature : Final[str] = originalTarball + '.asc'
   try:
      os.link(packageInfo['source_tarball_name'], originalTarball)
   except FileExistsError:
      pass
   if skipPackageSigning == False:
      try:
         os.link(packageInfo['source_tarball_signature'], originalSignature)
      except FileExistsError:
         pass

   defaultArchitecture : Final[str] = getDebianDefaultArchitecture()
   for codename in codenames:
      updatedDebhelperVersion : int = 0

      printSubsection('Creating source Debian package for ' + codename)

      # ====== Prepare work directory =======================================
      workdir : str = '/tmp/packaging-' + \
         codename + '-' + \
         packageInfo['debian_package_name'] + '-' + \
         packageInfo['debian_version_string'] + '-' + \
         packageInfo['debian_version_packaging']
      sys.stdout.write('Preparing work directory ' + workdir + ' ...\n')

      shutil.rmtree(workdir, ignore_errors = True)
      os.makedirs(workdir, exist_ok = True)

      # !!! Using *symlink* below! !!!
      # shutil.copyfile(originalTarball, workdir + '/' + originalTarball)
      # if skipPackageSigning == False:
          #shutil.copyfile(originalSignature, workdir + '/' + originalSignature)
      os.symlink(os.path.abspath(packageInfo['source_tarball_name']), workdir + '/' + originalTarball)
      if skipPackageSigning == False:
         os.symlink(os.path.abspath(packageInfo['source_tarball_signature']), workdir + '/' + originalSignature)


      # ====== Unpack the sources ===========================================
      compressionOption : str = TarOptions[packageInfo['source_tarball_format']]
      try:
         subprocess.run([ 'tar', 'x' + compressionOption + 'f', originalTarball ], cwd = workdir, check = True)
      except Exception as e:
         sys.stderr.write('ERROR: Unable to uncompress upstream source tarball ' + originalTarball + ': ' + str(e) + '\n')
         sys.exit(1)

      upstreamSourceDir : str = workdir + '/' + packageInfo['debian_package_name'] + '-' + packageInfo['debian_version_string']
      if not os.path.isdir(upstreamSourceDir):
         # Sources not found in expected directory!
         sys.stderr.write('ERROR: Sources are not in the expected directory ' + upstreamSourceDir + '!\n')
         # Check for package-*/debian/.. as new path:
         found = glob.glob(workdir + '/' + packageInfo['debian_package_name'] + '-*/debian/..')
         if len(found) == 1:
            # Found -> set new upstream source path
            sys.stderr.write('WARNING: Sources are not in the expected directory ' + upstreamSourceDir)
            upstreamSourceDir = os.path.realpath(found[0])
            sys.stderr.write(' => using ' + upstreamSourceDir + '!\n')
         else:
            # Not found -> abort with error.
            sys.stderr.write('ERROR: Sources are not in the expected directory ' + upstreamSourceDir + '!\n')
            sys.exit(1)


      # ====== Adapt packaging ==============================================
      changeLogContents       : list[str] = readTextFile(packageInfo['debian_changelog_name'])
      controlContents         : list[str] = readTextFile(packageInfo['debian_control_name'])
      rulesContents           : list[str] = readTextFile(packageInfo['debian_rules_name'])
      upstreamControlContents : list[str] | None = None

      newDebianVersionPackaging : str = modifyDebianVersionPackaging(packageInfo, codename)
      sys.stdout.write('Modifying packaging version from ' + \
                       packageInfo['debian_version_packaging'] + \
                       ' to ' + newDebianVersionPackaging + '!\n')


      # ====== Update changelog =============================================
      changeLogContents[0] = \
         packageInfo['debian_package_name'] + \
         ' (' + packageInfo['debian_version_prefix'] + packageInfo['debian_version_string'] + '-' + newDebianVersionPackaging + ') ' + \
         codename + '; ' + \
         'urgency=' + packageInfo['debian_urgency'] + \
         '\n'
      sys.stdout.write('Updating changelog header: ' + changeLogContents[0])

      # ------- Debian ------------------------------------------------------
      if codename in DebianCodenames:
         # Remove Launchpad entries:
         changeLogContents = \
            [ line for line in changeLogContents if not re_launchpad.match(line) ]

         # ------ Fetch distributor's latest changelog and control file -----
         distributorChangelogContents, distributorControlContents = \
            fetchDebianChangelogAndControl(packageInfo, codename)

         # ------ Update debian/changelog -----------------------------------
         if distributorChangelogContents is not None:
            # Merge new entries from changelog into a single entry, and append
            # the distributor's changelog with the rest:
            changeLogContents = \
               mergeDebianChangelogs(changeLogContents, distributorChangelogContents)

         changeLogContents = filterDebianChangelog(changeLogContents)

         # ------ Update debian/control -------------------------------------
         if distributorControlContents is not None:
            # Find Vcs-* defined by distributor:
            vcsGit     : str | None = None
            vcsBrowser : str | None = None
            for line in distributorControlContents:
               match : re.Match[str] | None = re_vcs.match(line)
               if match:
                  if match.group(1) == 'Vcs-Git':
                     vcsGit = match.group(3)
                  elif match.group(1) == 'Vcs-Browser':
                     vcsBrowser = match.group(3)

            # Replace Vcs-* with versions from distributor:
            if ( (vcsGit is not None) or (vcsBrowser is not None) ):
               for i in range(0, len(controlContents)):
                  match = re_vcs.match(controlContents[i])
                  if match:
                     if vcsGit is not None:
                        controlContents[i] = re.sub(r'^Vcs-Git:.*$', 'Vcs-Git: ' + vcsGit, controlContents[i])
                     if vcsBrowser is not None:
                        controlContents[i] = re.sub(r'^Vcs-Browser:.*$', 'Vcs-Browser: ' + vcsBrowser, controlContents[i])
            else:
               # No distributor Vcs-* fields -> remove them!
               controlContents = \
                  [ line for line in controlContents if not re_vcs.match(line) ]
         else:
            controlContents = \
               [ line for line in controlContents if not re_vcs.match(line) ]

      # ------- Ubuntu ------------------------------------------------------
      else:
         # ------ Translate Debian debhelper configuration to Ubuntu --------
         # Debian insists on the latest version of Debhelper,
         # Ubuntu in older versions does not provide the up-to-date Debhelper.
         # => Translate to older Debhelper versions, if necessary.
         if codename in DebhelperCompatFixes:
            dhcompatVersion : int = DebhelperCompatFixes[codename]
            for i in range(0, len(controlContents)):
               match = re_dhcompat.match(controlContents[i])
               if match:
                  controlContents[i] = match.group(1) +  \
                                         'debhelper (>= ' + str(dhcompatVersion) + ')' + \
                                          match.group(3) + '\n'

                  # Write debian/compat file:
                  writeTextFile(upstreamSourceDir + '/debian/compat', [ str(dhcompatVersion) + '\n' ])

                  # Replace "${DEB_HOST_MULTIARCH}" -> "*" in *.install:
                  installFiles : list[str] = glob.glob(upstreamSourceDir + '/debian/*.install')
                  if len(installFiles) > 0:
                     sedCommand = [ 'sed', '-e', 's#${DEB_HOST_MULTIARCH}#*#', '-i' ] + installFiles
                     try:
                        subprocess.run(sedCommand, check=True)
                     except Exception as e:
                        sys.stderr.write('ERROR: PBuilder run failed: ' + str(e) + '\n')
                        sys.exit(1)

                  updatedDebhelperVersion = dhcompatVersion
                  break


      # ====== Clean up empty lines =========================================
      writeTextFile(upstreamSourceDir + '/debian/changelog', changeLogContents)
      writeTextFile(upstreamSourceDir + '/debian/control',   controlContents)
      writeTextFile(upstreamSourceDir + '/debian/rules',     rulesContents)

      # print(upstreamSourceDir + '/debian/changelog')
      # print(upstreamSourceDir + '/debian/control')
      # sys.exit(2)

      sys.stdout.write('Updated Debian changelog:\n')
      showDiff(packageInfo['debian_changelog_name'], upstreamSourceDir + '/debian/changelog')
      sys.stdout.write('Updated Debian control:\n')
      showDiff(packageInfo['debian_control_name'], upstreamSourceDir + '/debian/control')
      sys.stdout.write('Updated Debian rules:\n')
      showDiff(packageInfo['debian_rules_name'], upstreamSourceDir + '/debian/rules')


      # ====== Build source Debian package ==================================
      if skipPackageSigning == False:
         # Build source package including signature:
         if packageInfo['packaging_maintainer_key'] is None:
            sys.stderr.write('ERROR: No MAINTAINER_KEY in ' + packageInfo['packaging_config_name'] + '\n')
            sys.exit(1)
         debuild = [ 'debuild', '--no-check-builddeps', '-sa', '-S', '-k' + packageInfo['packaging_maintainer_key'], '-i' ]
      else:
         # Build source package without signature:
         debuild = [ 'debuild', '--no-check-builddeps', '-us', '-uc', '-S', '-i' ]
      if updatedDebhelperVersion != 0:
         debuild.append('--no-check-builddeps')
      try:
         subprocess.run(debuild, cwd = upstreamSourceDir, check = True)
      except Exception as e:
         sys.stderr.write('ERROR: Debuild run failed: ' + str(e) + '\n')
         sys.exit(1)


      # ====== Check results ================================================
      sys.stdout.write('Checking results:\n')
      dscFileName        : str = getDebianDscName(packageInfo,            newDebianVersionPackaging)
      buildinfoName      : str = getDebianBuildinfoName(packageInfo,      newDebianVersionPackaging)
      changesName        : str = getDebianChangesName(packageInfo,        newDebianVersionPackaging)
      controlTarballName : str = getDebianControlTarballName(packageInfo, newDebianVersionPackaging)
      if os.path.isfile(workdir + '/' + dscFileName):
         sys.stdout.write('* DSC file is ' + dscFileName + '.\n')
         dscFiles[codename] = dscFileName
      else:
         sys.stderr.write('ERROR: DSC file not found at expected location ' + workdir + '/' + dscFileName + '!\n')
         sys.exit(1)
      changesFiles[codename] = changesName

      if os.path.isfile(workdir + '/' + buildinfoName):
         sys.stdout.write('* Buildinfo file is ' + buildinfoName + '.\n')
      else:
         sys.stderr.write('ERROR: Buildinfo file not found at expected location ' + workdir + '/' + buildinfoName + '!\n')
         sys.exit(1)
      if os.path.isfile(workdir + '/' + changesName):
         sys.stdout.write('* Changes file is ' + changesName + '.\n')
      else:
         sys.stderr.write('ERROR: Source changes file not found at expected location ' + workdir + '/' + changesName + '!\n')
         sys.exit(1)
      if os.path.isfile(workdir + '/' + controlTarballName):
         sys.stdout.write('* Control tarball file is ' + controlTarballName + '.\n')
      else:
         sys.stderr.write('ERROR: Control tarball file not found at expected location ' + workdir + '/' + controlTarballName + '!\n')
         sys.exit(1)

      # Copy results to main directory.
      # NOTE: The original tarball and signature are already there (symlinked)!
      for fileName in [ dscFileName, buildinfoName, changesName, controlTarballName ] :
         shutil.copyfile(workdir + '/' + fileName, fileName)

      # ====== Add files to summary =========================================
      if summaryFile is not None:
         summaryFile.write('debSourceDscFile: '            + workdir + '/' + dscFileName        + ' ' + codename + '\n')
         summaryFile.write('debSourceBuildinfoFile: '      + workdir + '/' + buildinfoName      + ' ' + codename + '\n')
         summaryFile.write('debSourceChangesFile: '        + workdir + '/' + changesName        + ' ' + codename + '\n')
         summaryFile.write('debSourceControlTarballFile: ' + workdir + '/' + controlTarballName + ' ' + codename + '\n')

   # ====== Print overview ==================================================
   printSection('Debian source package overview')

   sys.stdout.write('\x1b[34mUpload to PPA:\x1b[0m\n')
   for codename in sorted(changesFiles.keys()):
      if codename in DebianCodenames:
         ppa = 'mentors'
      else:
         ppa = 'ppa'
      sys.stdout.write('\x1b[34m   dput ' + ppa + ' ' + changesFiles[codename] + '\x1b[0m\n')
   sys.stdout.write('\n')

   sys.stdout.write('\x1b[34mBuild Test Commands:\x1b[0m\n')
   for codename in sorted(dscFiles.keys()):

      buildArchitecture = getDebianDefaultArchitecture()
      buildDistribution = codename
      if codename in DebianCodenames:
         buildSystem    = 'debian'
         lintianProfile = 'debian'
      else:
         buildSystem    = 'ubuntu'
         lintianProfile = 'ubuntu'
      basetgz = '/var/cache/pbuilder/' + buildSystem + '-' + codename + '-' + buildArchitecture + '-base.tgz'

      sys.stdout.write('\x1b[34m   ' + \
                       'sudo pbuilder build --basetgz ' + basetgz + ' ' + \
                       dscFiles[codename] + ' && ' + \
                       'lintian -iIEv --profile ' + lintianProfile + '  --pedantic ' + \
                        '/var/cache/pbuilder/result/*/' +  changesFiles[codename] + \
                       '\x1b[0m\n')
   sys.stdout.write('\n')

   return True


# ###### Build Debian binary package ########################################
def buildDeb(packageInfo        : dict[str,Any],
             codenames          : list[str],
             architectures      : list[str],
             skipPackageSigning : bool,
             summaryFile        : TextIO | None,
             twice              : bool) -> bool:

   assert DebianCodenames is not None

   # ====== Build for each distribution codename ============================
   printSection('Creating binary Debian packages')

   if len(codenames) == 0:
      codenames = [ packageInfo['debian_codename'] ]
   if len(architectures) == 0:
      architectures = [ getDebianDefaultArchitecture() ]
   for codename in codenames:

      # ====== Make sure that the source Debian files are available ============
      makeSourceDeb(packageInfo, [ codename ], skipPackageSigning, summaryFile)

      for buildArchitecture in architectures:
         newDebianVersionPackaging = modifyDebianVersionPackaging(packageInfo, codename)

         printSubsection('Creating binary Debian package for ' + codename + '/' + buildArchitecture)

         dscFileName = getDebianDscName(packageInfo, newDebianVersionPackaging)
         if not os.path.isfile(dscFileName):
            sys.stderr.write('ERROR: DSC file ' + dscFileName + ' does not exist!\n')
            sys.exit(1)

         buildDistribution : str = codename
         if codename in DebianCodenames:
            buildSystem    : str = 'debian'
            lintianProfile : str = 'debian'
         else:
            buildSystem    = 'ubuntu'
            lintianProfile = 'ubuntu'
         basetgz : str = '/var/cache/pbuilder/' + buildSystem + '-' + codename + '-' + buildArchitecture + '-base.tgz'
         if not os.path.isfile(basetgz):
            sys.stderr.write('ERROR: Base tgz ' + basetgz + ' for pbuilder is not available!\nCheck pbuilder installation and configuration!\n')
            sys.exit(1)
         buildlog : str = 'build-' + buildSystem + '-' + codename + '-' + buildArchitecture + '.log'

         # ====== Run pbuilder ==============================================
         pbuilderCommand : list[str] = [ 'sudo',
                                            'OS='   + buildSystem,
                                            'ARCH=' + buildArchitecture,
                                            'DIST=' + codename,
                                         'pbuilder',
                                            'build',
                                            '--basetgz', basetgz,
                                            '--logfile', buildlog ]
         if twice:
            pbuilderCommand.append('--twice')
         pbuilderCommand.append(dscFileName)
         print(pbuilderCommand)
         try:
            subprocess.run(pbuilderCommand, check=True)
         except Exception as e:
            sys.stderr.write('ERROR: PBuilder run failed: ' + str(e) + '\n')
            sys.exit(1)

         # ====== Check for changes file ====================================
         changesFileName : str = getDebianChangesName(packageInfo,
                                                      newDebianVersionPackaging,
                                                      buildArchitecture)
         changesFileGlobs : list[str] = [
            '/var/cache/pbuilder/result/' + buildSystem + '-' + codename + '-' + buildArchitecture + '/' + changesFileName,
            '/var/cache/pbuilder/result/' + changesFileName,
            '/var/cache/pbuilder/result/*/' + changesFileName
         ]
         for changesFileGlob in changesFileGlobs:
            foundChangesFiles : list[str] = glob.glob(changesFileGlob)
            if len(foundChangesFiles) != 0:
               break
         if len(foundChangesFiles) < 1:
            sys.stderr.write('ERROR: Unable to locate the changes file!\n')
            sys.stderr.write('Search locations for changes file: ' + str(changesFileGlobs) + '!')
            sys.exit(1)
         elif len(foundChangesFiles) > 1:
            sys.stderr.write('ERROR: Multiple changes files have been found! PBuilder configuration problem?\n')
            sys.stderr.write('Found changes files: ' + str(foundChangesFiles) + '\n')
            sys.exit(1)

         # ====== Run BLHC ==================================================
         # FIXME: BLHC < 0.14 does not properly handle D_FORTIFY_SOURCE=3!
         if shutil.which('blhc') is not None:
            blhcCommand : str = 'blhc ' + buildlog + ' | grep -v "^CPPFLAGS missing (-D_FORTIFY_SOURCE=2).*-D_FORTIFY_SOURCE=3.*"  | grep -v "^NONVERBOSE BUILD:"'
            printSubsection('Running BLHC')
            print('=> ' + blhcCommand)
            try:
               subprocess.run(blhcCommand, check=False, shell=True)
            except Exception as e:
               sys.stderr.write('ERROR: BLHC run failed: ' + str(e) + '\n')
               sys.exit(1)
         else:
            sys.stderr.write('NOTE: BLHC is not installed -> checks skipped!\n')

         # ====== Run Lintian ===============================================
         if shutil.which('lintian') is not None:
            lintianCommand : str = 'lintian -v -i -I -E --pedantic' + \
                                   ' --profile ' + lintianProfile + \
                                   ' --suppress-tags malformed-deb-archive,bad-distribution-in-changes-file' + \
                                   ' ' + foundChangesFiles[0]
            printSubsection('Running Lintian')
            print('=> ' + lintianCommand)
            try:
               subprocess.run(lintianCommand, check=False, shell=True)
            except Exception as e:
               sys.stderr.write('ERROR: Lintian run failed: ' + str(e) + '\n')
               sys.exit(1)
         else:
            sys.stderr.write('NOTE: Lintian is not installed -> checks skipped!\n')

         # ====== Run LRC ===================================================
         if shutil.which('lrc') is not None:
            lrcCommand : str = 'lrc -t'
            printSubsection('Running LRC')
            print('=> ' + lrcCommand)
            try:
               subprocess.run(lrcCommand, check=False, shell=True)
            except Exception as e:
               sys.stderr.write('ERROR: LRC run failed: ' + str(e) + '\n')
               sys.exit(1)
         else:
            sys.stderr.write('NOTE: LRC is not installed -> checks skipped!\n')

         # ====== Add Changes file to summary ===============================
         if summaryFile is not None:
            summaryFile.write('debBuildChangesFile: ' + foundChangesFiles[0] + ' ' + codename + ' ' + buildArchitecture + '\n')

   return True


# ###### Make SRPM source package ###########################################
def makeSourceRPM(packageInfo        : dict[str,Any],
                  skipPackageSigning : bool,
                  summaryFile        : TextIO | None) -> bool:

   # ====== Make sure that the source tarball is available ==================
   if makeSourceTarball(packageInfo, skipPackageSigning, summaryFile) == False:
      return False

   printSection('Creating SRPM source packages')

   # ====== Initialise RPM build directories ================================
   homeDir     : Final[str | None] = os.environ.get('HOME')
   assert homeDir is not None
   rpmbuildDir : Final[str]        = os.path.join(homeDir, 'rpmbuild')
   for subdir in [ 'BUILD', 'BUILDROOT', 'RPMS', 'SOURCES', 'SPECS', 'SRPMS' ]:
      os.makedirs(rpmbuildDir + '/' + subdir, exist_ok = True)

   # ====== Copy source tarball =============================================
   if skipPackageSigning == False:
      result = os.system('gpg --verify ' + \
                        packageInfo['source_tarball_signature'] + ' ' + \
                        packageInfo['source_tarball_name'])
      if result == 0:
         sys.stderr.write('Signature verified.\n')
      else:
         sys.stderr.write('ERROR: Bad signature! Something is wrong!\n')
         return False

   try:
      os.unlink(rpmbuildDir + '/SOURCES/' + packageInfo['source_tarball_name'])
   except FileNotFoundError:
      pass
   shutil.copyfile(os.path.abspath(packageInfo['source_tarball_name']),
                   rpmbuildDir + '/SOURCES/' + packageInfo['source_tarball_name'])

   rpmSpecNames : Final[list[str]] = glob.glob('rpm/*.spec')
   if len(rpmSpecNames) == 1:
      packageInfo['rpm_spec_name'] = rpmSpecNames[0]
      shutil.copyfile(packageInfo['rpm_spec_name'],
                      rpmbuildDir + '/SPECS/' + packageInfo['rpm_package_name'] + '.spec')
   elif len(rpmSpecNames) > 1:
      sys.stderr.write('ERROR: More than one spec file found: ' + str(rpmSpecNames) + '!\n')
      sys.exit(1)
   else:
      sys.stderr.write('ERROR: No spec file found!\n')
      sys.exit(1)

   # ====== Create SRPM =====================================================
   rpmbuild : Final[list[str]] = [ 'rpmbuild', '-bs', packageInfo['rpm_spec_name'] ]
   print(rpmbuild)
   try:
      subprocess.run(rpmbuild, check = True)
   except Exception as e:
      sys.stderr.write('ERROR: RPMBuild run failed: ' + str(e) + '\n')
      sys.exit(1)

   srpmFile = rpmbuildDir + '/SRPMS/' + \
                 packageInfo['rpm_package_name'] + '-' + \
                 packageInfo['rpm_version_string'] + '-' + str(packageInfo['rpm_version_packaging']) + \
                 '.src.rpm'
   if not os.path.isfile(srpmFile):
      sys.stderr.write('ERROR: SRPM ' + srpmFile + ' not found!\n')
      sys.exit(1)

   # ====== Sign SRPM =======================================================
   if skipPackageSigning == False:
      rpmsign = [ 'rpmsign',
                  '--define', '_gpg_name ' + packageInfo['packaging_maintainer_key'],
                  '--addsign', srpmFile ]
      print(rpmsign)
      try:
         subprocess.run(rpmsign, check = True)
      except Exception as e:
         sys.stderr.write('ERROR: RPMSign run failed: ' + str(e) + '\n')
         sys.exit(1)

   # ====== Run RPM Lint ====================================================
   if shutil.which('rpmlint') is not None:
      rpmlintCommand = 'rpmlint -P ' + srpmFile
      printSubsection('Running RPM Lint')
      print('=> ' + rpmlintCommand)
      try:
         subprocess.run(rpmlintCommand, check=False, shell=True)
      except Exception as e:
         sys.stderr.write('ERROR: RPM Lint run failed: ' + str(e) + '\n')
         sys.exit(1)
   else:
      sys.stderr.write('NOTE: RPM Lint is not installed -> checks skipped!\n')

   # ====== Add SRPM file to summary ========================================
   if summaryFile is not None:
      summaryFile.write('rpmSourceFile: ' + srpmFile + '\n')

   return True


# ###### Build RPM binary package ###########################################
def buildRPM(packageInfo        : dict[str,Any],
             releases           : list[str],
             architectures      : list[str],
             skipPackageSigning : bool,
             summaryFile        : TextIO | None) -> bool:

   # ====== Make sure that the source Debian files are available ============
   if len(releases) == 0:
      if distro.id() == 'fedora':
         releases = [ 'fedora-' + distro.major_version() ]
      else:
         releases = [ 'rawhide' ]
   if len(architectures) == 0:
      architectures = [ getArchitecture() ]
   makeSourceRPM(packageInfo, skipPackageSigning, summaryFile)

   # ====== Build for each distribution codename ============================
   printSection('Creating binary RPM packages')

   # ====== Check SRPM ======================================================
   homeDir     : Final[str | None] = os.environ.get('HOME')
   assert homeDir is not None
   rpmbuildDir : Final[str]        = os.path.join(homeDir, 'rpmbuild')
   srpmFile = rpmbuildDir + '/SRPMS/' + \
                 packageInfo['rpm_package_name'] + '-' + \
                 packageInfo['rpm_version_string'] + '-' + str(packageInfo['rpm_version_packaging']) + \
                 '.src.rpm'
   if not os.path.isfile(srpmFile):
      sys.stderr.write('ERROR: SRPM ' + srpmFile + ' not found!\n')
      sys.exit(1)

   if skipPackageSigning == False:
      rpm = [ 'rpm',
            '--define', '_gpg_name ' + packageInfo['packaging_maintainer_key'],
            '--checksig', srpmFile ]
      print(rpm)
      try:
         subprocess.run(rpm, check = True)
      except Exception as e:
         sys.stderr.write('ERROR: RPMSign run failed: ' + str(e) + '\n')
         sys.exit(1)

   # ====== Build for each release ==========================================
   for buildArchitecture in architectures:
      for release in releases:

         printSubsection('Creating binary RPM package(s) for ' + release + '/' + buildArchitecture)

         # ====== Check for mock configuration file =========================
         configuration = release + '-' + buildArchitecture
         if not os.path.isfile('/etc/mock/' + configuration + '.cfg'):
            sys.stderr.write('ERROR: Configuration ' + '/etc/mock/' + configuration + '.cfg does not exist!\n')
            sys.exit(1)

         # ====== Delete old RPMs ===========================================
         for rpmFilePrefix in packageInfo['rpm_packages']:
            for architecture in [ 'noarch', buildArchitecture ]:
               rpmFile = '/var/lib/mock/' + configuration + '/result/' + \
                            rpmFilePrefix + '.' + \
                            architecture + '.rpm'
         try:
            os.unlink(rpmFile)
         except FileNotFoundError:
            pass

         # ====== Run mock ==================================================
         try:
            # NOTE: using old chroot instead of container, to allow running
            #       mock inside a container!
            commands : list[list[str]] = [
                  [ 'mock', '-r', configuration, '--isolation=auto', '--init' ],
                  [ 'mock', '-r', configuration, '--isolation=auto', '--installdeps', srpmFile ],
                  [ 'mock', '-r', configuration, '--isolation=auto', '--rebuild', '--no-clean', srpmFile ]
               ]
            for command in commands:
               print(command)
               subprocess.run(command, check = True)
         except Exception as e:
            sys.stderr.write('ERROR: Mock run failed: ' + str(e) + '\n')
            sys.exit(1)

         # ------ Check resulting RPM =======================================
         for rpmFilePrefix in packageInfo['rpm_packages']:
            rpmArchitecture : str | None = None
            found           : bool       = False
            for architecture in [ 'noarch', buildArchitecture ]:
               rpmFile = \
                  '/var/lib/mock/' + configuration + '/result/' + \
                  rpmFilePrefix + '.' + \
                  architecture + '.rpm'
               if os.path.isfile(rpmFile):
                  rpmArchitecture = architecture
                  found           = True
                  break
            if not found:
               sys.stderr.write('ERROR: RPM ' + rpmFile + ' not found!\n')
               sys.exit(1)

            # ====== Sign SRPM ==============================================
            if skipPackageSigning == False:
               rpmsign : list[str] = \
                  [ 'rpmsign',
                    '--define', '_gpg_name ' + packageInfo['packaging_maintainer_key'],
                    '--addsign', rpmFile ]
               print(rpmsign)
               try:
                  subprocess.run(rpmsign, check = True)
               except Exception as e:
                  sys.stderr.write('ERROR: RPMSign run failed: ' + str(e) + '\n')
                  sys.exit(1)

            # ====== Run RPM Lint ===========================================
            if shutil.which('rpmlint') is not None:
               rpmlintCommand = 'rpmlint -P ' + rpmFile
               printSubsection('Running RPM Lint')
               print('=> ' + rpmlintCommand)
               try:
                  subprocess.run(rpmlintCommand, check=False, shell=True)
               except Exception as e:
                  sys.stderr.write('ERROR: RPM Lint run failed: ' + str(e) + '\n')
                  sys.exit(1)
            else:
               sys.stderr.write('NOTE: RPM Lint is not installed -> checks skipped!\n')

            # ====== Add RPM file to summary ================================
            if summaryFile is not None:
               summaryFile.write('rpmFile: ' + rpmFile + ' ' + release + ' ' + rpmArchitecture + '\n')

   return True



# ###########################################################################
# #### Main Program                                                      ####
# ###########################################################################

# ====== Check arguments ====================================================
if len(sys.argv) < 2:
   sys.stderr.write('Usage: ' + sys.argv[0] + ' tool [options ...]\n')
   sys.stderr.write('\n* ' + sys.argv[0] + ' info\n')
   sys.stderr.write('\n* ' + sys.argv[0] + ' make-source-tarball [--skip-signing]\n')
   sys.stderr.write('\n* ' + sys.argv[0] + ' make-source-deb [--summary=file] [codename ...] [--skip-signing]\n')
   sys.stderr.write('  ' + sys.argv[0] + ' build-deb [--summary=file]  [codename ...] [--skip-signing] [--architecture=arch[,...]] [--twice]\n')
   sys.stderr.write('  ' + sys.argv[0] + ' fetch-debian-changelog [codename]\n')
   sys.stderr.write('  ' + sys.argv[0] + ' fetch-debian-control [codename]\n')
   sys.stderr.write('\n* ' + sys.argv[0] + ' make-source-rpm [--summary=file]  [release ...] [--skip-signing]\n')
   sys.stderr.write('  ' + sys.argv[0] + ' build-rpm [--summary=file] [release ...] [--skip-signing] [--architecture=[,...]]\n')
   sys.exit(1)


packageInfo        : dict[str,Any] = readPackagingInformation()
tool               : Final[str]    = sys.argv[1]
summaryFile        : TextIO | None = None
skipPackageSigning : bool          = False
codenames          : list[str]     = [ ]
releases           : list[str]     = [ ]
architectures      : list[str]     = [ ]

# ====== Print information ==================================================
if tool == 'info':
   showInformation(packageInfo)

# ====== Make source tarball ================================================
elif tool == 'make-source-tarball':
   for i in range(2, len(sys.argv)):
      if sys.argv[i] == '--skip-signing':
         skipPackageSigning = True
      elif sys.argv[i][0:10] == '--summary=':
         summaryFileName = sys.argv[i][10:]
         try:
            summaryFile = open(summaryFileName, 'w', encoding='utf-8')
         except:
            sys.stderr.write('ERROR: Unable to create summary file ' + summaryFileName + '!\n')
            sys.exit(1)
      else:
         sys.stderr.write('ERROR: Bad make-source-tarball parameter ' + sys.argv[i] + '!\n')
         sys.exit(1)
   if makeSourceTarball(packageInfo, skipPackageSigning, summaryFile) == False:
      sys.exit(1)

# ====== Make source deb file ===============================================
elif tool == 'make-source-deb':
   obtainDistributionCodenames()
   for i in range(2, len(sys.argv)):
      if sys.argv[i][0] != '-':
         codenames.append(sys.argv[i])
      elif sys.argv[i] == '--skip-signing':
         skipPackageSigning = True
      elif sys.argv[i][0:10] == '--summary=':
         summaryFileName = sys.argv[i][10:]
         try:
            summaryFile = open(summaryFileName, 'w', encoding='utf-8')
         except:
            sys.stderr.write('ERROR: Unable to create summary file ' + summaryFileName + '!\n')
            sys.exit(1)
      else:
         sys.stderr.write('ERROR: Bad make-source-debparameter ' + sys.argv[i] + '!\n')
         sys.exit(1)
   makeSourceDeb(packageInfo, codenames, skipPackageSigning, summaryFile)

# ====== Build deb file =====================================================
elif tool == 'build-deb':
   obtainDistributionCodenames()
   twice : bool = False
   for i in range(2, len(sys.argv)):
      if sys.argv[i][0] != '-':
         codenames.append(sys.argv[i])
      elif sys.argv[i] == '--skip-signing':
         skipPackageSigning = True
      elif sys.argv[i][0:10] == '--summary=':
         summaryFileName = sys.argv[i][10:]
         try:
            summaryFile = open(summaryFileName, 'w', encoding='utf-8')
         except:
            sys.stderr.write('ERROR: Unable to create summary file ' + summaryFileName + '!\n')
            sys.exit(1)
      elif sys.argv[i][0:15] == '--architecture=':
         for architecture in sys.argv[i][15:].split(','):
            architectures.append(architecture)
      elif sys.argv[i] == '--twice':
         twice = True
      else:
         sys.stderr.write('ERROR: Bad build-deb parameter ' + sys.argv[i] + '!\n')
         sys.exit(1)
   buildDeb(packageInfo, codenames, architectures, skipPackageSigning, summaryFile, twice)

# ====== Make source deb file ===============================================
elif tool == 'make-source-rpm':
   for i in range(2, len(sys.argv)):
      if sys.argv[i] == '--skip-signing':
         skipPackageSigning = True
      elif sys.argv[i][0:10] == '--summary=':
         summaryFileName = sys.argv[i][10:]
         try:
            summaryFile = open(summaryFileName, 'w', encoding='utf-8')
         except:
            sys.stderr.write('ERROR: Unable to create summary file ' + summaryFileName + '!\n')
            sys.exit(1)
      else:
         sys.stderr.write('ERROR: Bad make-source-rpm parameter ' + sys.argv[i] + '!\n')
         sys.exit(1)
   makeSourceRPM(packageInfo, skipPackageSigning, summaryFile)

# ====== Build deb file =====================================================
elif tool == 'build-rpm':
   for i in range(2, len(sys.argv)):
      if sys.argv[i][0] != '-':
         releases.append(sys.argv[i])
      elif sys.argv[i] == '--skip-signing':
         skipPackageSigning = True
      elif sys.argv[i][0:10] == '--summary=':
         summaryFileName = sys.argv[i][10:]
         try:
            summaryFile = open(summaryFileName, 'w', encoding='utf-8')
         except:
            sys.stderr.write('ERROR: Unable to create summary file ' + summaryFileName + '!\n')
            sys.exit(1)
      elif sys.argv[i][0:15] == '--architecture=':
         for architecture in sys.argv[i][15:].split(','):
            architectures.append(architecture)
      else:
         sys.stderr.write('ERROR: Bad build-rpm parameter ' + sys.argv[i] + '!\n')
         sys.exit(1)
   buildRPM(packageInfo, releases, architectures, skipPackageSigning, summaryFile)

# ====== Fetch Debian changelog file ========================================
elif tool == 'fetch-debian-changelog':
   obtainDistributionCodenames()
   codename : str = 'unstable'
   if len(sys.argv) >= 3:
      codename = sys.argv[2]
   changelogContents, controlContents = \
      fetchDebianChangelogAndControl(packageInfo, codename)
   if changelogContents is not None:
      sys.stdout.write('\n')
      for line in changelogContents:
         sys.stdout.write(line)

# ====== Fetch Debian control file ==========================================
elif tool == 'fetch-debian-control':
   obtainDistributionCodenames()
   codename = 'unstable'
   if len(sys.argv) >= 3:
      codename = sys.argv[2]
   changelogContents, controlContents = \
      fetchDebianChangelogAndControl(packageInfo, codename)
   if controlContents is not None:
      sys.stdout.write('\n')
      for line in controlContents:
         sys.stdout.write(line)

# ====== Invalid tool =======================================================
else:
   sys.stderr.write('ERROR: Invalid tool "' + tool + '"\n')
   sys.exit(1)
