#!/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 distro
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

from typing import Any, Final, TextIO, cast


# ###########################################################################
# #### 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)
