#!/usr/bin/python

# Copyright C 2006 by Isilon Systems, Inc. All rights not expressly
# granted herein are reserved.
# Created in the United States of America.
# 
# This digital media is protected by U.S. and international copyright and
# intellectual property laws. Unless otherwise specified, all information
# and screens appearing as part of this digital medium, including
# software, services, documents, text, images, icons, and logos design;
# the selection, assembly, arrangement, and design thereof; and the code
# that enables its presentation, are the sole property of Isilon Systems,
# Inc.
# 
# Except as otherwise required by applicable law, no part of any
# copyrighted material may be reproduced, modified, translated,
# distributed, published, or transmitted by any means, electronic or
# mechanical, for any purpose, without the prior written consent of Isilon
# Systems. 
#  
# Isilon and Isilon Systems are registered trademarks of Isilon Systems,
# Inc.  Legion is a trademark of Isilon Systems, Inc.  All other
# trademarks and registered trademarks are the property of their
# respective holders.


import os
import sys
import subprocess
import string
import re
import errno
import logging
import logging.handlers
from logging import DEBUG, INFO, WARN, ERROR, FATAL

from isi.ui.lib.format import fmtLinsid, fmtLin, fmtSnapid
import isi.fs.lin
import isi.fs.pctl2

ZOMBIE_LOG_FILE_PATH = '/var/log/isi_zombie.log'

HEAD_SNAPID = 0xffffffffffffffff

STAT_DATA_LIN_KEY = "lins"
STAT_DATA_DINODE_KEY = "dinodes"
STAT_DATA_SHADOW_REF_KEY = "shadrefs"

STF_TEST_STR = "isi_stf_test"
STF_STAT_CMD = "stat"
STF_RMLIN_CMD = "rmlin"

TEST_THREAD_CMD = "isi_test_thread"

DEBUG_MODE = False

#UI
DIR_UNLINK_PRINT_ARGS = [
    'WARNING! path specified is a directory!',
    "performing this action will recursively delete all data under this directory!"
]
DIR_UNLINK_PROMPT = "are you sure you want to unlink this DIRECTORY? [y/N]: "


def usage():
    sys.stdout.write('''Usage:

isi_zombie --remove-lins L::x,y
Remove from the LIN table all versions of LIN L such that x <= version <= y.
This also removes the LIN L from snapshot tracking files (STFs).
Versions can be specified as decimal integers or the keyword HEAD.

isi_zombie --unlink pathname
Remove pathname from its directory. Use this only for files which are
inaccessible.

''')
    sys.exit(errno.EINVAL)

#LOGGING

syslog_formatter = logging.Formatter('%(name)s: %(message)s')

log = logging.getLogger('isi_zombie')
log.setLevel(DEBUG)

stdout_handler = logging.StreamHandler(sys.stdout)
stdout_handler.setLevel(DEBUG)

syslog_handler = logging.handlers.SysLogHandler()
syslog_handler.setLevel(DEBUG)
syslog_handler.setFormatter(syslog_formatter)

log.addHandler(stdout_handler)
log.addHandler(syslog_handler)

#helpers

def debug(msg):
    """
    if global DEBUG flag is enabled,
    logs a message at DEBUG level
    """
    if DEBUG_MODE:
        log.debug(msg)

def is_int(val, function_name):
    """
    validates function input
    """
    try:
        int(val)
    except ValueError:
        raise ValueError("%s passed invalid argument %s - not a number" %
                        (function_name, val))

def prev_snap_range(low_snap, snaps):
    """
    find the lowest snapid in the range
    will return zero if the specified snap
    is less than or equal to the lowest snap range
    """
    if low_snap == 0:
        return low_snap

    snaps.sort()
    ret = 0
    for index, snap in enumerate(snaps):
        if snap == low_snap:
            ret = snaps[index-1] if (index-1 >= 0) else 0
            break
        if snap < low_snap:
            ret = snap
        else:
            break
    return ret

#PARSE

def parseLin(s):
    s = s.replace(':', '')
    lin = string.atol(s, 16)
    return lin

