import os, time, threading
import psutil
from collections import namedtuple
import system_info_aix
import whatap.util.process_util as process_util
import whatap.util.thread_util as thread_util
import whatap.util.logging_util as logging_util
import whatap.agent.conf.configure as conf
from StringIO import StringIO
from multiprocessing import Pool, TimeoutError as _TimeoutError

from . import _DiskPerf

filesystem_blacklist = [
    "procfs"
]
def iterateLines(lines, skip=0):
    for i, l in enumerate(lines.split('\n')):
        l = l.strip()
        if i < skip or not l:
            continue
        
        yield l.split()

# SERVER-1847 Cause B/D:
# The old cache check was "cache is None or mountPoint not in cache". If a
# mount point never registered (e.g. its lsvg timed out), every lookup of it
# rebuilt the WHOLE map (lspv + one lsvg per VG). Since both contains() and
# get() look up every mount point each cycle, one collection cycle exploded
# into O(mountpoints x VG) subprocess calls.
#
# New approach: build the whole map once and keep it for a TTL. Lookups never
# trigger a rebuild by themselves; a missing mount point simply returns None
# (which acts as a negative cache) until the TTL expires. On build failure
# (lspv/lsvg timeout) we back off for a shorter window instead of retrying
# every lookup, and a circuit breaker (see below) can disable IO collection
# entirely after repeated failures.
_MOUNT_CACHE_TTL_SEC = 600
_MOUNT_CACHE_FAIL_BACKOFF_SEC = 60

_mountPointCacheEx = None
_mountCacheBuiltAt = 0
_mountCacheFailedAt = 0


class _DiskIoCircuitBreaker:
    # SERVER-1847 Task D: if disk IO topology collection keeps timing out,
    # stop attempting it for a cooldown period so a hung SAN cannot keep
    # spawning blocking subprocesses. State transitions are logged.
    def __init__(self, threshold=3, cooldown=600):
        self.threshold = threshold
        self.cooldown = cooldown
        self.consecutiveFailures = 0
        self.openedAt = 0
        self.opened = False
        self.halfOpen = False

    def recordSuccess(self):
        if self.opened or self.halfOpen:
            logging_util.info("[disk_aix] disk IO circuit breaker reset (recovered)")
        self.consecutiveFailures = 0
        self.opened = False
        self.halfOpen = False
        self.openedAt = 0

    def recordFailure(self):
        self.consecutiveFailures += 1
        if self.halfOpen:
            # A half-open trial failed: reopen immediately for another cooldown
            # instead of waiting for the failure count to climb again.
            self.halfOpen = False
            self.openedAt = time.time()
            logging_util.error(
                "[disk_aix] disk IO circuit breaker re-OPEN after failed trial; "
                "disabling disk IO collection for {}s".format(self.cooldown))
            return
        if not self.opened and self.consecutiveFailures >= self.threshold:
            self.opened = True
            self.openedAt = time.time()
            logging_util.error(
                "[disk_aix] disk IO circuit breaker OPEN after {} consecutive failures; "
                "disabling disk IO collection for {}s".format(
                    self.consecutiveFailures, self.cooldown))

    def allowed(self):
        if not self.opened:
            return True
        if time.time() - self.openedAt >= self.cooldown:
            # half-open: allow a single trial but keep the breaker "opened".
            # recordSuccess() closes it; recordFailure() reopens it right away.
            if not self.halfOpen:
                logging_util.info("[disk_aix] disk IO circuit breaker half-open; retrying disk IO collection")
                self.halfOpen = True
            return True
        return False


_diskIoCircuitBreaker = _DiskIoCircuitBreaker()


# SERVER-1847: while disk IO is unavailable the _DiskPerf IO fields stay at
# their 0 default. TaskInfraFileSystem (infra category) reports them as null so
# a hung SAN is not mistaken for an idle disk; TaskDisk (server category) still
# sends 0 because its pack schema is fixed and dropping fields needs a server
# side change. Either way operators need to know WHY the values are missing, so
# log the reason - throttled, because
# updateDiskPerf runs every aix.diskstat.interval seconds (default 5s) and an
# unthrottled message would flood the log for the whole degraded period.
_IO_UNAVAILABLE_LOG_INTERVAL = 60
_ioUnavailableLoggedAt = 0


def _logIoUnavailable(reason):
    global _ioUnavailableLoggedAt

    now = time.time()
    if now - _ioUnavailableLoggedAt < _IO_UNAVAILABLE_LOG_INTERVAL:
        return
    _ioUnavailableLoggedAt = now
    logging_util.info(
        "[disk_aix] disk IO metrics not collected ({}); IO fields are reported "
        "as null (infra category) / 0 (server category) until collection "
        "resumes".format(reason))


