#!/usr/bin/env python2.7

# RaidGuessFS, a FUSE pseudo-filesystem to guess RAID parameters of a damaged device
# Copyright (C) 2015 Ludovic Pouzenc <ludovic@pouzenc.fr>
#
# Inspired by various python-fuse examples :
#  hello.py
#    Copyright (C) 2006  Andrew Straw  <strawman@astraw.com>
#
#  nullfs.py
#    Copyright (C) 2001  Jeff Epler  <jepler@unpythonic.dhs.org>
#    Copyright (C) 2006  Csaba Henk  <csaba.henk@creo.hu>
#
#  templatefs.py
#    Copyright (c) 2009 Matt Giuca
#
# Since they use LGPL and BSD licence, I choose GPLv3 (seems okay)
# This file is part of RaidGuessFS.
#
# RaidGuessFS 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.
#
# RaidGuessFS 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 RaidGuessFS. If not, see <http://www.gnu.org/licenses/>

# Standard modules
import sys, os, errno, logging, re, fuse
# Custom modules
import mystat, mydisks, mybinview, myraid, mytasks

if not hasattr(fuse, '__version__'):
    raise RuntimeError, \
        "your fuse-py doesn't know of fuse.__version__, probably it's too old."


class RaidGuessFS(fuse.Fuse):
    """Main class, implementing the actual pseudo-filesystem"""

    def __init__(self, *args, **kwargs):
        logging.info("Initializing filesystem")
        logging.debug("Enter RaidGuessFS.__init__()")
        super(RaidGuessFS,self).__init__(*args, **kwargs)

        # Early initialisations (notably modules and the static directory tree part)
        self.d = mydisks.MyDisks()
        self.raid = myraid.MyRaid()
        self.bmp = mybinview.MyBinView()
        self.st = mystat.MyStat()
        self.tasks = mytasks.MyTasks(self.d)

        self.bmp.refresh_bmp()

        self.dattr = { '/': self.st.make_fake_dir() }
        self.fattr = { '/fsinit_has_failed': self.st.make_fake_file(0) }

        self.settings = [
                'disk_count',
                'raid_start', 'raid_end', 'raid_chunk_size',
                'raid_disk_order', 'raid_layout',
                'bmp_height', 'bmp_width', 'bmp_start_offset',
                'task_start', 'task_kill', 'task_find_files_pathlist'
                ]

        self.settings_getters = [
                self.d.get_disk_count,
                self.raid.get_raid_start, self.raid.get_raid_end, self.raid.get_raid_chunk_size,
                self.raid.get_raid_disk_order_str, self.raid.get_raid_layout,
                self.bmp.get_bmp_height, self.bmp.get_bmp_width, self.bmp.get_bmp_start_offset,
                self.get_task_start, self.get_task_kill, self.tasks.get_find_files_pathlist_str,
                ]

        self.settings_updaters = [
                self.update_disk_count,
                self.update_raid_start, self.update_raid_end, self.update_raid_chunk_size, self.update_raid_disk_order,
                self.raid.set_raid_layout,
                self.update_bmp_height, self.update_bmp_width, self.update_bmp_start_offset,
                self.tasks.task_start, self.tasks.task_kill, self.tasks.append_find_files_pathlist
                ]


        self.dentries = {
                '/'      : [ fuse.Direntry(name) for name in ['config','disk','raid','tasks','visual'] ],
                '/config': [ fuse.Direntry(name) for name in self.settings ],
                '/raid'  : [ fuse.Direntry(name) for name in myraid.MyRaid.RAID_TYPES ],
                '/tasks' : [ fuse.Direntry(name) for name in self.tasks.TASK_NAMES ],
                '/disk'  : [ ], # Filled in _refresh_disk_dentries()
                '/visual': [ ], # Filled in _refresh_disk_dentries()
        }
        
        for raid_type in myraid.MyRaid.RAID_TYPES:
            self.dentries.update( {
                '/raid/%s'%raid_type: [ fuse.Direntry(name) for name in ['disk_parity','disk_xor','raid_result'] ],
                }
                )
        logging.debug("Exit. RaidGuessFS.__init__()")

    def _refresh_disk_dentries(self):
        """Internal function to update directory entries about all disks"""
        logging.debug("Enter _refresh_disk_dentries()")
        disk_dentries = []
        visual_dentries = []
        for d in range(self.d.disk_count):
            st_img = self.st.make_fake_file(self.d.disks_size[d])
            st_bmp = self.st.make_fake_file(self.bmp.bmp_size)
            self.fattr.update( {
                '/disk/disk%02d.img'%d: st_img,
                '/visual/disk%02d.bmp'%d: st_bmp,
                    }
                ) 
            disk_dentries.append( fuse.Direntry('disk%02d.img'%d) )
            visual_dentries.append( fuse.Direntry('disk%02d.bmp'%d) )

        self.dentries.update( { '/disk': disk_dentries } )
        self.dentries.update( { '/visual': visual_dentries } )
        logging.debug("Exit. _refresh_disk_dentries()")

    def _refresh_raid_fattr(self):
        """Update the raid computed attributes after a config change"""
        logging.debug("Enter _refresh_raid_fattr()")

        for raid_type in myraid.MyRaid.RAID_TYPES:
            self.fattr['/raid/%s/disk_parity'%raid_type].st_size = self.raid.sizeof_disk_parity(raid_type)
            self.fattr['/raid/%s/disk_xor'   %raid_type].st_size = self.raid.sizeof_disk_xor   (raid_type)
            self.fattr['/raid/%s/raid_result'%raid_type].st_size = self.raid.sizeof_raid_result(raid_type)

        logging.debug("Exit. _refresh_raid_fattr()")

    def _split_path(self,path):
        """Internal function to explode path for read() and write() calls"""
        m = re.match('/([^/]+)/([^./0-9]*)([0-9+]*)(\.[^/]+)?(?:/([^/]+))?$', path)
        if m:
            return m.groups()
        else:
            return []

    def _aton(self,arg,allow_zero=True):
        i = int(arg)
        if allow_zero:
            if i < 0:
                raise ValueError("Negative value make no sense here")
        else:
            if i <= 0:
                raise ValueError("Non-positive value make no sense here")
        return i

    def update_disk_count(self,arg):
        i = self._aton(arg)
        self.d.set_disk_count(i)
        self.d.open_disks()
        self._refresh_disk_dentries()
        self.raid.set_disks(self.d)
        self.raid.set_raid_end(min(self.d.disks_size)-1)
        self.update_raid_disk_order(range(i))

    def update_bmp_start_offset(self, arg):
        i = self._aton(arg)
        self.bmp.set_bmp_start_offset(i)
        self.bmp.refresh_bmp()
        self._refresh_disk_dentries()

    def update_bmp_width(self, arg):
        i = self._aton(arg,False)
        self.bmp.set_bmp_width(i)
        self.bmp.refresh_bmp()
        self._refresh_disk_dentries()

    def update_bmp_height(self, arg):
        i = self._aton(arg,False)
        self.bmp.set_bmp_height(i)
        self.bmp.refresh_bmp()
        self._refresh_disk_dentries()

    def update_raid_start(self, arg):
        i = self._aton(arg)
        self.raid.set_raid_start(i)
        self._refresh_raid_fattr()

    def update_raid_end(self, arg):
        i = self._aton(arg)
        self.raid.set_raid_end(i)
        self._refresh_raid_fattr()

    def update_raid_chunk_size(self, arg):
        i = self._aton(arg,False)
        self.raid.set_raid_chunk_size(i)

    def update_raid_disk_order(self, arg):
        logging.debug("Enter update_raid_disk_order(%s)"%arg)
        if type(arg) is str:
            l = map(int,arg.split())
        elif type(arg) is list:
            l = arg
        else:
            raise TypeError('update_raid_disk_order() wants a list or str')
        logging.debug("==> %s (%d/%d)"%(l,len(l),self.d.disk_count))

        self.raid.set_raid_disk_order(l)
        self._refresh_raid_fattr()
        logging.debug("Exit. update_raid_disk_order(%s)"%arg)

    def get_task_start(self):
        return 'Write a task_name in this pseudo-file to start it\n'
    
    def get_task_kill(self):
        return 'Write a task_name in this pseudo-file to kill it\n'