def parseVersion(s):
    if s.upper() == 'HEAD':
        return HEAD_SNAPID
    try:
        #snapid of -1 is equal to HEAD
        if int(s) == -1:
            return HEAD_SNAPID
    except ValueError:
        raise ValueError("Couldn't parse LIN version %s" % s)
    return string.atol(s)

def parse_stf_lin_data(line):
    """
    Parses the text returned from an STF stat command into ints
    WARNING - stf stat output can vary from version to version
    Input:
        sample '\tLINs:\t1, DInodes:\t8, ShadRefs:\t0'
    Output:
        sample ['1']
    """
    try:
        data = re.findall(r'(?<=\:\t)\d+', line)
        lins = int(data[0])
        return[lins]
    except (ValueError, IndexError, TypeError):
        log.log(WARN, 'could not parse stat data, raw output in log file...')
        return None

def parse_stf_stat_output(stf_stat_output):
    """
    parses the raw output of stf stats
    currently only parses the LIN data
    """
    stat_data = {}
    if type(stf_stat_output) != str:
        log.log(DEBUG, "couldn't parse stf data - not a string")
    raw_lin_data = stf_stat_output.split('\n')
    for line in raw_lin_data:
        if "LINS:" in line.upper():
            #sample text '\tLINs:\t1, DInodes:\t8, ShadRefs:\t0'
            parsed_lin_data = parse_stf_lin_data(line)
            if parsed_lin_data != None and parsed_lin_data != []:
                stat_data[STAT_DATA_LIN_KEY] = parsed_lin_data[0]
            #WARNING: short return until more data is added
            break
    return stat_data

def parseRemoveLinsArgument(s):
    try:
        (lin, versions) = s.split('::')

        lin = parseLin(lin)

        if ',' in versions:
            (lowVersion, highVersion) = versions.split(',')
        else:
            lowVersion = versions
            highVersion = versions
        lowVersion = parseVersion(lowVersion)
        highVersion = parseVersion(highVersion)
    except:
        usage()
    return (lin, lowVersion, highVersion)

#UI
def print_and_prompt_user(print_args, prompt_arg):
    """
    reads user input after printing specified arguments and prompting

    Args: 
        print_args: A list of strings to print
        prompt_arg: A string to prompt:

    returns:
        True if user input begins with a "y"
        False otherwise.
    """
    for msg_line in print_args:
        print msg_line

    answer = raw_input(prompt_arg)
    if answer != '' and answer[0].upper() == "Y":
        return True
    return False 


def promptUser(action):
    sys.stdout.write('THIS MAY CAUSE FILE SYSTEM CORRUPTION!\n')
    sys.stdout.write('DO YOU REALLY WANT TO %s? [y/N]: ' % action)
    answer = sys.stdin.readline()
    return answer[0].upper() == 'Y'

#LOGIC

def getSnapshotsForLin(lin):
    """
    uses linIterate to return the snapshot RANGES for a given lin
    based from the LIN tree
    This does NOT mean that the snapshots returned here actually exist
    as snapshots on the system. attempting to look this up
    based off of this API was responsible for bug 116121

    the return value may include HEAD.
    """
    cookie = isi.fs.lin.linIterateCreate(lin, lin + 1)
    snaps = []
    done = False
    while not done:
        (cookie, listLinSnaps, pct, done) = isi.fs.lin.getLins(cookie, 64)
        for (lin, snap) in listLinSnaps:
            snaps.append(snap)
    return snaps

#SUBPROCESS

def popen_cmd(cmd):
    p = subprocess.Popen(cmd,
        stdout=subprocess.PIPE,
        stderr=subprocess.PIPE,
        close_fds=True )
    stdout, stderr = p.communicate()
    return (stdout, stderr, p.returncode)

#OPS

def stat_stf(stf_num):
    is_int(stf_num, stat_stf.__name__)
    cmd = [STF_TEST_STR, STF_STAT_CMD, str(stf_num)]
    return popen_cmd(cmd)