def _getDiskCmdTimeout():
    # Coerce to a number so a bad conf value (e.g. non-numeric string) cannot
    # later blow up thread.join(timeout); fall back to a safe default.
    try:
        timeout = float(conf.GetConfig().DiskCmdTimeout)
        if timeout > 0:
            return timeout
    except Exception, e:
        logging_util.error("Disk cmd timeout conf read error:", e)
    return 5


def _buildMountPointCacheEx():
    # Build the full mountpoint -> [deviceId] map. Returns (cache, complete):
    #   cache    - dict, or None if the build failed outright (lspv failed, or
    #              every lsvg failed so nothing could be learned).
    #   complete - False if any lsvg lookup timed out, so the caller can retry
    #              sooner instead of trusting a partial map for the full TTL.
    # Bounded to lspv once + at most one lsvg per VG.
    timeout = _getDiskCmdTimeout()
    lspv = process_util.executeCommandShellWithTimeout("lspv", timeout=timeout)
    if lspv == "":
        return None, False

    # Group physical volumes by VG so each VG is queried with lsvg only once
    # (multiple PVs commonly share a VG). A mountpoint maps to every PV that
    # backs its VG, which matches the previous per-PV behaviour.
    vgToDevices = {}
    vgOrder = []
    for l in lspv.split('\n'):
        l = l.strip()
        if not l or len(l.split()) != 4:
            logging_util.info("lspv skip line: ", l)
            continue
        (deviceId, serial, vgid, stauts) = l.split()
        if 'none' == vgid.lower():
            continue
        if vgid not in vgToDevices:
            vgToDevices[vgid] = []
            vgOrder.append(vgid)
        vgToDevices[vgid].append(deviceId)

    cache = {}
    failedVg = False
    for vgid in vgOrder:
        deviceIds = vgToDevices[vgid]
        lspvlist = process_util.executeCommandShellWithTimeout('lsvg -l %s' % (vgid), timeout=timeout)
        if lspvlist == "":
            failedVg = True
            continue

        for tokens in iterateLines(lspvlist, skip=2):
            if len(tokens) != 7:
                logging_util.info("lsvg skip line: ")
                continue
            lvname, lvtype, lvlps, lvpps, lvpvs, lvstate, mountpoint = tokens
            if 'N/A' == mountpoint:
                continue
            if mountpoint not in cache:
                cache[mountpoint] = []
            for deviceId in deviceIds:
                cache[mountpoint].append(deviceId)

    # If there were VGs to inspect but every lsvg failed and we learned
    # nothing, treat it as a build failure so the backoff / circuit breaker
    # engages instead of caching an empty map as a "success" for the whole TTL.
    if vgOrder and failedVg and not cache:
        logging_util.error("[disk_aix] all lsvg lookups failed while building disk topology")
        return None, False

    logging_util.info("Init Disk LV Info : {}".format(str(cache)))
    return cache, (not failedVg)


def parseFromMountPointEx(mountPoint):
    global _mountPointCacheEx, _mountCacheBuiltAt, _mountCacheFailedAt

    now = time.time()
    needBuild = _mountPointCacheEx is None or (now - _mountCacheBuiltAt) > _MOUNT_CACHE_TTL_SEC

    if needBuild:
        # Do not hammer the box while it is failing: skip rebuild attempts for
        # a backoff window after a recent failure and reuse whatever we have.
        if _mountCacheFailedAt and (now - _mountCacheFailedAt) < _MOUNT_CACHE_FAIL_BACKOFF_SEC:
            needBuild = False

    if needBuild:
        try:
            built, complete = _buildMountPointCacheEx()
        except Exception, e:
            logging_util.error("Disk Mount Pointer Check Error:", e)
            built, complete = None, False

        if built is None:
            _mountCacheFailedAt = now
            _diskIoCircuitBreaker.recordFailure()
        elif complete:
            _mountPointCacheEx = built
            _mountCacheBuiltAt = now
            _mountCacheFailedAt = 0
            _diskIoCircuitBreaker.recordSuccess()
        else:
            # Partial topology (some lsvg timed out): use what we have but do
            # not trust it for the full TTL. Keep _mountCacheBuiltAt unchanged
            # and set the failure timestamp so the next rebuild is attempted
            # after the (shorter) backoff window. A timed-out lsvg is still a
            # blocking subprocess, so count it toward the circuit breaker: if
            # partial failures persist the breaker eventually opens and stops
            # the lsvg retries entirely. A later complete build resets it.
            _mountPointCacheEx = built
            _mountCacheFailedAt = now
            _diskIoCircuitBreaker.recordFailure()

    if _mountPointCacheEx is None:
        return None

    return _mountPointCacheEx.get(mountPoint)

