# vim: set sts=2 ts=8 sw=2 tw=99 et ft=python:
import os, json, re, subprocess, shutil, sys
from ambuild2.frontend.v2_2.cpp.builders import TargetSuffix
from ambuild2.util import ConsoleRed, ConsoleNormal

# Edit the functions below for the extra functionality, the return should be
# a list of path's to wanted locations
def additionalLibs(context, binary, sdk):
  return [
    # Path should be relative either to hl2sdk folder or to build folder
    # 'path/to/lib/example.lib',
  ]

def additionalDefines(context, binary, sdk):
  return [
    # 'EXAMPLE_DEFINE=2'
  ]

def additionalIncludes(context, binary, sdk):
  return [
    # Path should be absolute only!
    # os.path.join(sdk['path'], 'game', 'server'),
    # os.path.join(sdk['path'], 'public', 'entity2'),
    # 'D:/absolute/path/to/include/folder/'
  ]

def hasLLDLinker():
  result = shutil.which('lld')
  if not result:
    result = shutil.which('ld.lld')
  return result != None

def getGitHeadPath():
  curr_source = builder.currentSourcePath
  direct_head = os.path.join(curr_source, '.git', 'HEAD')
  if not os.path.exists(direct_head):
    return (None, None)

  git_head_path = None
  with open(direct_head, 'r') as fp:
    head_contents = fp.read().strip()
    if re.search('^[a-fA-F0-9]{40}$', head_contents):
      git_head_path = direct_head
    else:
      git_state = head_contents.split(':')[1].strip()
      git_head_path = os.path.join(curr_source, '.git', git_state)
      if not os.path.exists(git_head_path):
        git_head_path = direct_head

  return (direct_head, git_head_path)

def runAndReturn(argv):
  p = subprocess.Popen(argv, stdout=subprocess.PIPE, stderr=subprocess.PIPE, cwd = builder.currentSourcePath)
  output, ignored = p.communicate()
  rval = p.poll()
  if rval:
    return None
  text = output.decode('utf8')
  return text.strip()

def resolveEnvPath(env, folder = None):
  if env in os.environ:
    path = os.environ[env]
    if os.path.isdir(path):
      return path
  elif folder is not None:
    head = os.getcwd()
    oldhead = None
    while head != None and head != oldhead:
      path = os.path.join(head, folder)
      if os.path.isdir(path):
        return path
      oldhead = head
      head, tail = os.path.split(head)
  return None

def resolveMMSRoot():
  prenormalized_path = None
  if builder.options.mms_path:
    prenormalized_path = builder.options.mms_path
  else:
    prenormalized_path = resolveEnvPath('MMSOURCE20', 'mmsource-2.0')
    if not prenormalized_path:
      prenormalized_path = resolveEnvPath('MMSOURCE_DEV', 'metamod-source')
    if not prenormalized_path:
      prenormalized_path = resolveEnvPath('MMSOURCE_DEV', 'mmsource-central')
  if not prenormalized_path or not os.path.isdir(prenormalized_path):
    raise Exception('Could not find a source copy of Metamod:Source')
  
  return os.path.abspath(os.path.normpath(prenormalized_path))

mms_root = resolveMMSRoot()

def resolveHL2SDKManifestsRoot():
  prenormalized_path = None
  if builder.options.hl2sdk_manifests:
    prenormalized_path = builder.options.hl2sdk_manifests
  else:
    prenormalized_path = resolveEnvPath('HL2SDKMANIFESTS', 'hl2sdk-manifests')
  if not prenormalized_path or not os.path.isdir(prenormalized_path):
    raise Exception('Could not find a source copy of HL2SDK manifests')
  
  return os.path.abspath(os.path.normpath(prenormalized_path))

hl2sdk_manifests_root = resolveHL2SDKManifestsRoot()

SdkHelpers = builder.Eval(os.path.join(hl2sdk_manifests_root, 'SdkHelpers.ambuild'), {
  'Project': 'metamod'
})