########################################################
# Actual File System operations implementation follows #
########################################################

    def fsinit(self):
        """Make some run-time initalisations after argument parsing"""
        logging.info("Mounting filesystem...")
        # WARNING : this method is called by FUSE in a context that don't show fatal exceptions,
        # even with -d[ebug] flag set, so log all exceptions
        try:
            self.dattr = {
                path: self.st.make_fake_dir() for path in self.dentries.keys()
            }
            self.fattr = {
                '/config/%s'%s: self.st.make_fake_file(4096,0666) for s in self.settings
            }

            self.fattr.update( {
                '/tasks/%s'%s: self.st.make_fake_file(4096) for s in self.tasks.TASK_NAMES
            })

            for raid_type in myraid.MyRaid.RAID_TYPES:
                self.fattr.update( {
                    '/raid/%s/disk_parity'%raid_type: self.st.make_fake_file(0),
                    '/raid/%s/disk_xor'%raid_type: self.st.make_fake_file(0),
                    '/raid/%s/raid_result'%raid_type: self.st.make_fake_file(0),
                })


            self.d.set_disks_path([getattr(self.parser.values,'disk%02d'%d) for d in range(self.d.max_disks)])
            self.update_disk_count(len(self.d.disk_paths))

            self._refresh_disk_dentries()
            self._refresh_raid_fattr()

            logging.info("Mounted.")
        except Exception as e:
            logging.exception(e)

    def fsdestroy(self):
        #TODO Kill tasks and see why some python processes stay alive on fusermount -u
        return
    
    def getattr(self, path):
        logging.info("getattr: %s" % path)
        res = self.dattr.get(path) or self.fattr.get(path) or -errno.ENOENT
        logging.debug("==> " + str(res))
        return res

    def fgetattr(self, path, fh=None):
        #logging.debug("fgetattr: %s (fh %s)" % (path, fh))
        return self.getattr(path)
    
    def readdir(self, path, offset, dh=None):
        logging.info("readdir: %s (offset %s, dh %s)" % (path, offset, dh))
        return self.dentries[path]

    def truncate(self, path, size):
        logging.info("truncate: %s (size %s)" % (path, size))
        
        if path == '/config/task_find_files_pathlist':
            self.tasks.set_find_files_pathlist([])
        elif path.startswith('/config/'):
            return # Ignore truncates on others pseudo config files
        else:
            return -errno.EOPNOTSUPP

    def ftruncate(self, path, size, fh=None):
        logging.info("ftruncate: %s (size %s, fh %s)" % (path, size, fh))
        return self.truncate(path, size)

    def read(self, path, size, offset, fh=None):
        logging.info("read: %s (size %s, offset %s, fh %s)" % (path, size, offset, fh))

        path_chuncks = self._split_path(path)
        if path_chuncks:
            try:
                if path_chuncks[0] == 'config':
                    # TODO take care here
                    idx = self.settings.index(path_chuncks[1])
                    return str(self.settings_getters[idx]()) + "\n"

                if path_chuncks[0] == 'disk':
                    if path_chuncks[1] == 'disk':
                        i = int(path_chuncks[2])
                        if 0 <= i <= self.d.disk_count:
                            if path_chuncks[3] == '.img':
                                return self.d.read(i,offset,size)
                            if path_chuncks[3] == '.bmp':
                                return self.bmp.read(self.d.disks[i],offset,size)

                if path_chuncks[0] == 'visual':
                    if path_chuncks[1] == 'disk':
                        i = int(path_chuncks[2])
                        if 0 <= i <= self.d.disk_count:
                            if path_chuncks[3] == '.bmp':
                                return self.bmp.read(self.d.disks[i],offset,size)

                if path_chuncks[0] == 'raid':
                    raid_type=path_chuncks[2]
                    if raid_type in myraid.MyRaid.RAID_TYPES:
                        if path_chuncks[4] == 'disk_parity':
                            return self.raid.read_disk_parity(raid_type,offset,size)
                        if path_chuncks[4] == 'disk_xor':
                            return self.raid.read_disk_xor(raid_type,offset,size)
                        if path_chuncks[4] == 'raid_result':
                            return self.raid.read_raid_result(raid_type,offset,size)

                if path_chuncks[0] == 'tasks':
                    if path_chuncks[1] == 'find_bootsect':
                        return self.tasks.read_find_bootsect()[offset:offset+size]
                    if path_chuncks[1] == 'find_files':
                        return self.tasks.read_find_files()[offset:offset+size]
                    
            except Exception as e:
                logging.exception(e)
                return -errno.ENOENT

        logging.error("Unimplemented read of '%s' (%s)"%(path, str(path_chuncks)))
        return -errno.ENOENT

    def write(self, path, buf, offset, fh=None):
        logging.info("write: %s (offset %s, fh %s)" % (path, offset, fh))

        path_chuncks = self._split_path(path)
        if path_chuncks:
            try:
                if path_chuncks[0] == 'config':
                    # TODO take care here
                    idx = self.settings.index(path_chuncks[1])
                    try:
                        self.settings_updaters[idx](buf.rstrip())
                        return len(buf)
                    except Exception as e:
                        logging.exception(e)
                        return -errno.EIO

            except Exception as e:
                logging.exception(e)
                return -errno.ENOENT

        logging.error("Unimplemented write of '%s' (%s)"%(path, str(path_chuncks)))
        return -errno.ENOENT