sdiskio = namedtuple('sdiskio', ['read_count', 'write_count',
                                 'read_bytes', 'write_bytes',
                                 'read_time', 'write_time', 'time'])

class _DiskIoPerfCache:
    def __init__(self, iocounters=None):
        self.iocounters= iocounters
        self.timestamp = time.time()

    def contains(self, mountPoint):
        deviceIds = parseFromMountPointEx(mountPoint)
        deviceFound = False
        if not deviceIds:
            return deviceFound
        for deviceId in deviceIds:
            deviceFound = deviceFound or deviceId in self.iocounters
        return deviceFound
    
    def get(self, mountPoint):
        deviceIds = parseFromMountPointEx(mountPoint)
        iotime = 0
        read_count = 0
        write_count = 0
        read_bytes = 0
        write_bytes = 0
        read_time = 0
        write_time = 0

        if deviceIds:
            for deviceId in deviceIds:
                iocounter = self.iocounters.get(deviceId)
                iotime += iocounter.time
                read_count += iocounter.read_count
                write_count += iocounter.write_count
                read_bytes += iocounter.read_bytes
                write_bytes += iocounter.write_bytes
                read_time += iocounter.read_time
                write_time += iocounter.write_time
            iotime /= len(deviceIds)
        
        return sdiskio(time=iotime, read_count=read_count, write_count=write_count, read_bytes=read_bytes, write_bytes=write_bytes, read_time=read_time, write_time=write_time)

iocountersLastTime = _DiskIoPerfCache(psutil.disk_io_counters(perdisk=True))

def populateIoPerf(diskQuotas, init):
    global iocountersLastTime 

    iocounters = _DiskIoPerfCache(psutil.disk_io_counters(perdisk=True))
    diskQuotasThisTime = []
    # SERVER-1847: count the two reasons a disk can end this loop without IO so
    # the throttled log below can say which one it is.
    unmappedCount = 0
    noPreviousSampleCount = 0
    for diskQuota in diskQuotas:
        diskQuotasThisTime.append(diskQuota)
        mountPoint = diskQuota.mountPoint

        if not iocounters.contains(mountPoint):
            if init:
                logging_util.info("Init Disk IO Info : {} not found iocounter".format(mountPoint))
            unmappedCount += 1
            continue

        if not iocountersLastTime.contains(mountPoint):
            noPreviousSampleCount += 1
            continue

        iocounter = iocounters.get(mountPoint)
        iocounterLasttime =  iocountersLastTime.get(mountPoint)
        timediff = float(iocounters.timestamp - iocountersLastTime.timestamp)

        diskQuota.readIops = float(iocounter.read_count - iocounterLasttime.read_count) / timediff
        diskQuota.writeIops = float(iocounter.write_count - iocounterLasttime.write_count) / timediff
        diskQuota.readBps = float(iocounter.read_bytes - iocounterLasttime.read_bytes) /timediff
        diskQuota.writeBps = float(iocounter.write_bytes - iocounterLasttime.write_bytes) /timediff
        diskQuota.ioPercent = float(100) * float(iocounter.time - iocounterLasttime.time) / (timediff * 1000)
        # SERVER-1847: only now do the IO fields hold a real measurement.
        diskQuota.ioCollected = True
    iocountersLastTime = iocounters

    if unmappedCount or noPreviousSampleCount:
        reasons = []
        if unmappedCount:
            reasons.append(
                "{} mountpoint(s) not yet mapped to a device - background disk "
                "IO topology initialization in progress".format(unmappedCount))
        if noPreviousSampleCount:
            reasons.append(
                "{} mountpoint(s) have no previous sample yet - IO rates need "
                "two samples, available from the next cycle".format(noPreviousSampleCount))
        _logIoUnavailable("; ".join(reasons))

    return diskQuotasThisTime


mountpointStatus = {} #True running
lock = threading.Lock()

class StatVfsError(Exception):
    pass

class StatVfsTimeout(Exception):
    pass

def statvfs(mountpoint, result):
    try :
        result['statvfs'] = os.statvfs(mountpoint)
    except Exception as e:
        result['error'] = e
    finally:
        mountpointStatus[mountpoint] = False
    #return os.statvfs(mountpoint)


