from __future__ import annotations

import os
import subprocess
from pathlib import Path
from typing import (
  Optional, Tuple, List, Union, Dict, TYPE_CHECKING, Any,
)
import logging
from functools import lru_cache
import traceback
import string
import time
from contextlib import suppress

import structlog

from .vendor.github import GitHub

from .mail import MailService
from .packages import get_built_package_files
from .tools import ansi_escape_re, has_pacfiles
from . import lilacyaml, intl
from .typing import LilacMod, Maintainer, LilacInfos, LilacInfo
from .nomypy import BuildResult # type: ignore
if TYPE_CHECKING:
  from .packages import Dependency
  del Dependency

logger = logging.getLogger(__name__)
build_logger_old = logging.getLogger('build')
build_logger = structlog.get_logger(logger_name='build')

class Repo:
  gh: Optional[GitHub]

  def __init__(self, config: dict[str, Any]) -> None:
    self.myaddress = config['lilac']['email']
    self.mymaster = config['lilac']['master']
    self.logurl_template = config['lilac'].get('logurl')
    self.repomail = config['repository']['email']
    self.name = config['repository']['name']
    self.trim_ansi_codes = not config['smtp'].get('use_ansi', False)
    self.commit_msg_prefix = config['lilac'].get('commit_msg_prefix', '')
    self.user_agent = config['lilac'].get('user_agent')

    self.repodir = Path(config['repository']['repodir']).expanduser()
    self.bindmounts = config.get('bindmounts', [])
    self.tmpfs = config.get('misc', {}).get('tmpfs', [])

    self.ms = MailService(config)
    github_token = config['lilac'].get('github_token')
    if github_token:
      self.gh = GitHub(github_token)
    else:
      self.gh = None

    self.on_built_cmds = config.get('misc', {}).get('postbuild', [])

    self.lilacinfos: LilacInfos = {}  # to be filled by self.load_managed_lilac_and_report()
    self.yamls: dict[str, Any] = {}
    self._maint_cache: dict[str, list[Maintainer]] = {}

  @lru_cache()
  def maintainer_from_github(self, username: str) -> Optional[Maintainer]:
    if self.gh is None:
      l10n = intl.get_l10n('mail')
      msg = l10n.format_value('github-token-not-set')
      raise ValueError(msg)

    userinfo = self.gh.get_user_info(username)
    if userinfo['email']:
      return Maintainer(userinfo['name'] or username, userinfo['email'], username)
    else:
      return None

  def parse_maintainers(
    self,
    ms: List[Dict[str, str]],
  ) -> Tuple[List[Maintainer], List[str]]:
    ret = []
    errors = []

    l10n = intl.get_l10n('mail')
    for m in ms:
      if 'github' in m and 'email' in m:
        ret.append(
          Maintainer.from_email_address(m['email'], m['github'])
        )
      elif 'github' in m:
        try:
          u = self.maintainer_from_github(m['github'])
        except Exception as e:
          msg = l10n.format_value('github-email-error', {'error': repr(e)})
          errors.append(msg)
        else:
          if u is None:
            msg = l10n.format_value('github-email-private', {'user': m['github']})
            errors.append(msg)
          else:
            ret.append(u)
      else:
        logger.error('unsupported maintainer info: %r', m)
        msg = l10n.format_value('unsupported-maintainer-info', {'info': repr(m)})
        errors.append(msg)
        continue

    return ret, errors

  def find_dependents(
    self, pkgbase: str,
  ) -> List[str]:
    if self.lilacinfos:
      return self._find_dependents_heavy(pkgbase)
    else:
      return self._find_dependents_lite(pkgbase)

  def _find_dependents_heavy(
    self, pkgbase: str,
  ) -> List[str]:
    '''find_dependents for main process'''
    ret = []

    for info in self.lilacinfos.values():
      ds = info.repo_depends
      if any(x == pkgbase for x, y in ds):
        ret.append(info.pkgbase)

    return ret

  def _find_dependents_lite(
    self, pkgbase: str,
  ) -> List[str]:
    '''find_dependents for worker process'''
    ret = []
    self._load_yamls_ignore_errors()

    for p, yamlconf in self.yamls.items():
      ds = yamlconf.get('repo_depends', ())
      if any(x == pkgbase for x, y in ds):
        ret.append(p)

    return ret

  def _load_yamls_ignore_errors(self) -> None:
    if self.yamls:
      return

    for dir in lilacyaml.iter_pkgdir(self.repodir):
      try:
        yamlconf = lilacyaml.load_lilac_yaml(dir)
      except Exception:
        pass
      else:
        self.yamls[dir.name] = yamlconf

  def find_maintainers(
    self, mod: Union[LilacInfo, LilacMod],
    fallback_git: bool = True,
  ) -> List[Maintainer]:
    if mod.pkgbase not in self._maint_cache:
      mts = self._find_maintainers_impl(
        mod.pkgbase,
        maintainers = getattr(mod, 'maintainers', None),
        fallback_git = fallback_git,
      )
      self._maint_cache[mod.pkgbase] = mts
    return self._maint_cache[mod.pkgbase]

  def _find_maintainers_impl(
    self,
    pkgbase: str,
    maintainers: Optional[List[Dict[str, str]]],
    fallback_git: bool = True,
  ) -> List[Maintainer]:
    ret: List[Maintainer] = []
    errors: List[str] = []

    if maintainers is not None:
      if maintainers:
        ret, errors = self.parse_maintainers(maintainers)
      else:
        dependents = self.find_dependents(pkgbase)
        for pkg in dependents:
          if self.lilacinfos:
            maintainers = self.lilacinfos[pkg].maintainers
          else:
            maintainers = self.yamls[pkg].get('maintainers')
          dmaints = self._find_maintainers_impl(
            pkg, maintainers, fallback_git=False,
          )
          ret.extend(dmaints)

    if (not ret and fallback_git) or errors:
      # fallback to git
      dir = self.repodir / pkgbase
      git_maintainer = self.find_maintainer_by_git(dir)

    if errors:
      error_str = '\n'.join(errors)
      l10n = intl.get_l10n('mail')
      self.sendmail(
        git_maintainer,
        subject = l10n.format_value('maintainers-error-subject', {'pkg': pkgbase}),
        msg = l10n.format_value('maintainers-error-body') + f'\n\n{error_str}\n',
      )

    if not ret and fallback_git:
      logger.warning("lilac doesn't give out maintainers for %s, "
                     "fallback to git.", pkgbase)
      return [git_maintainer]
    else:
      return ret

  def find_maintainer_by_git(
    self,
    dir: Path = Path('.'),
    file: str = '*',
  ) -> Maintainer:

    me = self.myaddress

    cmd = [
      "git", "log", "--format=%H %an <%ae>", "--", file,
    ]
    p = subprocess.Popen(
      cmd, stdout=subprocess.PIPE, universal_newlines=True,
      cwd = dir,
    )

    try:
      stdout = p.stdout
      assert stdout
      while True:
        line = stdout.readline()
        if not line:
          logger.error('history exhausted while finding maintainer, stop.')
          raise Exception('maintainer cannot be found')
        commit, author = line.rstrip().split(None, 1)
        if me not in author:
          return Maintainer.from_email_address(author)
    finally:
      p.terminate()

  def report_error(self, subject: str, msg: str) -> None:
    self.ms.sendmail(self.mymaster, subject, msg)

  def send_error_report(
    self,
    mod: Union[LilacInfo, LilacMod, str], *,
    msg: Optional[str] = None,
    exc: Optional[Exception] = None,
    subject: Optional[str] = None,
    logfile: Optional[Path] = None,
  ) -> None:
    '''
    the mod argument can be a LilacInfo, or LilacMod (for worker), or a str in case the module cannot be loaded,
    in that case we use git to find a maintainer.
    '''
    if msg is None and exc is None:
      raise TypeError('send_error_report received insufficient args')

    if isinstance(mod, str):
      maintainers = [self.find_maintainer_by_git(file=mod)]
      pkgbase = mod
    else:
      maintainers = self.find_maintainers(mod)
      pkgbase = mod.pkgbase

    msgs = []
    if msg is not None:
      msgs.append(msg)

    l10n = intl.get_l10n('mail')

    if exc is not None:
      tb = ''.join(traceback.format_exception(type(exc), exc, exc.__traceback__))
      if isinstance(exc, subprocess.CalledProcessError):
        subject_real = subject or l10n.format_value('packaging-error-subprocess-subject')
        msg1 = l10n.format_value('packaging-error-subprocess', {
          'cmd': repr(exc.cmd),
          'returncode': exc.returncode,
        })
        msgs.append(msg1)
        if exc.output:
          msg1 = l10n.format_value('packaging-error-subprocess-output')
          msgs.append(msg1 + '\n\n' + exc.output)
        msg1 = l10n.format_value('packaging-error-traceback')
        msgs.append(msg1 + '\n\n' + tb)
      elif isinstance(exc, TimeoutError):
        subject_real = subject or l10n.format_value('packaging-error-timeout-subject')
      else:
        subject_real = subject or l10n.format_value('packaging-error-unknown-subject')
        msg1 = l10n.format_value('packaging-error-unknown')
        msgs.append(msg1 + '\n\n' + tb)
    else:
      if subject is None:
        raise ValueError('subject should be given but not')
      subject_real = subject

    if '%s' in subject_real:
      subject_real = subject_real % pkgbase

    if logfile:
      with suppress(FileNotFoundError):
        # we need to replace error characters because the mail will be
        # strictly encoded, disallowing surrogate pairs
        with logfile.open(errors='replace') as f:
          build_output = f.read()

        if len(build_output) > 200 * 1024:
          too_long = l10n.format_value('log-too-long')
          build_output = (
            build_output[:100 * 1024]
            + '\n\n' + too_long + '\n\n'
            + build_output[-100 * 1024:]
          )

        if build_output:
          log_header = l10n.format_value('packaging-log')
          with suppress(ValueError, KeyError): # invalid template or wrong key
            if self.logurl_template and len(logfile.parts) >= 2:
              # assume the directory name is the time stamp for now.
              logurl = string.Template(self.logurl_template).substitute(
                datetime = logfile.parts[-2],
                timestamp = int(time.time()),
                pkgbase = pkgbase,
              )
              log_header += ' ' + logurl
          msgs.append(log_header)
          msgs.append('\n' + build_output)

    msg = '\n'.join(msgs)
    if self.trim_ansi_codes:
      msg = ansi_escape_re.sub('', msg)

    addresses = [str(x) for x in maintainers]
    logger.debug('mail to %s:\nsubject: %s\nbody: %s',
                 addresses, subject_real, msg[:200])
    self.sendmail(addresses, subject_real, msg)

  def sendmail(self, who: Union[str, List[str], Maintainer],
               subject: str, msg: str) -> None:
    if isinstance(who, Maintainer):
      who = str(who)
    self.ms.sendmail(who, subject, msg)

  def send_repo_mail(self, subject: str, msg: str) -> None:
    self.ms.sendmail(self.repomail, subject, msg)

  def manages(self, dep: Dependency) -> bool:
    return dep.pkgdir.name in self.lilacinfos

  def load_managed_lilac_and_report(self) -> dict[str, tuple[str, ...]]:
    self.lilacinfos, errors = lilacyaml.load_managed_lilacinfos(
      self.repodir, use_pacfiles=has_pacfiles())
    failed: dict[str, tuple[str, ...]] = {p: () for p in errors}
    l10n = intl.get_l10n('mail')
    for name, exc_info in errors.items():
      logger.error('error while loading lilac.yaml for %s', name, exc_info=exc_info)
      exc = exc_info[1]
      if not isinstance(exc, Exception):
        raise
      self.send_error_report(name, exc=exc,
                             subject=l10n.format_value('lilac-yaml-loadding-error'))
      build_logger_old.error('%s failed', name)
      build_logger.exception('lilac.yaml error', pkgbase = name, exc_info=exc_info)

    return failed

  def on_built(self, pkg: str, result: BuildResult, version: Optional[str]) -> None:
    if not self.on_built_cmds:
      return

    package_files = [f.name for f in get_built_package_files(self.repodir / pkg)]
    env = os.environ.copy()
    env['PKGBASE'] = pkg
    env['RESULT'] = result.__class__.__name__
    env['VERSION'] = version or ''
    env['PACKAGE_FILES'] = ' '.join(package_files)

    for cmd in self.on_built_cmds:
      try:
        subprocess.check_call(cmd, env=env)
      except Exception:
        logger.exception('postbuild cmd error for %r', cmd)

