# -*- coding: utf-8 -*-
import time
import subprocess
import shlex
import whatap.pack.tagcount_pack as tagcount_pack
import whatap.osinfo as osinfo
import whatap.agent.secure.security_master as secure
import whatap.agent.conf.configure as config
from whatap.osinfo import procgroup_rule
from whatap.util.hash_util import HashUtil as hashutil
import whatap.util.version_util as version_util
from whatap.value.list_value import ListValue
from whatap.value.null_value import NullValue

# Upper bound on how many environment variable names may be collected per
# process (fixed fields env1~env5).
MAX_ENV_KEYS = 5
# Cap on bytes read from /proc/<pid>/environ so a pathologically large
# environment cannot cause a resource issue.
ENVIRON_READ_LIMIT = 64 * 1024


class _PlaceholderGroup:
    """매칭 프로세스가 없는 커스텀 룰을 표현하는 placeholder 그룹.
    process count=0, 그 외 지표는 null로 emit되어 '관련 프로세스 없음'을 명시한다."""

    def __init__(self, user, name):
        self.user = user
        self.cmd1 = name
        self.name = name
        self.custom = True
        self.processCount = 0
        self.cpu = 0.0
        self.cpuUser = 0.0
        self.cpuSys = 0.0
        self.memoryBytes = 0
        self.vsz = 0
        self.totalCharIO = 0.0
        self.threadCount = 0
        self.pids = []