def runStatvfsThread(mountpoint):
    result = {}
    with lock:
        if mountpoint in mountpointStatus and mountpointStatus[mountpoint] == True:
            raise StatVfsTimeout("statvfs hang : ", mountpoint)
        
        mountpointStatus[mountpoint] = True
    th = threading.Thread(target=statvfs, args=(mountpoint, result))
    th.daemon = True
    th.start()
    th.join(1)

    if th.is_alive():
        raise StatVfsTimeout("statvfs timeout: ", mountpoint)
    elif 'error' in result:
        raise StatVfsError("{}".format(result['error']))
    elif 'statvfs' in result:
        return result['statvfs']

    raise StatVfsError("statvfs unknown error")


def updateDiskPerf(init=False):
    global lastDiskPerf

    diskQuotas = []
    for part in psutil.disk_partitions(all=False):
        if init:
            logging_util.info("Init Disk Partiton Info : device - {}, mountpoint - {}, fstype - {}".format(part.device, part.mountpoint, part.fstype))
        if part.fstype in filesystem_blacklist:
            continue
        p = _DiskPerf()
        # SERVER-1847: start pessimistic on AIX - populateIoPerf() flips this to
        # True only for the disks it actually measured. Capacity is collected
        # here and is always valid; IO is a separate, much slower path that can
        # be skipped entirely (breaker open / disabled) or per disk.
        p.ioCollected = False
        p.mountPoint = part.mountpoint
        if not p.mountPoint:
            p.mountPoint = part.device
        p.deviceId = part.device
        p.fileSystem = part.fstype

        try:
            #f_bsize, f_frsize, f_blocks, f_bfree, f_bavail, f_files, f_ffree, f_favail, f_flag, f_namemax = os.statvfs(p.mountPoint)
            f_bsize, f_frsize, f_blocks, f_bfree, f_bavail, f_files, f_ffree, f_favail, f_flag, f_namemax = runStatvfsThread(p.mountPoint)
        except Exception, e:
            logging_util.error("Disk statvfs error : {} - ".format(p.mountPoint), e)
            continue

        p.totalSpace = int(f_frsize * f_blocks)
        p.usedSpace = int(f_frsize * (f_blocks - f_bfree))
        p.usedPercent = float(100.0 * float(f_blocks - f_bfree) / float(f_blocks))
        p.freeSpace = p.totalSpace - p.usedSpace
        p.freePercent = float(100.0 * f_bfree / f_blocks)
        p.blksize = int(f_frsize)
        p.inodePercent = float(100.0 * float(f_files - f_ffree) / float(f_files))

        diskQuotas.append(p)

    # SERVER-1847: publish capacity as soon as it is ready, but ONLY while we
    # have nothing at all to serve. On a large SAN the first populateIoPerf()
    # (lspv + lsvg per VG) can take minutes, and lastDiskPerf would otherwise
    # stay None for that whole window - callers skip the cycle entirely, so
    # capacity that was already computed seconds earlier goes unreported.
    # Every disk still carries ioCollected=False here, so IO is reported as
    # null (infra) / 0 (server) instead of as a fake idle reading.
    #
    # The "is None" guard matters: assigning unconditionally would, on every
    # later cycle, briefly replace complete data with an IO-less list until
    # populateIoPerf() finishes, making steady-state graphs flicker to null.
    # Once we have data, keep serving the previous complete sample instead.
    #
    # Publishing these objects while populateIoPerf() mutates them in place is
    # safe: it sets ioCollected=True only after every IO field of that disk is
    # written, so a reader sees either "no IO" or "complete IO" per disk.
    if lastDiskPerf is None:
        lastDiskPerf = diskQuotas

    # SERVER-1847 Task D: skip IO topology collection while the circuit
    # breaker is open so a hung SAN cannot keep spawning blocking subprocesses.
    if conf.GetConfig().DiskIOEnabled and _diskIoCircuitBreaker.allowed():
        lastDiskPerf = populateIoPerf(diskQuotas, init)
    else:
        # Capacity is still published; every disk keeps ioCollected=False so
        # the IO fields go out as null / 0 rather than a fake idle reading.
        if not conf.GetConfig().DiskIOEnabled:
            _logIoUnavailable("disk.io.enabled=false")
        else:
            _logIoUnavailable("disk IO circuit breaker is open")
        lastDiskPerf = diskQuotas

lastDiskPerf = None
first = True
_diskPerfInitialized = False


def _updateDiskPerfLoop():
    # Wrapper for the background loop so the very first background iteration
    # still logs the "init" details, while later iterations run quietly. The
    # flag is only flipped after a successful run so a failed first iteration
    # is retried as init next time.
    global _diskPerfInitialized
    init = not _diskPerfInitialized
    updateDiskPerf(init=init)
    _diskPerfInitialized = True


