#!/usr/bin/python3
# -*- coding: utf-8 -*-
#
# Copyright (C) 2011-2014 Julien Muchembled <jm@jmuchemb.eu>
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License version 3 as
# published by the Free Software Foundation.
#
# 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/>.

import errno, imp, os, random, shutil, stat, tempfile, unittest
from collections import defaultdict, deque
fssync = imp.new_module('fssync')
exec(compile(open('fssync').read(), os.path.realpath('fssync'), 'exec'),
     fssync.__dict__)

# XXX: http://braawi.org/genbackupdata/ (packaged by Debian) may help.

class Stat(fssync.Stat):

  _fake_dev = set()
  _fake_ino = {}
  _last_ino = 0

  def __init__(self, path):
    super(Stat, self).__init__(path)
    if self.key in self._fake_dev:
      self.dev += 1
    try:
      self.ino = self._fake_ino[path]
    except KeyError:
      pass

  @classmethod
  def fake_dev(cls, path):
    cls._fake_dev.add(cls.__bases__[0](path).key)

  @classmethod
  def reset_dev(cls, path=None):
    if path:
      cls._fake_dev.remove(cls.__bases__[0](path).key)
    else:
      cls._fake_dev.clear()

  @classmethod
  def set_ino(cls, path, ino=None):
    if ino is None:
      # negative number to not conflict with real inodes
      cls._last_ino = ino = cls._last_ino - 1
    cls._fake_ino[os.path.realpath(path)] = ino
    return ino

  @classmethod
  def reset_ino(cls):
    cls._fake_ino.clear()
    cls._last_ino = 0

  @classmethod
  def _lstat(cls, name, **kw):
    s = os.lstat(name, **kw)
    return type(s)(s[:2] + (-1,) + s[3:]) \
      if (s.st_dev, s.st_ino) in cls._fake_dev else s

fssync.Stat = Stat


class DummyRpcClient(object):

  def __init__(self, remote):
    self.remote = remote
    self._rpc = deque()
    self.called = defaultdict(int)

  def wait(self):
    name, args, kw = self._rpc.popleft()
    self.called[name] += 1
    if isinstance(args[0], bytes):
      args = (os.path.join(self.remote.root, args[0]),) + args[1:]
    return getattr(self.remote, name)(*args, **kw)

  def __getattr__(self, name):
    append = self._rpc.append
    f = lambda *args, **kw: append((name, args, kw))
    setattr(self, name, f)
    return f


class Local(fssync.Local):

  prealloc = True

  def __init__(self):
    super(Local, self).__init__(fssync.encode(tempfile.mkdtemp()), ':memory:',
                                DummyRpcClient(Remote()))

  def __del__(self):
    shutil.rmtree(self.root)
    super(Local, self).__del__()
    Stat.reset_ino()

  def is_masked(self, path):
    return super(Local, self).is_masked(path, Stat._lstat)

  @property
  def remote(self):
    return self.rpc.remote


class Remote(fssync.Remote):

  def __init__(self):
    super(Remote, self).__init__(fssync.encode(tempfile.mkdtemp()))

  def __del__(self):
    shutil.rmtree(self.root)


def gen_data(size, __blob=bytes(int(random.gauss(0, .8)) % 256
                                for x in range(100000))):
  i = random.randrange(len(__blob) - size)
  return __blob[i:i+size]


class Test(unittest.TestCase):

  os_listdir = staticmethod(os.listdir)

  def setUp(self):
    self.fssync = Local()
    os.chdir(self.fssync.root)
    os.listdir = lambda p: sorted(self.os_listdir(p))

  def tearDown(self):
    os.listdir = self.os_listdir
    del self.fssync

  def mkreg(self, path, size=0, sparse_map=0, mode=None):
    d = os.path.dirname(path)
    if d and not os.path.exists(d):
      os.makedirs(d)
    with open(path, 'wb') as f:
      f.write(gen_data(size))
    if mode is not None:
      os.chmod(path, mode)

  def assertSynced(self):
    isdir = os.path.isdir
    join = os.path.join
    dst_root = self.fssync.remote.root
    for src, names, files in os.walk(b'.'):
      dst = dst_root + src[1:]
      names = set(names).union(files)
      self.assertEqual(names, set(os.listdir(dst)))
      for name in names:
        src_path = join(src, name)
        dst_path = join(dst, name)
        s = Stat(src_path)
        try:
          d = Stat(dst_path)
        except FileNotFoundError as e:
          self.fail(str(e))
        self.assertEqual(s.value, d.value, name)
        fmt = stat.S_IFMT(s.mode)
        if fmt == stat.S_IFLNK:
          self.assertEqual(os.readlink(src_path), os.readlink(dst_path), name)
        elif fmt == stat.S_IFREG:
          with open(src_path, 'rb') as s:
            with open(dst_path, 'rb') as d:
              self.assertEqual(s.read(), d.read(), name)

  def assertNotSynced(self):
    self.assertRaises(self.failureException, self.assertSynced)

  @property
  def called(self):
    return self.fssync.rpc.called

  def sync(self, clean=True, synced=True):
    self.fssync.sync(b'')
    if clean:
      self.fssync.clean(b'')
    del self.fssync.masked[:]
    (self.assertSynced if synced else self.assertNotSynced)()

  def test1(self):
    import xml
    shutil.copytree(fssync.encode(os.path.dirname(xml.__file__)),
                    b'a', symlinks=True)
    self.assertNotSynced()
    self.sync()
    self.assertEqual(sorted(self.called), ['check_data', 'sync_data',
                                           'sync_meta', 'truncate'])

    self.called.clear()
    self.sync()
    self.assertFalse(self.called)

    os.rename(b'a', b'b')
    self.assertNotSynced()
    self.sync()
    self.assertEqual(self.called, dict(rename=1, sync_meta=1))

    self.called.clear()
    os.rename(b'b', b'c')
    Stat.set_ino(b'c')
    self.sync()
    self.assertEqual(sorted(self.called), ['link', 'removemany',
                                           'rename', 'sync_meta'])

  def test2(self):
    self.mkreg(b'a')
    os.mkdir(b'b')
    os.link(b'a', b'b/c')
    self.sync()
    self.assertEqual(self.called, dict(link=1, sync_meta=3))

    self.called.clear()
    Stat.fake_dev(b'b')
    self.sync()

    os.rename(b'a', b'c')
    self.assertRaises(SystemExit, self.sync)
    self.assertFalse(self.called)

    Stat.reset_dev(b'b')
    self.sync()
    self.assertEqual(self.called, dict(link=1, removemany=1, sync_meta=1))

    self.called.clear()
    os.link(b'c', b'd')
    self.fssync.remote.removemany([b'b/c'])
    self.sync(synced=False)
    self.sync()
    self.assertEqual(self.called, dict(link=3, sync_meta=2))

    self.called.clear()
    os.rename(b'b', b'a')
    shutil.rmtree(self.fssync.remote.root + b'/b')
    self.sync()
    self.assertEqual(self.called, dict(link=1, rename=1, sync_meta=2))


if __name__ == '__main__':
  unittest.main()