class TaskInfraProcessTopNAndGroup:
    PROCESS_TOPN = "infra_process_topn"

    PROCESS_TOPN_COMMAND = "cmd"
    PROCESS_TOPN_OWNER = "owner"
    PROCESS_TOPN_CMDLINE = "args"
    PROCESS_TOPN_STIME = "stime"
    PROCESS_TOPN_PID = "pid"
    PROCESS_TOPN_PPID = "ppid"
    PROCESS_TOPN_CPU = "cpu"
    PROCESS_TOPN_RSS = "rss"
    PROCESS_TOPN_VSZ = "vsz"
    PROCESS_TOPN_TOTAL_CHAR_IO = "totalCharIo"
    PROCESS_TOPN_THREAD_COUNT = "threadCnt"
    PROCESS_TOPN_PROCESSINFO = "procInfo"

    # Config attribute (whatap.conf key: process.topn.envkeys) holding a
    # comma-separated list of env var names to collect per process.
    ENV_KEYS_ATTR = "ProcessTopNEnvKeys"
    ENV_KEYS_DELIMITER = ","

    PROCESS_GROUP = "infra_process_group"
    PROCESS_GROUP_COMMAND = "cmd"
    PROCESS_GROUP_OWNER = "owner"
    PROCESS_GROUP_CPU = "cpu"
    PROCESS_GROUP_RSS = "rss"
    PROCESS_GROUP_VSZ = "vsz"
    PROCESS_GROUP_TOTAL_CHAR_IO = "totalCharIo"
    PROCESS_GROUP_THREAD_COUNT = "threadCnt"
    PROCESS_GROUP_PROCESS_COUNT = "procCnt"
    PROCESS_GROUP_NAME = "name"
    PROCESS_GROUP_CUSTOM = "custom"


    def __init__(self, interval = 5, topn = 40):
        self.intervalValue = interval
        self.recType = 2
        self.topn = topn

    def interval(self):
        return self.intervalValue

    def _add_never_matched_placeholders(self, pg):
        """정의된 커스텀 룰 중 이번 주기에 매칭 프로세스가 없던 룰을 placeholder로 추가한다.
        user가 비어있는 와일드카드 룰은 결정론적 owner가 없어 제외한다."""
        try:
            rules = procgroup_rule.list_custom_rules()
        except Exception:
            return pg
        if not rules:
            return pg
        if pg is None:
            pg = {}
        existing = set()
        for g in pg.values():
            gname = getattr(g, 'name', None) or getattr(g, 'cmd1', '')
            existing.add((getattr(g, 'user', ''), gname))
        for group_name, user in rules:
            if not user:
                continue
            if (user, group_name) in existing:
                continue
            pg[('__placeholder__', user, group_name)] = _PlaceholderGroup(user, group_name)
        return pg

    def _get_env_keys(self):
        """Return the configured env var names (whatap.conf: process.topn.envkeys)
        as a de-duplicated, order-preserving list capped at MAX_ENV_KEYS. Empty
        list means the feature is disabled (no collection performed). De-dup is
        case-insensitive, so "PATH" and "path" count as the same key; the first
        spelling seen is kept for display."""
        try:
            raw = config.GetConfig().getStringSet(
                TaskInfraProcessTopNAndGroup.ENV_KEYS_ATTR, "",
                TaskInfraProcessTopNAndGroup.ENV_KEYS_DELIMITER)
        except Exception:
            return []
        keys = []
        seen = set()
        for k in raw:
            k = k.strip()
            if not k:
                continue
            lk = k.lower()
            if lk in seen:
                continue
            seen.add(lk)
            keys.append(k)
            if len(keys) >= MAX_ENV_KEYS:
                break
        return keys

    def _read_proc_environ(self, pid, wanted_set):
        """Read /proc/<pid>/environ (null-separated) and return a dict mapping
        each matched env var (keyed by its lower-cased name) to the display
        string "ACTUALNAME=value". Matching is case-insensitive, so wanted_set
        must already hold lower-cased names. Reads are bounded and every
        OS-level failure (permission denied, dead process, platform without
        /proc) is swallowed so collection never crashes."""
        result = {}
        if not wanted_set:
            return result
        try:
            f = open("/proc/{}/environ".format(pid), "rb")
        except (IOError, OSError):
            return result
        try:
            data = f.read(ENVIRON_READ_LIMIT)
        except (IOError, OSError):
            return result
        finally:
            try:
                f.close()
            except (IOError, OSError):
                pass
        if not data:
            return result
        for entry in data.split("\0"):
            if not entry:
                continue
            eq = entry.find("=")
            if eq <= 0:
                continue
            name = entry[:eq]
            lname = name.lower()
            if lname in wanted_set and lname not in result:
                result[lname] = "{}={}".format(name, entry[eq + 1:])
                if len(result) == len(wanted_set):
                    break
        return result

    def _read_env_via_ps(self, wanted_set):
        """AIX fallback: /proc/<pid>/environ does not exist there, so read the
        environment of ALL processes in a SINGLE 'ps geww' call and return
        {pid: {lower_name: "ACTUALNAME=value"}}. Because it is called once per
        cycle and keyed by pid, the same pid is never queried more than once.
        Matching is case-insensitive (wanted_set holds lower-cased names).
        Best-effort: an env value containing spaces is truncated at the first
        space (ps has no null delimiter), and any failure is swallowed so
        collection never crashes. Returns {} on any problem."""
        cache = {}
        if not wanted_set:
            return cache
        # ps output columns before COMMAND: PID TTY STAT TIME.
        PREFIX_COLS = 4
        try:
            p = subprocess.Popen(shlex.split("/usr/bin/ps geww"),
                                  stdout=subprocess.PIPE, stderr=subprocess.PIPE)
            out, _ = p.communicate()
        except (OSError, ValueError):
            return cache
        if not out:
            return cache
        if isinstance(out, bytes) and not isinstance(out, str):
            # Python 3 returns bytes; on AIX (Python 2) bytes is already str.
            out = out.decode("utf-8", "replace")
        for line in out.splitlines():
            parts = line.split()
            if len(parts) <= PREFIX_COLS:
                continue
            try:
                pid = int(parts[0])
            except (ValueError, IndexError):
                # Header row ("PID ...") or any non-numeric first column.
                continue
            found = {}
            # Tokens after the fixed columns are the command args followed by
            # the environment; scan them for NAME=value tokens we want.
            for token in parts[PREFIX_COLS:]:
                eq = token.find("=")
                if eq <= 0:
                    continue
                lname = token[:eq].lower()
                if lname in wanted_set and lname not in found:
                    found[lname] = token
                    if len(found) == len(wanted_set):
                        break
            if found:
                cache[pid] = found
        return cache

    def process(self, os_, pcode, oid, now):
        (topn, pg) = osinfo.GetTopNAndGroupProc(self.topn)
        pg = self._add_never_matched_placeholders(pg)
        topnPack = None
        pgPack = None

        mem_total = osinfo.GetMemorySize()
        if not mem_total or mem_total <= 0:
            mem_total = None

        secu = secure.GetSecurityMaster()
        if topn:
            topnPack = tagcount_pack.TagCountPack()
            topnPack.pcode = pcode
            topnPack.oid = oid
            topnPack.time = now
            topnPack.category = TaskInfraProcessTopNAndGroup.PROCESS_TOPN
            topnPack.putTag("oname", str(secu.oname))
            topnPack.putTag("!rectype", str(self.recType))
            topnPack.putTag("ver", version_util.GetVerTag())

        
            idList = ListValue()
            commandList = ListValue()
            ownerList = ListValue()
            cmdlineList = ListValue()
            stimeList = ListValue()
            pidList = ListValue()
            ppidList = ListValue()
            cpuList = ListValue()
            cpuUserList = ListValue()
            cpuSysList = ListValue()
            rssList = ListValue()
            vszList = ListValue()
            totalCharIoList = ListValue()
            threadCountList = ListValue()
            processInfoList = ListValue()
            pRssList = ListValue()

            # Optional per-process environment variable collection. Only active
            # when process.topn.envkeys is configured; otherwise no reads happen
            # and no env fields are emitted (backward compatible).
            envKeys = self._get_env_keys()
            # Matching is case-insensitive: the wanted set holds lower-cased
            # names and per-process lookups compare in lower case.
            envWantedSet = set(k.lower() for k in envKeys)
            envLists = [ListValue() for _ in range(MAX_ENV_KEYS)]
            # AIX has no /proc/<pid>/environ, so build a pid->env cache with a
            # single 'ps geww' call for the whole cycle (no per-pid, no repeat
            # calls). Other platforms read /proc/<pid>/environ per pid (once).
            envPsCache = None
            if envKeys and osinfo.system == 'aix':
                envPsCache = self._read_env_via_ps(envWantedSet)

            for proc in topn:
                key = hashutil.hashFromString("{}{}{}{}{}{}".format(proc.cmd1, proc.user, proc.cmd2, proc.createTime, proc.pid, proc.ppid))
                idList.addLong(key)
                commandList.addString(proc.cmd1)
                ownerList.addString(proc.user)
                cmdlineList.addString(proc.cmd2)
                stimeList.addLong(proc.createTime)
                pidList.addLong(proc.pid)
                ppidList.addLong(proc.ppid)
                cpuList.addFloat(proc.cpu)
                cpuUserList.addFloat(getattr(proc, 'cpuUser', 0.0))
                cpuSysList.addFloat(getattr(proc, 'cpuSys', 0.0))
                rssList.addLong(proc.memoryBytes)
                vszList.addLong(proc.vsz)
                totalCharIoList.addFloat(proc.totalCharIO)
                threadCountList.addLong(proc.threadCount)
                processInfoList.addString("{}, {}, {}, {}, {} ({})".format(proc.user, proc.pid, proc.ppid, proc.createTime, proc.cmd1, proc.cmd2))
                if mem_total:
                    pRssList.addFloat(float(proc.memoryBytes) / float(mem_total) * 100.0)
                else:
                    pRssList.addValue(NullValue())

                # env1~env5 are fixed fields. envN maps to the N-th configured
                # env var name; when that var exists on the process (matched
                # case-insensitively) the value is "ACTUALNAME=value" using the
                # name's real case on the process, otherwise null. Slots beyond
                # the configured count are always null.
                if envKeys:
                    if envPsCache is not None:
                        procEnv = envPsCache.get(proc.pid, {})
                    else:
                        procEnv = self._read_proc_environ(proc.pid, envWantedSet)
                    for i in range(MAX_ENV_KEYS):
                        lk = envKeys[i].lower() if i < len(envKeys) else None
                        if lk is not None and lk in procEnv:
                            envLists[i].addString(procEnv[lk])
                        else:
                            envLists[i].addValue(NullValue())

            topnPack.put("@id", idList)
            topnPack.put(TaskInfraProcessTopNAndGroup.PROCESS_TOPN_COMMAND, commandList)
            topnPack.put(TaskInfraProcessTopNAndGroup.PROCESS_TOPN_OWNER, ownerList)
            topnPack.put(TaskInfraProcessTopNAndGroup.PROCESS_TOPN_CMDLINE, cmdlineList)
            topnPack.put(TaskInfraProcessTopNAndGroup.PROCESS_TOPN_STIME, stimeList)
            topnPack.put(TaskInfraProcessTopNAndGroup.PROCESS_TOPN_PID, pidList)
            topnPack.put(TaskInfraProcessTopNAndGroup.PROCESS_TOPN_PPID, ppidList)
            topnPack.put(TaskInfraProcessTopNAndGroup.PROCESS_TOPN_CPU, cpuList)
            topnPack.put("cpuUser", cpuUserList)
            topnPack.put("cpuSys", cpuSysList)
            topnPack.put(TaskInfraProcessTopNAndGroup.PROCESS_TOPN_RSS, rssList)
            topnPack.put(TaskInfraProcessTopNAndGroup.PROCESS_TOPN_VSZ, vszList)
            topnPack.put(TaskInfraProcessTopNAndGroup.PROCESS_TOPN_TOTAL_CHAR_IO, totalCharIoList)
            topnPack.put(TaskInfraProcessTopNAndGroup.PROCESS_TOPN_THREAD_COUNT, threadCountList)
            topnPack.put(TaskInfraProcessTopNAndGroup.PROCESS_TOPN_PROCESSINFO, processInfoList)
            topnPack.put("pRss", pRssList)

            # Emit fixed env fields env1~env5 only when collection is enabled.
            if envKeys:
                for i in range(MAX_ENV_KEYS):
                    topnPack.put("env{}".format(i + 1), envLists[i])

        if pg:
            pgPack = tagcount_pack.TagCountPack()
            pgPack.pcode = pcode
            pgPack.oid = oid
            pgPack.time = now
            pgPack.category = TaskInfraProcessTopNAndGroup.PROCESS_GROUP
            pgPack.putTag("oname", str(secu.oname))
            pgPack.putTag("!rectype", str(self.recType))
            pgPack.putTag("ver", version_util.GetVerTag())

            idList = ListValue()
            commandList = ListValue()
            ownerList = ListValue()
            cpuList = ListValue()
            cpuUserList = ListValue()
            cpuSysList = ListValue()
            rssList = ListValue()
            vszList = ListValue()
            totalCharIoList = ListValue()
            threadCountList = ListValue()
            processCountList = ListValue()
            nameList = ListValue()
            pidListVal = ListValue()
            pRssList = ListValue()
            customList = ListValue()

            for _, proc in pg.items():
                # @id는 user + name 조합으로 생성한다(cmd는 변동성으로 키에서 제외 — 데이터 단절 방지).
                # 이를 통해 실제 프로세스가 매칭되어 생성된 행과, 매칭 프로세스가 없어 placeholder로
                # 생성한 행의 @id가 동일하게 유지된다.
                group_name_val = proc.name if getattr(proc, 'name', None) else proc.cmd1
                hash_key = hashutil.hashFromString("{}\\{}\\".format(proc.user, group_name_val))
                idList.addLong(hash_key)
                commandList.addString(proc.cmd1)
                ownerList.addString(proc.user)
                processCountList.addLong(proc.processCount)
                nameList.addString(group_name_val)
                pids = getattr(proc, 'pids', [])
                pidListVal.addString(",".join(str(p) for p in pids))
                customList.addBoolean(bool(getattr(proc, 'custom', False)))

                # 매칭 프로세스가 없는 그룹(커스텀 룰 placeholder): count=0, 그 외 지표는 null.
                if proc.processCount <= 0:
                    cpuList.addValue(NullValue())
                    cpuUserList.addValue(NullValue())
                    cpuSysList.addValue(NullValue())
                    rssList.addValue(NullValue())
                    vszList.addValue(NullValue())
                    totalCharIoList.addValue(NullValue())
                    threadCountList.addValue(NullValue())
                    pRssList.addValue(NullValue())
                    continue

                cpuList.addFloat(proc.cpu)
                cpuUserList.addFloat(getattr(proc, 'cpuUser', 0.0))
                cpuSysList.addFloat(getattr(proc, 'cpuSys', 0.0))
                rssList.addLong(proc.memoryBytes)
                vszList.addLong(proc.vsz)
                totalCharIoList.addFloat(proc.totalCharIO)
                threadCountList.addLong(proc.threadCount)
                if mem_total:
                    avg_rss = float(proc.memoryBytes) / float(proc.processCount)
                    pRssList.addFloat(avg_rss / float(mem_total) * 100.0)
                else:
                    pRssList.addValue(NullValue())

            pgPack.put("@id", idList)
            pgPack.put(TaskInfraProcessTopNAndGroup.PROCESS_GROUP_COMMAND, commandList)
            pgPack.put(TaskInfraProcessTopNAndGroup.PROCESS_GROUP_OWNER, ownerList)
            pgPack.put(TaskInfraProcessTopNAndGroup.PROCESS_GROUP_CPU, cpuList)
            pgPack.put("cpuUser", cpuUserList)
            pgPack.put("cpuSys", cpuSysList)
            pgPack.put(TaskInfraProcessTopNAndGroup.PROCESS_GROUP_RSS, rssList)
            pgPack.put(TaskInfraProcessTopNAndGroup.PROCESS_GROUP_VSZ, vszList)
            pgPack.put(TaskInfraProcessTopNAndGroup.PROCESS_GROUP_TOTAL_CHAR_IO, totalCharIoList)
            pgPack.put(TaskInfraProcessTopNAndGroup.PROCESS_GROUP_THREAD_COUNT, threadCountList)
            pgPack.put(TaskInfraProcessTopNAndGroup.PROCESS_GROUP_PROCESS_COUNT, processCountList)
            pgPack.put(TaskInfraProcessTopNAndGroup.PROCESS_GROUP_NAME, nameList)
            pgPack.put("pidList", pidListVal)
            pgPack.put("pRss", pRssList)
            pgPack.put(TaskInfraProcessTopNAndGroup.PROCESS_GROUP_CUSTOM, customList)


        return [topnPack, pgPack]