class MMSPluginConfig(object):
  def __init__(self):
    self.sdk_manifests = []
    self.sdks = {}
    self.sdk_targets = []
    self.binaries = []
    self.mms_root = mms_root
    self.version_header_deps = []
    self.versionlib_deps = dict()
    self.all_targets = []
    self.target_archs = set()

    self.metadata = {
      'name': 'sample_mm',
      'alias': 'sample',
      'display_name': 'Sample Plugin',
      'description': 'Sample basic plugin',
      'author': 'AlliedModders LLC',
      'url': 'http://www.sourcemm.net/',
      'log_tag': 'SAMPLE',
      'license': 'Public Domain',
      'version': '1.0.0.{{git-shorthash}}',
    }

    if builder.options.targets:
      target_archs = builder.options.targets.split(',')
    else:
      target_archs = ['x86', 'x86_64']

    for arch in target_archs:
        try:
            cxx = builder.DetectCxx(target_arch = arch)
            self.target_archs.add(cxx.target.arch)
        except Exception as e:
            # Error if archs were manually overridden.
            if builder.options.targets:
                raise
            print('Skipping target {}: {}'.format(arch, e))
            continue
        self.all_targets.append(cxx)

    if not self.all_targets:
        raise Exception('No suitable C/C++ compiler was found.')

  def findSdkPath(self, sdk_name):
    dir_name = 'hl2sdk-{}'.format(sdk_name)
    hl2sdk_root = builder.options.hl2sdk_root
    if not hl2sdk_root:
      hl2sdk_root = resolveEnvPath('HL2SDKROOT')

    if hl2sdk_root:
      sdk_path = os.path.abspath(os.path.normpath(os.path.join(hl2sdk_root, dir_name)))
      if os.path.exists(sdk_path):
        return sdk_path
    return resolveEnvPath('HL2SDK{}'.format(sdk_name.upper()), dir_name)

  def detectSDKs(self):
    sdk_list = [s for s in builder.options.sdks.split(',') if s]
    SdkHelpers.find_sdk_path = self.findSdkPath
    SdkHelpers.findSdks(builder, self.all_targets, sdk_list)

    self.sdks = SdkHelpers.sdks
    self.sdk_manifests = SdkHelpers.sdk_manifests
    self.sdk_targets = SdkHelpers.sdk_targets

    for sdk_target in self.sdk_targets:
      if not sdk_target.sdk['source2']:
        raise Exception('Only Source2 games are supported by this script.')
  
  def addVersioning(self, cxx):
    cxx.includes += [
      os.path.join(builder.buildPath, 'versioning')
    ]

    # Nasty hack to force use lib from ambuild in the vs solution
    # otherwise it'll be required to compile versionlib separately as a vs solution
    # which is very annoying to have
    versionlib = self.versionlib_deps[cxx.target.arch]
    if builder.options.generator == 'vs':
      target_path = TargetSuffix(cxx.target)
      cxx.postlink += [ os.path.join(builder.buildPath, 'versionlib', target_path, 'versionlib.lib') ]
      cxx.sourcedeps += [ versionlib ]
    else:
      cxx.postlink += [ versionlib ]
    
    cxx.sourcedeps += self.version_header_deps

  def generateVersioningHeaders(self):
    builder.SetBuildFolder('/')

    # Generate metadata plugin header, which would cause plugin recompilation if changed
    self.version_header_deps += [
      builder.AddOutputFile(os.path.join('versioning', 'version_gen.h'), f"""
#ifndef _PLUGIN_METADATA_INFORMATION_H_
#define _PLUGIN_METADATA_INFORMATION_H_

#define PLUGIN_NAME           \"{self.metadata['name']}\"
#define PLUGIN_ALIAS          \"{self.metadata['alias']}\"
#define PLUGIN_DISPLAY_NAME   \"{self.metadata['display_name']}\"
#define PLUGIN_DESCRIPTION    \"{self.metadata['description']}\"
#define PLUGIN_AUTHOR         \"{self.metadata['author']}\"
#define PLUGIN_URL            \"{self.metadata['url']}\"
#define PLUGIN_LOGTAG         \"{self.metadata['log_tag']}\"
#define PLUGIN_LICENSE        \"{self.metadata['license']}\"

extern const char* PLUGIN_FULL_VERSION;
extern const char* PLUGIN_REVISION_SHORT;
extern const char* PLUGIN_REVISION_LONG;
extern const char* PLUGIN_REVISION_COUNT;

#endif /* _PLUGIN_METADATA_INFORMATION_H_ */
      """.encode('utf-8'))
    ]

  def generateVersionLib(self):
    builder.SetBuildFolder('/')

    version = self.metadata['version']

    direct_head, git_head_path = getGitHeadPath()
    shorthash = longhash = revision_count = '0'
    if direct_head and git_head_path:
      # Force track head changes to trigger recompilation
      builder.AddConfigureFile(git_head_path)
      builder.AddConfigureFile(direct_head)

      tag = runAndReturn(['git', 'describe', '--tags']) or 'MISSING_TAG'
      commits_after_tag = '0'

      tag_match = re.match(r'(.+?)\-(\d+)\-g[a-f\d]+$', tag, re.MULTILINE)
      if tag_match is not None:
        tag = tag_match.group(1)
        commits_after_tag = tag_match.group(2)

      revision_count = runAndReturn(['git', 'rev-list', '--count', 'HEAD']) or 'MISSING_COUNT'
      revision_hash = runAndReturn(['git', 'log', '--pretty=format:%h:%H', '-n', '1']) or 'MISSING_SHORTHASH:MISSING_LONGHASH'
      shorthash, longhash = revision_hash.split(':')

    if '{{git-shorthash}}' in version:
      version = version.replace('{{git-shorthash}}', shorthash)

    if '{{git-longhash}}' in version:
      version = version.replace('{{git-longhash}}', longhash)

    if '{{git-count}}' in version:
      version = version.replace('{{git-count}}', revision_count)

    if '{{git-tag}}' in version:
      version = version.replace('{{git-tag}}', tag)

    if '{{git-commits-after-tag}}' in version:
      version = version.replace('{{git-commits-after-tag}}', commits_after_tag)

    # For the version strings and git hashes compile that to a static library instead,
    # to prevent a full recompilation on version/git changes which would only trigger this cpp
    # and linking to be performed
    versionlib_cpp = builder.AddOutputFile(os.path.join('versioning', 'versionlib.cpp'), f"""
const char *PLUGIN_FULL_VERSION   = \"{version}\";
const char *PLUGIN_REVISION_SHORT = \"{shorthash}\";
const char *PLUGIN_REVISION_LONG  = \"{longhash}\";
const char *PLUGIN_REVISION_COUNT = \"{revision_count}\";
    """.encode('utf-8'))

    for cxx in self.all_targets:
      lib = cxx.StaticLibrary(f'versionlib_{cxx.target.arch}')
	  
      self.configureCXX(lib.compiler)
	  
      # VS generator doesn't like having non string sources, but we need that for correct
      # dependency tracking
      if builder.options.generator == 'vs':
        lib.sources += [ os.path.join(builder.buildPath, 'versioning', 'versionlib.cpp') ]
        lib.compiler.sourcedeps += [ versionlib_cpp ]
      else:
        lib.sources += [ versionlib_cpp ]
	  
      self.versionlib_deps[cxx.target.arch] = builder.Add(lib).binary

  def configure(self):
    for cxx in self.all_targets:
      if cxx.target.arch not in ['x86', 'x86_64']:
        raise Exception('Unknown target architecture: {0}'.format(arch))

      self.configureCXX(cxx)

  def configurePluginMetadata(self):
    plugin_metadata_path = os.path.join(builder.sourcePath, 'plugin-metadata.json')

    if not os.path.exists(plugin_metadata_path):
      with open(plugin_metadata_path, 'w') as f:
        json.dump(self.metadata, f, indent = 4)
    else:
      obj = None
      with open(plugin_metadata_path, 'r') as f:
        obj = json.load(f)
        self.metadata.update(obj)

      # If some fields are missing, add them back in from a sample metadata
      if len(obj) < len(self.metadata):
        with open(plugin_metadata_path, 'w') as f:
          json.dump(self.metadata, f, indent = 4)

    self.generateVersioningHeaders()
    self.generateVersionLib()    

  def configureCXX(self, cxx):
    if cxx.behavior == 'gcc':
      cxx.defines += [
        'stricmp=strcasecmp',
        '_stricmp=strcasecmp',
        '_snprintf=snprintf',
        '_vsnprintf=vsnprintf',
        'HAVE_STDINT_H',
        'GNUC',
      ]
      cxx.cflags += [
        '-pipe',
        '-fno-strict-aliasing',
        '-Wall',
        '-Werror',
        '-Wno-sign-compare',
        '-Wno-uninitialized',
        '-Wno-unused',
        '-Wno-switch',
        '-msse',
        '-fPIC',
      ]

      cxx.cxxflags += ['-std=c++17']
      if (cxx.version >= 'gcc-4.0') or cxx.family == 'clang':
        cxx.cflags += ['-fvisibility=hidden']
        cxx.cxxflags += ['-fvisibility-inlines-hidden']
      cxx.cxxflags += [
        '-fno-exceptions',
        '-fno-threadsafe-statics',
        '-Wno-non-virtual-dtor',
        '-Wno-overloaded-virtual',
        '-Wno-register',
      ]
      if (cxx.version >= 'gcc-4.7' or cxx.family == 'clang'):
        cxx.cxxflags += ['-Wno-delete-non-virtual-dtor']
      if cxx.family == 'gcc':
        cxx.cflags += ['-mfpmath=sse']
      if cxx.family == 'clang':
        cxx.cxxflags += ['-Wno-implicit-exception-spec-mismatch']
        if cxx.version >= 'clang-3.9':
          cxx.cxxflags += ['-Wno-expansion-to-defined']
        if cxx.version >= 'clang-3.6':
          cxx.cxxflags += ['-Wno-inconsistent-missing-override']
        if cxx.version >= 'clang-3.4':
          cxx.cxxflags += ['-Wno-deprecated-register']
        else:
          cxx.cxxflags += ['-Wno-deprecated']
      
      # Work around SDK warnings.
      if cxx.version >= 'clang-10.0':
        cxx.cflags += [
          '-Wno-implicit-int-float-conversion',
          '-Wno-tautological-overlap-compare',
        ]

    elif cxx.like('msvc'):
      if builder.options.debug == '1':
        cxx.cflags += ['/MTd']
        cxx.linkflags += ['/NODEFAULTLIB:libcmt']
      else:
        cxx.cflags += ['/MT']
      cxx.defines += [
        '_CRT_SECURE_NO_DEPRECATE',
        '_CRT_SECURE_NO_WARNINGS',
        '_CRT_NONSTDC_NO_DEPRECATE',
      ]
      cxx.cflags += [
        '/W3',
        '/Zi',
        '/std:c++17',
      ]
      cxx.cxxflags += ['/TP']
      
      cxx.linkflags += [
        '/SUBSYSTEM:WINDOWS',
        'kernel32.lib',
        'user32.lib',
        'gdi32.lib',
        'winspool.lib',
        'comdlg32.lib',
        'advapi32.lib',
        'shell32.lib',
        'ole32.lib',
        'oleaut32.lib',
        'uuid.lib',
        'odbc32.lib',
        'odbccp32.lib',
      ]

    # Optimization
    if builder.options.opt == '1':
      cxx.defines += ['NDEBUG']
      if cxx.behavior == 'gcc':
        cxx.cflags += ['-O3']
      elif cxx.behavior == 'msvc':
        cxx.cflags += ['/Ox', '/Zo']
        cxx.linkflags += ['/OPT:ICF', '/OPT:REF']

    # Debugging
    if builder.options.debug == '1':
      cxx.defines += ['DEBUG', '_DEBUG']
      if cxx.behavior == 'gcc':
        cxx.cflags += ['-g3']
      elif cxx.behavior == 'msvc':
        cxx.cflags += ['/Od', '/RTC1']

    # Don't omit the frame pointer.
    # This needs to be after our optimization flags which could otherwise disable it.
    if cxx.behavior == 'gcc':
      cxx.cflags += ['-fno-omit-frame-pointer']
    elif cxx.behavior == 'msvc':
      cxx.cflags += ['/Oy-']

    # Platform-specifics
    if cxx.target.platform == 'linux':
      cxx.defines += ['LINUX', '_LINUX', 'POSIX', '_FILE_OFFSET_BITS=64']
      if cxx.family == 'gcc':
        cxx.linkflags += ['-static-libgcc']
      elif cxx.family == 'clang':
        cxx.linkflags += ['-lgcc_eh']
      cxx.linkflags += ['-static-libstdc++']
      
      if hasLLDLinker():
        # This prevents STB_GNU_UNIQUE symbols from appearing in resulting binary
        # as these cause it to not unload on dlclose which metamod relies on to unload its plugins
        # as a result this messes with static/global variable destruction & causes other various effects
        # like server crashes on server exit
        cxx.linkflags += ['-fuse-ld=lld', '-Wl,--no-gnu-unique']
      
    elif cxx.target.platform == 'windows':
      cxx.defines += ['WIN32', '_WINDOWS']

  def Library(self, cxx, name):
    binary = cxx.Library(name)
    return binary
  
  def HL2Library(self, context, compiler, name, sdk):
    binary = self.Library(compiler, name)
    mms_core_path = os.path.join(self.mms_root, 'core')
    cxx = binary.compiler
    
    self.addVersioning(cxx)

    cxx.cxxincludes += [
      os.path.join(context.currentSourcePath),
      os.path.join(mms_core_path),
      os.path.join(mms_core_path, 'sourcehook'),
    ]

    for other_sdk in self.sdk_manifests:
      cxx.defines += ['SE_{}={}'.format(other_sdk['define'], other_sdk['code'])]

    if sdk['source2']:
      cxx.defines += ['META_IS_SOURCE2']
      binary.sources += [
        os.path.join(sdk['path'], 'public', 'tier0', 'memoverride.cpp'),
        os.path.join(sdk['path'], 'tier1', 'convar.cpp'),
      ]

    SdkHelpers.configureCxx(context, binary, sdk)

    cxx.linkflags += additionalLibs(context, binary, sdk)
    cxx.defines += additionalDefines(context, binary, sdk)
    cxx.cxxincludes += additionalIncludes(context, binary, sdk)
    context.AddConfigureFile(os.path.join(context.currentSourcePath, 'plugin-metadata.json'))

    return binary

MMSPlugin = MMSPluginConfig()
MMSPlugin.detectSDKs()
MMSPlugin.configurePluginMetadata()
MMSPlugin.configure()

if os.name == 'posix' and not hasLLDLinker():
  ConsoleRed(sys.stdout)
  print('[!] LLD linker not found, using systems default.')
  print('[!] This might result in a binary that is not unloadable at runtime and will potentially cause issues/crashes!')
  print('[!] Please do install LLD linker for better binary stability. (apt install lld)')
  ConsoleNormal(sys.stdout)

BuildScripts = [
  'AMBuilder',
  'PackageScript',
]

builder.Build(BuildScripts, { 'MMSPlugin': MMSPlugin })