def lin_rem_entry(linsnap):
    """
    removes a lin range from the lin tree
    """
    (lin, snap) = linsnap
    is_int(lin, lin_rem_entry.__name__)
    is_int(snap, lin_rem_entry.__name__)
    funct = 'lin_rem_entry(%x, 0x%x)' % (lin, snap)
    cmd = [TEST_THREAD_CMD , funct]
    return popen_cmd(cmd)

def stf_rem_entry(linsnap):
    """
    removes a single lin fron an STF
    """
    (lin, snap) = linsnap
    is_int(lin, stf_rem_entry.__name__)
    is_int(snap, stf_rem_entry.__name__)
    funct = "stf_rem_file(0x%x, %x)" % (snap, lin)
    cmd = [TEST_THREAD_CMD , funct]
    return popen_cmd(cmd)

def stf_rem_file(snap, lin, last_snap):
    """
    Removes the STF form the lin range specified

    Works backwards attempting to remove the given lin from the stf
    taks the given snapshort range (inclusive) from the lin tree
    and attempts to find and remove the LIN specified from STF.

    The last snapshot is used to make sure we don't get off the rails
    in the case that the lin is in no stfs in the range, but exists in the lin tree
    if this happens, an error condition is triggered.

    the output of the stats are saved, parsed and logged

    returns the stf found in the specified range for tracking

    see bug 116121
    """
    before_stat = None
    after_stat = None
    enter_stf_log_str = "calling stf_rem_file with: snap: %s lin: %s last_snap: %s" % (snap, fmtLin(lin), last_snap)
    debug(enter_stf_log_str)
    log.info(enter_stf_log_str)
    parse_stf = True

    for current_stf in reversed(range(last_snap + 1, snap + 1)):
        stdout, stderr, ret = stat_stf(current_stf)
        if ret != 0:
            log.log(WARN,"couldn't find lin %s in stf %s, error %s zombie will keep looking..." %
                (fmtLin(lin), current_stf, stderr))
            #assume sna

            continue
        before_stat = stdout
        stdout, stderr, ret = stf_rem_entry((lin, current_stf))
        if ret != 0:
            log.log(WARN,"couldn't remove lin %s from stf %s, zombie will keep looking..." %
                (fmtLin(lin), current_stf))
            debug("stdout: %s stderr: %s return code: %s" %
                 (stdout, stderr, ret))
            continue
        stdout, stderr, ret = stat_stf(current_stf)
        after_stat = stdout
        #parsing
        if parse_stf:
            try:
                before_stf_dict = parse_stf_stat_output(before_stat)
                after_stf_dict = parse_stf_stat_output(after_stat)

                if STAT_DATA_LIN_KEY in before_stf_dict:
                    log.log(INFO, ("STF stat before: lins: %d" %
                                  before_stf_dict[STAT_DATA_LIN_KEY]))
                if STAT_DATA_LIN_KEY in after_stf_dict:
                    log.log(INFO, ("STF stat after: lins: %d" %
                                   after_stf_dict[STAT_DATA_LIN_KEY]))
                debug(before_stat)
                debug(after_stat)
            except Exception as e:
                log.info("issue parsing stat output, ignoring error: %s \n output: %s" % (e, before_stat))
                parse_stf = False
                pass # log and just Ignore stat parsing from now on
        #logging
        log.log(WARN,"removed lin %s from stf: %s!" % (fmtLin(lin) , current_stf))
        return current_stf

    err_str = ("!!!Could not find lin: %s in snap range %s before "
               "hitting lower limit of %s" % (fmtLin(lin), snap, last_snap))
    log.log(FATAL, err_str)
    sys.exit(errno.ENOENT)


#HIGH LEVEL OPS