def getDisk():
    global first
    if first:
        # SERVER-1847 Cause A / Task A:
        # Do NOT run updateDiskPerf(init=True) synchronously here. On large
        # SAN boxes the first disk collection can block effectively forever,
        # which stalls CounterManager init ([CounterManager] Init End is never
        # reached) so NO metrics are ever transmitted. Instead we only start
        # the async collection loop and return immediately; the first sample
        # is produced by that background loop.
        try:
            thread_util.asyncLoop(_updateDiskPerfLoop, conf.GetConfig().DiskstatInterval)
            # Only mark done once the loop actually started, so a startup
            # failure (thread/conf error) is retried on the next call instead
            # of disabling disk collection permanently.
            first = False
            logging_util.info(
                "[disk_aix] disk collection started in background (interval={}s); "
                "no disk metrics are reported until the first sample completes".format(
                    conf.GetConfig().DiskstatInterval))
        except Exception, e:
            logging_util.error("disk perf aix error:", e)
        return None
    global lastDiskPerf
    if lastDiskPerf is None:
        # SERVER-1847: the background loop has not produced its first sample
        # yet. Callers skip the cycle entirely (no pack is sent), so nothing is
        # misreported - but on a large SAN this window can last minutes and
        # would otherwise look like silent data loss.
        _logIoUnavailable(
            "background disk collection has not produced its first sample yet")
    return lastDiskPerf



oldIOTotal = None
def GetDiskTotalIO():
    disk = psutil.disk_io_counters(perdisk=True)
    now = time.time()

    global oldIOTotal
    if not oldIOTotal:
        oldIOTotal = [disk, now]
        return None

    oldDisk = oldIOTotal[0]
    diffTime = float(now - oldIOTotal[1])

    total_read_bytes = 0
    total_write_bytes = 0
    total_read_count = 0
    total_write_count = 0
    max_busy = 0.0
    max_disk_name = ""

    for k, v in disk.items():
        if k not in oldDisk or k.startswith("cd"):
            continue
        oldv = oldDisk[k]
        total_read_bytes += v.read_bytes - oldv.read_bytes
        total_write_bytes += v.write_bytes - oldv.write_bytes
        total_read_count += v.read_count - oldv.read_count
        total_write_count += v.write_count - oldv.write_count

        busy_diff = float(v.time - oldv.time)
        busy_pct = 100.0 * busy_diff / (diffTime * 1000) if diffTime > 0 else 0.0
        if busy_pct > max_busy:
            max_busy = busy_pct
            max_disk_name = k

    ret = _DiskPerf()
    ret.readBps = float(total_read_bytes) / diffTime if diffTime > 0 else 0.0
    ret.writeBps = float(total_write_bytes) / diffTime if diffTime > 0 else 0.0
    ret.readIops = float(total_read_count) / diffTime if diffTime > 0 else 0.0
    ret.writeIops = float(total_write_count) / diffTime if diffTime > 0 else 0.0
    ret.busyMax = min(max_busy, 100.0)
    ret.diskName = max_disk_name

    oldIOTotal = [disk, now]
    return ret

oldIO = None
def GetDiskIO():
    disk = psutil.disk_io_counters(perdisk=True)
    now = time.time()

    global oldIO
    if not oldIO:
        oldIO = [disk, now]
        return None

    oldDisk = oldIO[0]
    diffTime = float(now - oldIO[1])

    retArr = []
    for k, v in disk.items():
        if k not in oldDisk or k.startswith("cd"):
            continue
        oldv = oldDisk[k]

        ret = _DiskPerf()
        ret.deviceId = k
        ret.readBps = float(v.read_bytes - oldv.read_bytes) / diffTime
        ret.writeBps = float(v.write_bytes - oldv.write_bytes) / diffTime
        readIoCount  = float(v.read_count - oldv.read_count)
        ret.readIops = readIoCount / diffTime
        writeIoCount = float(v.write_count - oldv.write_count)
        ret.writeIops = writeIoCount / diffTime

        readTime = float(v.read_time - oldv.read_time)
        writeTime = float(v.write_time - oldv.write_time)

        busyTime = float(v.time - oldv.time)
        if readIoCount == 0:
            ret.readTime = 0
        else:
            ret.readTime = readTime / readIoCount

        if writeIoCount == 0:
            ret.writeTime = 0
        else:
            ret.writeTime = writeTime / writeIoCount
        
        ret.ioPercent = 100 * (busyTime) / (diffTime * 1000)
        if ret.ioPercent > float(100):
            ret.ioPercent = float(100)

        retArr.append(ret)

    oldIO = [disk, now]
    return retArr

        
        

def test():
    for a in getDisk():
        print a

if __name__ == '__main__':
    test()