def main():
    usage = fuse.Fuse.fusage + """

RaidGuessFS is a pseudo-filesystem that allows to guess parameters and disk order of a damaged RAID devices.
  Takes disk image files as arguments (defaults to ./diskNN.img).
  Could take advantage of ddrecue logs file (metadata about unreadable sectors)
"""
    fuse.fuse_python_api = (0, 2)

    LOG_FILENAME = "/tmp/raidguessfs.log"
    #logging.basicConfig(filename=LOG_FILENAME,level=logging.WARN,)
    #logging.basicConfig(filename=LOG_FILENAME,level=logging.INFO,)
    logging.basicConfig(filename=LOG_FILENAME,level=logging.DEBUG,)

    server = RaidGuessFS(version="%prog " + fuse.__version__,usage=usage,dash_s_do='setsingle')
    server.multithreaded = False

    cwd = os.getcwd()
    # TODO : only 2 parameters, but taking a parametrized string
    for num in range(server.d.max_disks):
        server.parser.add_option(
                mountopt="disk%02d"%num,
                metavar="ABS_PATH",
                default="%s/disk%02d.img"%(cwd,num),
                help="Disk #%d image file path [default: ./disk%02d.img]"%(num,num)
        )
        server.parser.add_option(
                mountopt="logf%02d"%num,
                metavar="ABS_PATH",
                default="%s/disk%02d.log"%(cwd,num),
                help="Disk #%d ddrescue log file [default: ./disk%02d.log]"%(num,num)
        )

    server.parse(errex=1)
    server.main()

if __name__ == '__main__':
    main()