def removeLins(lin, lowVersion, highVersion):
    lin_tree_remove_count = 0
    snaps = getSnapshotsForLin(lin)
    snaps_to_remove = [s for s in snaps if s >= lowVersion and s <= highVersion]
    last_snap = prev_snap_range(lowVersion, snaps)

    if snaps_to_remove == []:
        log.log(FATAL, "\nWARNING! found no specified versions of "
                       "lin %s - in range (%d-%d) exiting" %
                       (fmtLin(lin), lowVersion, highVersion))
        sys.exit(errno.ENOENT)

    stfs_removed = []
    for snap in snaps_to_remove:
        log.log(INFO, "attempting to remove lin: %s from the lin tree" % ( fmtLinsid( (lin, snap) ) ))
        stdout, stderr, ret = lin_rem_entry((lin, snap))
        if ret != 0:
            log.log(FATAL, ("\nWARNING! - could not remove: "
                            "%s from LIN tree!" % fmtLinsid((lin, snap)) ) )
            log.log(FATAL, "stdout: %s stderr: %s return code: %s" %
                  (stdout, stderr, ret))
            sys.exit(errno.ENOENT) #ENOENT
        log.log(INFO, "removed %s from LIN tree" % ( fmtLinsid( (lin, snap)) ))
        if snap != HEAD_SNAPID:
            stf_removed = stf_rem_file(snap, lin, last_snap)
            stfs_removed.append(stf_removed)
            last_snap = snap
        lin_tree_remove_count = lin_tree_remove_count + 1
    log.log(INFO, "removed lin %s from stfs: %s" % (fmtLin(lin), stfs_removed))
    log.log(WARN, "removed %d LIN tree entries" % lin_tree_remove_count)

def unlink(pathname):
    fd = None
    (dirname, entryname) = os.path.split(pathname)
    if not dirname:
        dirname = '.'

    #confirm with user if is a directory.
    if os.path.isdir(pathname):
        if not DEBUG_MODE:
            ans = print_and_prompt_user(DIR_UNLINK_PRINT_ARGS, DIR_UNLINK_PROMPT) 
            if ans == True:
                log_str = "user confirmed unlink of directory %s" % pathname
                log.log(WARN, log_str)
            else:
                log_str = "user canceled unlink of directory %s at prompt" % pathname
                log.log(INFO, log_str)
                print "Exiting without performing unlinking..."
                sys.exit(0)
        else:
            log_str = "Debug check skipped directory unlink prompt on directory %s" % pathname
            log.log(WARN, log_str)
    
    #attempt to open parent dir
    try:
        fd = os.open(dirname, os.O_RDONLY)
    except Exception as e:
        log.log(FATAL, "\nZombie failed unlink of file: %s " 
            "because it can not open parent directory: %s" % (pathname, dirname))
        sys.exit(1)

    #performing the unlink
    log.log(WARN, "Unlinking: %s" % pathname)
    try:        
        isi.fs.pctl2.zombieUnlink(fd, entryname)
    except Exception as e:
        log_str = "Zombie could not to unlink file %s\nerror: %s" % (pathname, e)
        log.log(FATAL, log_str)
        print "Exiting without performing unlinking..."
        sys.exit(errno.ENOENT)
    finally:
        os.close(fd)
    log_str = "Successfully completed unlink of file %s from parent directory %s" % (pathname, dirname)
    log.log(WARN, log_str)

#MAIN

def main(argv):
    #TODO real arg parsing
    if argv[-1].upper() == "--DEBUG":
        argv.pop()
        global DEBUG_MODE
        DEBUG_MODE = True
    if DEBUG_MODE:
        print "running in debug mode"
    if len(argv) == 3 and argv[1] == '--remove-lins':
        (lin, lowVersion, highVersion) = parseRemoveLinsArgument(argv[2])
        if promptUser('DELETE LIN %s VERSIONS %s...%s' % (fmtLin(lin), fmtSnapid(lowVersion),
                                                          fmtSnapid(highVersion))):

            log.log(WARN, "user specified zombie of lin: %s snaps: %s - %s" % (fmtLin(lin),
                fmtSnapid(lowVersion), fmtSnapid(highVersion)))

            removeLins(lin, lowVersion, highVersion)

            log.log(WARN, "completed zombie of lin: %s snaps: %s - %s" % (fmtLin(lin),
                fmtSnapid(lowVersion), fmtSnapid(highVersion)))

    elif len(argv) == 3 and argv[1] == '--unlink':
        pathname = argv[2]
        if promptUser('REMOVE %s FROM ITS PARENT DIRECTORY' % pathname):
            unlink(pathname)
    else:
        usage()

if __name__ == '__main__':
    main(sys.argv)
