import os
import sys
import json
import time
import signal
import subprocess
import threading
import csv
import base64
import pwd
from datetime import datetime
from StringIO import StringIO

try:
    from croniter import croniter
except ImportError:
    croniter = None


class SimpleCroniter:
    def __init__(self, spec, base_time=None):
        self.spec = spec
        self.base_time = base_time or datetime.now()
        parts = spec.split()
        if len(parts) != 5:
            raise ValueError("Invalid cron spec (must have 5 fields): {}".format(spec))
        self.minute = self._parse_field(parts[0], 0, 59)
        self.hour = self._parse_field(parts[1], 0, 23)
        self.day = self._parse_field(parts[2], 1, 31)
        self.month = self._parse_field(parts[3], 1, 12)
        self.weekday = self._parse_field(parts[4], 0, 6)

    def _parse_field(self, field, min_val, max_val):
        if field == '*':
            return set(range(min_val, max_val + 1))

        result = set()
        for part in field.split(','):
            part = part.strip()
            if '/' in part:
                base, step = part.split('/', 1)
                step = int(step)
                if base == '*':
                    result.update(range(min_val, max_val + 1, step))
                elif '-' in base:
                    start, end = map(int, base.split('-', 1))
                    result.update(range(start, end + 1, step))
                else:
                    start = int(base)
                    result.update(range(start, max_val + 1, step))
            elif '-' in part:
                start, end = map(int, part.split('-', 1))
                result.update(range(start, end + 1))
            else:
                result.add(int(part))
        return result

    def _matches(self, dt):
        if dt.minute not in self.minute:
            return False
        if dt.hour not in self.hour:
            return False
        if dt.day not in self.day:
            return False
        if dt.month not in self.month:
            return False
        if dt.weekday() not in self.weekday:
            return False
        return True

    def get_next(self, return_type=None):
        from datetime import timedelta

        current = self.base_time + timedelta(minutes=1)
        current = current.replace(second=0, microsecond=0)

        max_iterations = 366 * 24 * 60 * 2
        for _ in range(max_iterations):
            if self._matches(current):
                self.base_time = current
                return current
            current += timedelta(minutes=1)

        raise ValueError("No valid next run time found within 2 years for spec: {}".format(self.spec))


def get_croniter(spec, base_time=None):
    if croniter is not None:
        return croniter(spec, base_time)
    return SimpleCroniter(spec, base_time)


def _to_text(val):
    """Convert a value to unicode text safely across Py2/Py3.

    On Python 2, str() of a non-ASCII unicode value raises UnicodeEncodeError;
    this helper avoids that so @id hashing never crashes the job thread. ASCII
    values yield the same bytes as the previous str() path once utf-8 encoded,
    so existing @id hashes are unchanged.
    """
    if isinstance(val, bytes):
        return val.decode('utf-8', 'replace')
    try:
        return u"%s" % (val,)
    except Exception:
        return u""


def _parse_start_time(start_time_str):
    """Parse "HH:MM" string and return (hour, minute) tuple.
    Returns (None, None) on parse failure.
    """
    if not start_time_str:
        return None, None
    try:
        parts = start_time_str.strip().split(':')
        if len(parts) < 2:
            return None, None
        hour = int(parts[0])
        minute = int(parts[1])
        if hour < 0 or hour > 23 or minute < 0 or minute > 59:
            return None, None
        return hour, minute
    except (ValueError, AttributeError):
        return None, None


def _day_ints(days):
    """Coerce a day-selector list to a set of ints, accepting str or int entries.

    The server may serialize weekday / day-of-month values either as JSON
    numbers ([1,2,3]) or as strings (["1","2","3"]); both must match.
    Non-numeric entries are skipped.
    """
    out = set()
    for d in days:
        try:
            out.add(int(d))
        except (ValueError, TypeError):
            continue
    return out


def _normalize_md(value):
    """Normalize a yearly "MM-DD" (or "M-D") key to zero-padded "MM-DD".

    Returns None if not parseable, so malformed entries are simply ignored.
    """
    try:
        parts = str(value).strip().split('-')
        if len(parts) != 2:
            return None
        return "%02d-%02d" % (int(parts[0]), int(parts[1]))
    except (ValueError, TypeError):
        return None


def _in_selected_day(now, schedule_type, trigger):
    """Return True if 'now' falls on a day selected by the trigger.days list.

    Schedule types (scheduleType field controls interpretation of trigger.days):
      daily   - always True (trigger.days ignored)
      weekly  - isoweekday(now) in trigger.days (ISO 1=Mon..7=Sun)
      monthly - now.day in trigger.days (31 in a short month never fires)
      yearly  - "MM-DD" for now in trigger.days

    Day entries are type-normalized so the selector matches whether the server
    sent ints or strings (see _day_ints / _normalize_md).
    An empty days list is treated as "all days" (True).
    """
    st = schedule_type
    if st == WINDOWED_SUBTYPE_DAILY:
        return True
    if st == WINDOWED_SUBTYPE_WEEKLY:
        days = trigger.days
        if not days:
            return True
        return now.isoweekday() in _day_ints(days)
    if st == WINDOWED_SUBTYPE_MONTHLY:
        days = trigger.days
        if not days:
            return True
        return now.day in _day_ints(days)
    if st == WINDOWED_SUBTYPE_YEARLY:
        days = trigger.days
        if not days:
            return True
        key = "%02d-%02d" % (now.month, now.day)
        return key in set(filter(None, (_normalize_md(d) for d in days)))
    # Unknown subtype - conservative: do not allow (matches Go sibling behavior)
    return False


def _in_time_window(now, start_hour, start_minute, duration_seconds):
    """Return True if 'now' is within [window_start, window_end).

    window_start = today at start_hour:start_minute (local time)
    window_end   = window_start + duration_seconds

    Cross-midnight windows are fully supported: if window_end falls on the next
    calendar day the check covers both the same-day leg (after window_start) and
    the carry-over leg (before window_end on the next day).
    """
    from datetime import timedelta
    # Case 1: window starting today
    window_start = now.replace(hour=start_hour, minute=start_minute, second=0, microsecond=0)
    window_end = window_start + timedelta(seconds=duration_seconds)
    if window_start <= now < window_end:
        return True
    # Case 2: cross-midnight carry-over - window started yesterday, still ongoing now
    yesterday_start = (now - timedelta(days=1)).replace(
        hour=start_hour, minute=start_minute, second=0, microsecond=0
    )
    yesterday_end = yesterday_start + timedelta(seconds=duration_seconds)
    if yesterday_end > yesterday_start.replace(hour=23, minute=59, second=59, microsecond=0):
        # Window crosses midnight - check if now is in the carry-over leg
        if yesterday_start <= now < yesterday_end:
            return True
    return False


def _window_anchor_date(now, start_hour, start_minute, duration_seconds):
    """Return the date on which the active or most-recent window started.

    Used to determine which calendar date to apply day-selector checks against
    for cross-midnight windows.  Returns None if not currently inside a window.
    """
    from datetime import timedelta
    # Check same-day window
    window_start = now.replace(hour=start_hour, minute=start_minute, second=0, microsecond=0)
    window_end = window_start + timedelta(seconds=duration_seconds)
    if window_start <= now < window_end:
        return window_start.date()
    # Check cross-midnight carry-over
    yesterday_start = (now - timedelta(days=1)).replace(
        hour=start_hour, minute=start_minute, second=0, microsecond=0
    )
    yesterday_end = yesterday_start + timedelta(seconds=duration_seconds)
    if yesterday_end > yesterday_start.replace(hour=23, minute=59, second=59, microsecond=0):
        if yesterday_start <= now < yesterday_end:
            return yesterday_start.date()
    return None


def _next_window_start(now, start_hour, start_minute, trigger):
    """Return the datetime of the next window start after 'now'.

    Scans day-by-day (up to 1500 days) until a selected day is found.
    The limit is 1500 to cover the worst case: consecutive leap-year Feb-29
    occurrences are 1460 days apart (4 years), plus a safety margin.
    Returns None if no valid day is found within the scan limit.
    """
    from datetime import timedelta
    schedule_type = trigger.schedule_type

    candidate = now.replace(hour=start_hour, minute=start_minute, second=0, microsecond=0)
    # If today's window start is still in the future for today, check today first
    if candidate > now and _in_selected_day(candidate, schedule_type, trigger):
        return candidate

    # Advance to the next day and scan forward
    candidate = (now + timedelta(days=1)).replace(
        hour=start_hour, minute=start_minute, second=0, microsecond=0
    )
    for _ in range(1500):
        if _in_selected_day(candidate, schedule_type, trigger):
            return candidate
        candidate = candidate + timedelta(days=1)
    return None


import whatap.agent.conf.configure as config
import whatap.agent.secure.security_master as secure
import whatap.agent.data.data_pack as data
import whatap.pack.tagcount_pack as tagcount_pack
import whatap.pack.param_pack as param_pack
import whatap.util.logging_util as logging_util
import whatap.agent.counter.inspection_forwarder as inspection_forwarder
from whatap.util.date_util import DateUtil as dateutil
from whatap.util.hash_util import HashUtil
import whatap.util.version_util as version_util
import whatap.net as net
from whatap.value.text_value import TextValue
from whatap.value.decimal_value import DecimalValue
from whatap.value.float_value import FloatValue
from whatap.value.list_value import ListValue
from whatap.value.map_value import MapValue

# Constants
SCHEDULE_TYPE_CRON = "cron"
SCHEDULE_TYPE_INTERVAL = "interval"
SCHEDULE_TYPE_WINDOWED = "windowed"

# Windowed schedule sub-types
WINDOWED_SUBTYPE_DAILY = "daily"
WINDOWED_SUBTYPE_WEEKLY = "weekly"
WINDOWED_SUBTYPE_MONTHLY = "monthly"
WINDOWED_SUBTYPE_YEARLY = "yearly"

STATUS_INIT = "init"
STATUS_RUNNING = "running"
STATUS_SUCCESS = "success"
STATUS_ERROR = "error"

CONFIG_POLL_INTERVAL = 5  # seconds


class JobStatus(object):
    def __init__(self, job_id):
        self.id = job_id
        self.schedule = ""
        self.schedule_type = ""
        self.last_execute_start_time = 0
        self.last_execute_status = STATUS_INIT
        self.last_execute_duration = 0
        self.error_message = ""


class Script(object):
    def __init__(self, data=None):
        data = data or {}
        self.file_name = data.get('fileName', '')
        self.type = data.get('type', 0)  # 0: csv, 1: csv, 2: json
        self.delimiter = data.get('delimiter', '|')
        self.content = data.get('content', '')


class ScriptConfig(object):
    def __init__(self, data=None):
        data = data or {}
        self.file_name = data.get('fileName', '')
        self.content = data.get('content', '')


class Trigger(object):
    def __init__(self, data=None):
        data = data or {}
        self.cron = data.get('cron')
        self.interval = data.get('interval')
        # Windowed schedule fields (additive - absent means legacy mode)
        self.schedule_type = data.get('scheduleType')      # "daily"|"weekly"|"monthly"|"yearly"
        self.start_time = data.get('startTime')            # "HH:MM" agent local time
        self.duration = data.get('duration')               # window length in seconds
        # Unified day selector: interpretation depends on scheduleType.
        # weekly  -> list of ISO-8601 weekday ints (1=Mon..7=Sun)
        # monthly -> list of day-of-month ints (1..31)
        # yearly  -> list of "MM-DD" strings
        # Accepts legacy keys (daysOfWeek/daysOfMonth/monthDays) as fallback for
        # backward compatibility with configs written by older agent versions.
        self.days = (
            data.get('days')
            or data.get('daysOfWeek')
            or data.get('daysOfMonth')
            or data.get('monthDays')
            or []
        )


class Column(object):
    def __init__(self, data=None):
        data = data or {}
        self.type = data.get('type', 'string')
        self.required = data.get('required', True)


class OutputTagCount(object):
    def __init__(self, data=None):
        data = data or {}
        self.category_name = data.get('categoryName', '')
        self.column_map = {}
        for k, v in data.get('columnMap', {}).items():
            self.column_map[k] = Column(v)
        self.fold_rec_type = data.get('foldRecType', 0)
        # pkFields: field names used to generate @id when foldRecType==2
        self.pk_fields = data.get('pkFields', [])


class OutputKvTable(object):
    def __init__(self, data=None):
        data = data or {}
        self.category_name = data.get('categoryName', '')
        self.column_map = {}
        for k, v in data.get('columnMap', {}).items():
            self.column_map[k] = Column(v)
        # pkFields: field names used to derive the row key (pk0), mirroring the
        # tagCount @id rule.  Required to build a kvtable.  Normalize here (strip,
        # drop blanks) so downstream key derivation and the "no pkFields" guard
        # both see a clean list.
        self.pk_fields = [pf.strip() for pf in data.get('pkFields', [])
                          if pf and _to_text(pf).strip()]


class Output(object):
    def __init__(self, data=None):
        data = data or {}
        self.tag_count = None
        self.kv_table = None

        if data.get('tagCount'):
            self.tag_count = OutputTagCount(data['tagCount'])
        if data.get('kvTable'):
            self.kv_table = OutputKvTable(data['kvTable'])


class Job(object):
    def __init__(self, data=None):
        data = data or {}
        self.id = data.get('id', '')
        self.script = Script(data.get('script', {}))
        self.script_config = ScriptConfig(data.get('config', {}))
        self.trigger = Trigger(data.get('trigger', {}))
        self.output = Output(data.get('output', {}))
        # runUser: optional username to drop privileges before executing the script.
        # Empty string or None means run as the current agent user (no privilege drop).
        self.run_user = data.get('runUser', '')
        # SERVER-1943: marks a job registered by the inspection module. Results of
        # such a job go to the inspection project only, never to the default one.
        # Missing / null / false all mean False, so jobs registered before this
        # field existed keep their current routing.
        self.inspection = bool(data.get('inspection', False))


class Config(object):
    def __init__(self, data=None):
        data = data or {}
        self.job_list = []
        for j in data.get('jobList', []):
            self.job_list.append(Job(j))


# Global variables
_cron_dir_prefix = "/usr/whatap/infra/cronjob"
_config_path = "/usr/whatap/infra/cronjob/cronjob.json"
_job_status_map = {}
_job_status_lock = threading.Lock()
_scheduled_jobs = {}  # job_id -> next_run_time
_running = False
_cronjob_thread = None


def _auto_parse(s):
    s = s.strip()

    try:
        return DecimalValue(int(s))
    except ValueError:
        pass

    try:
        return FloatValue(float(s))
    except ValueError:
        pass

    return TextValue(s)


def _resolve_escapes(s):
    if not s:
        return s

    result = []
    i = 0
    while i < len(s):
        if s[i] == '\\' and i + 1 < len(s):
            next_ch = s[i + 1]
            if next_ch == 't':
                result.append(u'\t')
                i += 2
            elif next_ch == 'n':
                result.append(u'\n')
                i += 2
            elif next_ch == 'r':
                result.append(u'\r')
                i += 2
            elif next_ch == '\\':
                result.append(u'\\')
                i += 2
            elif next_ch == 'u' and i + 5 < len(s):
                hex_str = s[i + 2:i + 6]
                try:
                    result.append(unichr(int(hex_str, 16)))
                    i += 6
                except ValueError:
                    result.append(s[i])
                    i += 1
            else:
                result.append(s[i])
                i += 1
        else:
            result.append(s[i])
            i += 1

    return u''.join(result)


def _parse_delimited(csv_str, delimiter):
    result = []
    lines = csv_str.splitlines()
    if not lines:
        return result

    headers = [h.strip() for h in lines[0].split(delimiter)]

    for line in lines[1:]:
        if not line.strip():
            continue
        parts = line.split(delimiter)
        row_data = {}
        for i, val in enumerate(parts):
            if i < len(headers):
                row_data[headers[i]] = _auto_parse(val)
        result.append(row_data)

    return result


def _parse_csv(csv_str, delimiter='|'):
    result = []

    encoded = isinstance(csv_str, unicode)
    if encoded:
        csv_str = csv_str.encode('utf-8')

    reader = csv.reader(StringIO(csv_str), delimiter=str(delimiter))
    rows = list(reader)

    if len(rows) < 1:
        return result

    headers = [
        (h.decode('utf-8') if isinstance(h, str) else h).strip()
        for h in rows[0]
    ]

    for row in rows[1:]:
        if not row:
            continue
        row_data = {}
        for i, val in enumerate(row):
            if i < len(headers):
                key = headers[i]
                v = val.decode('utf-8') if isinstance(val, str) else val
                row_data[key] = _auto_parse(v)
        result.append(row_data)

    return result


def _load_config(path):
    if not os.path.exists(path):
        dir_path = os.path.dirname(path)
        if not os.path.exists(dir_path):
            try:
                os.makedirs(dir_path)
            except OSError as e:
                logging_util.error("[CronJob] Failed to create directory: {}".format(e))
                return Config()

        default_cfg = {"jobList": []}
        try:
            with open(path, 'w') as f:
                json.dump(default_cfg, f, indent=2)
            logging_util.info("[CronJob] Created default config file: {}".format(path))
        except Exception as e:
            logging_util.error("[CronJob] Failed to create default config: {}".format(e))

        return Config()

    try:
        with open(path, 'r') as f:
            data = json.load(f)
        return Config(data)
    except Exception as e:
        logging_util.error("[CronJob] Failed to load config: {}".format(e))
        return Config()


def _update_job_status(job_id, success, start_time, duration, error=None):
    with _job_status_lock:
        if job_id not in _job_status_map:
            _job_status_map[job_id] = JobStatus(job_id)

        status = _job_status_map[job_id]
        status.last_execute_duration = duration

        if success:
            status.last_execute_status = STATUS_SUCCESS
            status.error_message = ""
        else:
            status.last_execute_status = STATUS_ERROR
            if error:
                status.error_message = str(error)


def _set_job_running(job_id, start_time):
    with _job_status_lock:
        if job_id in _job_status_map:
            status = _job_status_map[job_id]
            status.last_execute_status = STATUS_RUNNING
            status.last_execute_start_time = start_time


def _initialize_job_status(job, spec, schedule_type):
    with _job_status_lock:
        if job.id not in _job_status_map:
            _job_status_map[job.id] = JobStatus(job.id)

        status = _job_status_map[job.id]
        status.schedule = spec
        status.schedule_type = schedule_type


def _remove_job_status(job_id):
    with _job_status_lock:
        if job_id in _job_status_map:
            del _job_status_map[job_id]


def _cleanup_orphaned_job_status(active_job_ids):
    with _job_status_lock:
        active_set = set(active_job_ids)
        orphaned = [jid for jid in _job_status_map if jid not in active_set]
        for jid in orphaned:
            del _job_status_map[jid]
            logging_util.debug("[CronJob] Removed orphaned job status: {}".format(jid))


class TimeoutException(Exception):
    pass


def _resolve_run_user(username):
    """Resolve a username string to (uid, gid) using the pwd module.

    Returns (uid, gid) on success.
    Raises KeyError if the user does not exist.
    Never falls back to the agent user -- callers must handle the exception
    and abort the job without running it.
    """
    entry = pwd.getpwnam(username)
    return entry.pw_uid, entry.pw_gid


def _make_preexec_fn(uid, gid):
    """Return a preexec_fn callable that drops privileges to (uid, gid).

    The gid must be set before uid; reversing the order can result in
    EPERM on some Unix variants (AIX, HP-UX, Solaris).
    """
    def _drop_privileges():
        os.setgid(gid)
        os.setuid(uid)
    return _drop_privileges


def _run_command_with_timeout(script_path, timeout, uid=None, gid=None):
    """Execute script_path and return (output_str, error).

    If uid and gid are both provided, the child process will drop privileges
    to that user/group pair before executing the script.  If the privilege
    drop fails (e.g. agent is not root), an OSError is returned as the error
    value; no fallback to the agent user is attempted.
    """
    result = ""
    error = None
    output_data = [None]
    proc_error = [None]

    preexec = None
    if uid is not None and gid is not None:
        preexec = _make_preexec_fn(uid, gid)

    try:
        proc = subprocess.Popen(
            [script_path],
            stdout=subprocess.PIPE,
            stderr=subprocess.STDOUT,
            shell=False,
            preexec_fn=preexec
        )

        def communicate_thread():
            try:
                output_data[0], _ = proc.communicate()
            except Exception as e:
                proc_error[0] = e

        comm_thread = threading.Thread(target=communicate_thread)
        comm_thread.start()
        comm_thread.join(timeout)

        if comm_thread.is_alive():
            proc.kill()
            proc.wait()
            error = TimeoutException("Timeout after {} seconds".format(timeout))
        elif proc_error[0]:
            error = proc_error[0]
        else:
            output = output_data[0]
            if output:
                if isinstance(output, bytes):
                    result = output.decode('utf-8', 'replace')
                else:
                    result = output
            else:
                result = ""

    except Exception as e:
        error = e

    return result, error


# SERVER-1943: cronjob results are routed to exactly one project.
#
#   inspection=True  -> inspection project only, never the default one
#   inspection=False -> default project only (the pre-existing behaviour)
#
# There is deliberately no fallback: if a job is marked inspection but no
# inspection target is configured, the result is dropped rather than leaking
# into the default project, and the miss is logged (throttled, because a job can
# fire often and every row would otherwise produce a line).
_INSPECTION_MISS_LOG_INTERVAL = 300
_inspection_miss_logged_at = 0


def _send_cronjob_pack(p, inspection, job_id):
    global _inspection_miss_logged_at

    if not inspection:
        data.SendHide(p)
        return

    if inspection_forwarder.SendInspectionPack(p):
        return

    now = time.time()
    if now - _inspection_miss_logged_at >= _INSPECTION_MISS_LOG_INTERVAL:
        _inspection_miss_logged_at = now
        logging_util.error(
            "[CronJob] job {} is marked inspection but no inspection target is "
            "configured (inspection.license unset or unparseable); results are "
            "not being sent".format(job_id))


def _apply_inspection_tag(p, inspection):
    """Suppress 5m/hour rollups for inspection data.

    Inspection output is looked at as raw rows, so it must not feed the
    statistics pipeline. Only tagCount packs carry tags.
    """
    if p is None or not inspection:
        return
    p.putTag("_no_5m_hour_", "")


def _process_result(job, result, error, start_time):
    end_time = dateutil.now()
    duration = end_time - start_time

    _update_job_status(job.id, error is None, start_time, duration, error)

    if error:
        logging_util.error("[CronJob] Job {} failed: {}".format(job.id, error))
        return

    secu = secure.GetSecurityMaster()

    script_data = []
    if job.script.type == 0 or job.script.type == 1:
        raw_deli = job.script.delimiter if job.script.delimiter else u'|'
        delimiter = _resolve_escapes(raw_deli)
        if not isinstance(delimiter, unicode):
            delimiter = delimiter.decode('utf-8')
        raw_deli_u = raw_deli if isinstance(raw_deli, unicode) else raw_deli.decode('utf-8')

        # Resolved \n/\r conflict with row separation (csv EOL, splitlines).
        # Fall back to the literal 2-char form so `echo "...\n..."` (no -e) parses.
        if delimiter in (u'\n', u'\r'):
            delimiter = raw_deli_u
        elif raw_deli_u != delimiter and result:
            # Delimiter was given via escape (e.g., "\t"). If the resolved char
            # isn't in the output, the script likely emitted the literal escape
            # form (`echo` without -e). Normalize it before parsing.
            if isinstance(result, bytes):
                result = result.decode('utf-8', 'replace')
            if delimiter not in result:
                result = result.replace(raw_deli_u, delimiter)

        try:
            if len(delimiter) == 1 and ord(delimiter) < 128:
                script_data = _parse_csv(result, str(delimiter))
            else:
                script_data = _parse_delimited(result, delimiter)
        except Exception as e:
            logging_util.error("[CronJob] Parse error for job {}: {}".format(job.id, e))
            return

    if not script_data:
        logging_util.debug("[CronJob] No data parsed for job {}".format(job.id))
        return

    length = len(script_data)

    tg = job.output.tag_count
    if tg:
        category_name = "custom_" + tg.category_name if tg.category_name else "custom_" + job.script.file_name

        # SERVER-1977: one script run must produce one timestamp shared by every
        # row it emits.  The row_id hash uses time as the "which run" axis, so
        # calling now() again per row splits rows of the same run across runs:
        # rows landing in the same millisecond collide, rows crossing a
        # millisecond boundary drift apart, and which happens varies per
        # execution.  Computing it once here also matches the Go agent, which
        # derives every custom_ tagCount pack of a run from a single timestamp.
        run_time = dateutil.now()

        if tg.fold_rec_type == 2:
            p = tagcount_pack.TagCountPack()
            p.category = category_name
            p.pcode = secu.pcode
            p.oid = secu.oid
            p.time = run_time
            p.putTag("oname", str(secu.oname))
            p.putTag("!rectype", "2")
            p.putTag("ver", version_util.GetVerTag())
            _apply_inspection_tag(p, job.inspection)

            list_value_map = {}
            # Detect @id from the header (first row keys) only.
            # _parse_csv/_parse_delimited derive column names from line 0 and apply
            # the same keys to every row, so a single header check is sufficient.
            # Compare against the stripped key so a header like " @id" (leading
            # space) is still recognized and we do not generate a duplicate @id.
            script_has_atid = any(k.strip() == '@id' for k in script_data[0])

            for row_data in script_data:
                for k, v in row_data.items():
                    k = k.strip()
                    if k not in list_value_map:
                        list_value_map[k] = ListValue()
                    list_value_map[k].addValue(v)

                # Generate @id from pkFields+oid when script does not provide it
                if not script_has_atid and tg.pk_fields:
                    pk_parts = [_to_text(secu.oid)]
                    for pf in tg.pk_fields:
                        field_val = row_data.get(pf.strip())
                        if field_val is not None:
                            # Value objects (DecimalValue/FloatValue/TextValue) expose
                            # the underlying datum as .value; plain scalars fall through.
                            raw_val = getattr(field_val, 'value', field_val)
                            # _to_text avoids str()'s UnicodeEncodeError on Py2.
                            pk_parts.append(_to_text(raw_val))
                    # Encode to utf-8 bytes so hashFromString receives a byte str.
                    pk_str = u"".join(pk_parts).encode('utf-8', 'replace')
                    pk_hash = HashUtil.hashFromString(pk_str)
                    if '@id' not in list_value_map:
                        list_value_map['@id'] = ListValue()
                    list_value_map['@id'].addValue(DecimalValue(pk_hash))

            if list_value_map:
                for k, v in list_value_map.items():
                    p.fields.put(k, v)
                logging_util.debug("[CronJob] Sending TagCount (folded) for job {}".format(job.id))
                _send_cronjob_pack(p, job.inspection, job.id)
        else:
            for i, row_data in enumerate(script_data):
                p = tagcount_pack.TagCountPack()
                p.category = category_name
                p.pcode = secu.pcode
                p.oid = secu.oid
                # run_time (not now()) so every row of this run shares one
                # timestamp; the row index below is what separates the rows.
                p.time = run_time
                p.putTag("oname", str(secu.oname))
                _apply_inspection_tag(p, job.inspection)

                if length > 1:
                    p.putTag("row", str(i))

                # SERVER-1814: attach a stable row_id tag to flat (non-folded)
                # custom_ tagcount rows so the backend can key each row's
                # diagnostics verdict in the diagnostics_verdict table.  Folded
                # data (foldRecType==2) already carries @id+oid, so it is handled
                # in the branch above and does not need row_id.  The hash mirrors
                # the @id/pk0 derivation convention: HashUtil.hashFromString over
                # the utf-8 bytes of the joined key fields.  category is required
                # so the backend pk stays unique across custom_ categories.
                # SERVER-1977: include the row index so multi-row script output
                # keeps row_id unique within the same (time, oid, category);
                # always appended (i=0 for a single row) for consistency.
                row_id_parts = [
                    _to_text(category_name),
                    _to_text(secu.oid),
                    _to_text(p.time),
                    _to_text(i),
                ]
                row_id_str = u"".join(row_id_parts).encode('utf-8', 'replace')
                row_id = HashUtil.hashFromString(row_id_str)
                # SERVER-1963: send row_id as a number, not a string.  go-apm
                # already emits it that way (cron_job.go: Tags.PutLong) and the
                # backend is moving to MapValue.getLong("row_id") to match, so
                # unix has to follow or its rows stop resolving once the server
                # side lands.  The value is a CRC32 and therefore always fits in
                # int32, so this stays a 4-byte decimal on the wire.
                #
                # Bypass putTag() rather than changing it: putTag hardcodes
                # putString, and the other tags on this pack (oname / row / ver
                # / !rectype) are genuinely text.
                p.tags.putLong("row_id", row_id)

                for k, v in row_data.items():
                    k = k.strip()
                    p.fields.put(k, v)

                logging_util.debug("[CronJob] Sending TagCount for job {}, row {}".format(job.id, i))
                _send_cronjob_pack(p, job.inspection, job.id)

    kv = job.output.kv_table
    if kv:
        category_name = "custom_" + kv.category_name if kv.category_name else "custom_" + job.script.file_name

        # The kvtable row key (pk0, stored in the "key" field) is derived from
        # oid + pkFields, the same rule tagCount uses for @id.  Without pkFields
        # there is no stable primary key, so the kvtable cannot be built: emit an
        # error and skip this job's kvtable emission instead of sending a bogus key.
        if not kv.pk_fields:
            logging_util.error(
                "[CronJob] KvTable output for job {} requires pkFields to derive the row key; skipping kvtable emission".format(job.id))
        else:
            for i, row_data in enumerate(script_data):
                p = param_pack.ParamPack()
                p.pcode = secu.pcode
                p.oid = secu.oid
                p.time = dateutil.now()
                p.id = net.PARAM_INFRA_HOSTINFO if hasattr(net, 'PARAM_INFRA_HOSTINFO') else 0

                inventory_map = MapValue()
                for k, v in row_data.items():
                    k = k.strip()
                    inventory_map.put(k, v)

                # Derive the row key from oid + pkFields, matching the tagCount
                # @id derivation (HashUtil.hashFromString over utf-8 bytes).
                # Every pkField must be present in the row; a missing one would
                # collapse pk_str and let distinct rows collide on the same key,
                # so skip such a row instead of emitting an ambiguous key.
                pk_parts = [_to_text(secu.oid)]
                missing = False
                for pf in kv.pk_fields:
                    field_val = row_data.get(pf)
                    if field_val is None:
                        missing = True
                        break
                    # Value objects (DecimalValue/FloatValue/TextValue) expose
                    # the underlying datum as .value; plain scalars fall through.
                    raw_val = getattr(field_val, 'value', field_val)
                    # _to_text avoids str()'s UnicodeEncodeError on Py2.
                    pk_parts.append(_to_text(raw_val))
                if missing:
                    logging_util.error(
                        "[CronJob] KvTable row {} for job {} is missing one of pkFields {}; skipping row".format(
                            i, job.id, kv.pk_fields))
                    continue
                pk_str = u"".join(pk_parts).encode('utf-8', 'replace')
                pk = HashUtil.hashFromString(pk_str)

                p.put("table", TextValue(category_name))
                p.put("key", DecimalValue(pk))
                p.put("data", inventory_map)

                logging_util.debug("[CronJob] Sending KvTable for job {}, row {}".format(job.id, i))
                _send_cronjob_pack(p, job.inspection, job.id)

    logging_util.debug("[CronJob] Processed result for job {}: {} rows".format(job.id, length))


def _run_job(job, timeout):
    start_time = dateutil.now()
    _set_job_running(job.id, start_time)

    script_path = os.path.join(_cron_dir_prefix, job.script.file_name)

    if not os.path.exists(script_path):
        error = Exception("Script not found: {}".format(script_path))
        _process_result(job, "", error, start_time)
        return

    # Resolve runUser to (uid, gid) if specified.  An invalid or missing user
    # is a hard failure: the job is aborted and no fallback to the agent user
    # is performed.  This prevents accidental root execution when the operator
    # intended to restrict the job to a less-privileged account.
    uid = None
    gid = None
    run_user = getattr(job, 'run_user', '') or ''
    if run_user:
        try:
            uid, gid = _resolve_run_user(run_user)
            logging_util.debug("[CronJob] Job {} will run as user '{}' (uid={}, gid={})".format(
                job.id, run_user, uid, gid))
        except KeyError:
            error = Exception(
                "runUser '{}' not found in passwd; job aborted".format(run_user)
            )
            logging_util.error("[CronJob] Job {}: {}".format(job.id, error))
            _process_result(job, "", error, start_time)
            return

    logging_util.debug("[CronJob] Running job {}: {}".format(job.id, script_path))

    result, error = _run_command_with_timeout(script_path, timeout, uid=uid, gid=gid)
    _process_result(job, result, error, start_time)


def _calculate_timeout(job, default_timeout):
    timeout = default_timeout

    if job.trigger.cron:
        if timeout > 60:
            timeout = 60
    elif job.trigger.interval:
        if timeout > job.trigger.interval:
            timeout = job.trigger.interval
    # Windowed type reuses trigger.interval for within-window repeat period
    elif job.trigger.schedule_type and job.trigger.interval:
        if timeout > job.trigger.interval:
            timeout = job.trigger.interval

    return timeout


def _windowed_entry_matches(prev_entry, schedule_type, start_hour, start_minute,
                            duration, interval):
    """Return True if prev_entry is a windowed entry with identical config.

    Used on config reload to decide whether scheduling progress (next_run) can
    be carried over instead of reset.
    """
    return (prev_entry is not None
            and prev_entry.get('type') == SCHEDULE_TYPE_WINDOWED
            and prev_entry.get('schedule_type') == schedule_type
            and prev_entry.get('start_hour') == start_hour
            and prev_entry.get('start_minute') == start_minute
            and prev_entry.get('duration') == duration
            and prev_entry.get('interval') == interval)


def _build_windowed_schedule_entry(job, now, prev_entry=None):
    """Build the _scheduled_jobs entry dict for a windowed trigger.

    Returns None and logs an error if the trigger config is invalid.
    start_time and duration must be present; if absent the job is skipped.

    next_run defaults to now (first poll evaluates immediately). On a config
    reload where this job's windowed config is unchanged, the previous next_run
    is preserved so an unrelated config edit does not re-fire the job mid-window.
    """
    trigger = job.trigger
    start_hour, start_minute = _parse_start_time(trigger.start_time)
    if start_hour is None:
        logging_util.error(
            "[CronJob] Windowed job {} has invalid startTime '{}', skipping".format(
                job.id, trigger.start_time))
        return None

    duration = trigger.duration
    if not duration or duration <= 0:
        logging_util.error(
            "[CronJob] Windowed job {} has invalid duration '{}', skipping".format(
                job.id, duration))
        return None

    interval = trigger.interval
    if not interval or interval <= 0:
        logging_util.error(
            "[CronJob] Windowed job {} has no interval, skipping".format(job.id))
        return None

    spec_str = "windowed:{} start={} dur={}s interval={}s".format(
        trigger.schedule_type, trigger.start_time, duration, interval)
    _initialize_job_status(job, spec_str, SCHEDULE_TYPE_WINDOWED)

    next_run = now
    if _windowed_entry_matches(prev_entry, trigger.schedule_type, start_hour,
                               start_minute, duration, interval):
        next_run = prev_entry.get('next_run', now)

    return {
        'type': SCHEDULE_TYPE_WINDOWED,
        'schedule_type': trigger.schedule_type,
        'start_hour': start_hour,
        'start_minute': start_minute,
        'duration': duration,
        'interval': interval,
        'next_run': next_run,
        'job': job
    }


def _interval_entry_matches(prev_entry, interval):
    """Return True if prev_entry is an interval entry with identical config.

    Used on config reload to decide whether scheduling progress (next_run) can
    be carried over instead of reset (mirrors _windowed_entry_matches).
    """
    return (prev_entry is not None
            and prev_entry.get('type') == SCHEDULE_TYPE_INTERVAL
            and prev_entry.get('interval') == interval)


def _build_interval_schedule_entry(job, now, prev_entry=None):
    """Build the _scheduled_jobs entry dict for an interval trigger.

    next_run defaults to now (first poll fires immediately). On a config reload
    where this job's interval is unchanged, the previous next_run is preserved
    so an unrelated cronjob.json edit does not reset the interval timer and
    re-fire the job on the next poll.
    """
    interval = job.trigger.interval
    _initialize_job_status(job, str(interval), SCHEDULE_TYPE_INTERVAL)

    next_run = now
    if _interval_entry_matches(prev_entry, interval):
        next_run = prev_entry.get('next_run', now)

    return {
        'type': SCHEDULE_TYPE_INTERVAL,
        'interval': interval,
        'next_run': next_run,
        'job': job
    }


def _cron_entry_matches(prev_entry, cron_spec):
    """Return True if prev_entry is a cron entry with an identical cron spec.

    Used on config reload to decide whether scheduling progress (next_run) can
    be carried over instead of reset (mirrors _windowed_entry_matches).
    """
    return (prev_entry is not None
            and prev_entry.get('type') == SCHEDULE_TYPE_CRON
            and prev_entry.get('cron') == cron_spec)


def _build_cron_schedule_entry(job, now, prev_entry=None):
    """Build the _scheduled_jobs entry dict for a cron trigger.

    Returns None and logs an error if the cron spec cannot be parsed.

    next_run defaults to the next fire time after now. On a config reload where
    this job's cron spec is unchanged, the previous next_run is preserved so an
    unrelated cronjob.json edit does not reset an already-scheduled fire time.
    """
    cron_spec = job.trigger.cron
    _initialize_job_status(job, cron_spec, SCHEDULE_TYPE_CRON)

    if _cron_entry_matches(prev_entry, cron_spec):
        prev_next = prev_entry.get('next_run')
        if prev_next is not None:
            return {
                'type': SCHEDULE_TYPE_CRON,
                'cron': cron_spec,
                'next_run': prev_next,
                'job': job
            }

    try:
        cron = get_croniter(cron_spec, now)
        return {
            'type': SCHEDULE_TYPE_CRON,
            'cron': cron_spec,
            'next_run': cron.get_next(datetime),
            'job': job
        }
    except Exception as e:
        logging_util.error("[CronJob] Failed to parse cron spec '{}': {}".format(cron_spec, e))
        return None


def _cronjob_loop():
    global _running, _config_path, _cron_dir_prefix, _scheduled_jobs

    conf = config.GetConfig()
    _cron_dir_prefix = getattr(conf, 'CronjobPath', '/usr/whatap/infra/cronjob')
    _config_path = os.path.join(_cron_dir_prefix, getattr(conf, 'CronjobConf', 'cronjob.json'))
    default_timeout = getattr(conf, 'CronjobTimeout', 5)

    last_mtime = None
    jobs = []

    while _running:
        try:
            try:
                if not os.path.exists(_config_path):
                    cfg = _load_config(_config_path)
                    jobs = cfg.job_list
                    last_mtime = os.path.getmtime(_config_path) if os.path.exists(_config_path) else 0

                    active_job_ids = [j.id for j in jobs]
                    _cleanup_orphaned_job_status(active_job_ids)

                    prev_scheduled = _scheduled_jobs
                    _scheduled_jobs = {}
                    for job in jobs:
                        prev_entry = prev_scheduled.get(job.id)
                        if job.trigger.schedule_type:
                            entry = _build_windowed_schedule_entry(
                                job, datetime.now(), prev_entry)
                        elif job.trigger.cron:
                            entry = _build_cron_schedule_entry(
                                job, datetime.now(), prev_entry)
                        elif job.trigger.interval:
                            entry = _build_interval_schedule_entry(
                                job, datetime.now(), prev_entry)
                        else:
                            entry = None
                        if entry is not None:
                            _scheduled_jobs[job.id] = entry
                            # SERVER-1943: surface the routing decision at registration
                            # time - whether a job's results go to the inspection project
                            # is otherwise invisible until something turns up missing.
                            logging_util.info(
                                "[CronJob] Add scheduled job {}: script={} inspection={}".format(
                                    job.id, job.script.file_name, job.inspection))

                    logging_util.debug("[CronJob] Config created and loaded ({} jobs)".format(len(jobs)))

                elif os.path.exists(_config_path):
                    mtime = os.path.getmtime(_config_path)
                    if last_mtime is None or mtime > last_mtime:
                        cfg = _load_config(_config_path)
                        jobs = cfg.job_list
                        last_mtime = mtime

                        active_job_ids = [j.id for j in jobs]
                        _cleanup_orphaned_job_status(active_job_ids)

                        prev_scheduled = _scheduled_jobs
                        _scheduled_jobs = {}
                        for job in jobs:
                            prev_entry = prev_scheduled.get(job.id)
                            if job.trigger.schedule_type:
                                entry = _build_windowed_schedule_entry(
                                    job, datetime.now(), prev_entry)
                            elif job.trigger.cron:
                                entry = _build_cron_schedule_entry(
                                    job, datetime.now(), prev_entry)
                            elif job.trigger.interval:
                                entry = _build_interval_schedule_entry(
                                    job, datetime.now(), prev_entry)
                            else:
                                entry = None
                            if entry is not None:
                                _scheduled_jobs[job.id] = entry
                                # SERVER-1943: surface the routing decision at registration
                                # time - whether a job's results go to the inspection project
                                # is otherwise invisible until something turns up missing.
                                logging_util.info(
                                    "[CronJob] Add scheduled job {}: script={} inspection={}".format(
                                        job.id, job.script.file_name, job.inspection))

                        logging_util.debug("[CronJob] Config reloaded ({} jobs)".format(len(jobs)))
            except Exception as e:
                logging_util.error("[CronJob] Config load error: {}".format(e))

            now = datetime.now()
            for job_id, schedule_info in _scheduled_jobs.items():
                try:
                    if now >= schedule_info['next_run']:
                        job = schedule_info['job']

                        if schedule_info['type'] == SCHEDULE_TYPE_WINDOWED:
                            from datetime import timedelta, datetime as _dt
                            sh = schedule_info['start_hour']
                            sm = schedule_info['start_minute']
                            dur = schedule_info['duration']
                            ivl = schedule_info['interval']

                            # For cross-midnight carry-over the window started on a
                            # prior calendar day; use that anchor date for the day
                            # selector check so the job is not skipped at 00:xx.
                            anchor_date = _window_anchor_date(now, sh, sm, dur)
                            if anchor_date is not None:
                                anchor_dt = _dt(anchor_date.year, anchor_date.month,
                                                anchor_date.day, sh, sm, 0)
                            else:
                                anchor_dt = now

                            in_window = _in_time_window(now, sh, sm, dur)
                            in_day = _in_selected_day(anchor_dt,
                                                       schedule_info['schedule_type'],
                                                       job.trigger)

                            if in_window and in_day:
                                # Inside selected day and time window - execute
                                timeout = _calculate_timeout(job, default_timeout)
                                t = threading.Thread(target=_run_job, args=(job, timeout))
                                t.daemon = True
                                t.start()
                                schedule_info['next_run'] = now + timedelta(seconds=ivl)
                            else:
                                # Outside window or not a selected day - jump to next window start
                                next_ws = _next_window_start(now, sh, sm, job.trigger)
                                if next_ws is not None:
                                    schedule_info['next_run'] = next_ws
                                else:
                                    schedule_info['next_run'] = now + timedelta(seconds=ivl)
                        else:
                            timeout = _calculate_timeout(job, default_timeout)
                            t = threading.Thread(target=_run_job, args=(job, timeout))
                            t.daemon = True
                            t.start()

                            if schedule_info['type'] == SCHEDULE_TYPE_CRON:
                                cron = get_croniter(schedule_info['cron'], now)
                                schedule_info['next_run'] = cron.get_next(datetime)
                            elif schedule_info['type'] == SCHEDULE_TYPE_INTERVAL:
                                from datetime import timedelta
                                schedule_info['next_run'] = now + timedelta(seconds=schedule_info['interval'])
                except Exception as e:
                    logging_util.error("[CronJob] Job execution error for {}: {}".format(job_id, e))

            time.sleep(CONFIG_POLL_INTERVAL)

        except Exception as e:
            logging_util.error("[CronJob] Main loop error: {}".format(e))
            time.sleep(CONFIG_POLL_INTERVAL)


def start_cronjob():
    global _running, _cronjob_thread

    conf = config.GetConfig()

    if not getattr(conf, 'CronjobEnabled', False):
        logging_util.info("[CronJob] Disabled by configuration")
        return

    if croniter is None:
        logging_util.info("[CronJob] croniter library not available, using built-in SimpleCroniter")

    _running = True
    _cronjob_thread = threading.Thread(target=_cronjob_loop)
    _cronjob_thread.daemon = True
    _cronjob_thread.start()

    logging_util.info("[CronJob] Started")


def stop_cronjob():
    global _running
    _running = False
    logging_util.info("[CronJob] Stopped")


def get_cronjob_status():
    with _job_status_lock:
        return [
            {
                'id': s.id,
                'schedule': s.schedule,
                'scheduleType': s.schedule_type,
                'lastExecuteStartTime': s.last_execute_start_time,
                'lastExecuteStatus': s.last_execute_status,
                'lastExecuteDuration': s.last_execute_duration,
                'errorMessage': s.error_message
            }
            for s in _job_status_map.values()
        ]


def get_cron_config_path(path=""):
    conf = config.GetConfig()
    cron_dir = getattr(conf, 'CronjobPath', '/usr/whatap/infra/cronjob')
    if path:
        return os.path.join(cron_dir, path)
    return cron_dir


def _safe_write_atomic(path, content, make_executable=False):
    dir_path = os.path.dirname(path)
    base_name = os.path.basename(path)
    tmp_path = os.path.join(dir_path, "." + base_name + ".tmp")

    if not os.path.exists(dir_path):
        os.makedirs(dir_path, 0o755)

    with open(tmp_path, 'w') as f:
        f.write(content)
        f.flush()
        os.fsync(f.fileno())

    if make_executable:
        os.chmod(tmp_path, 0o755)
    else:
        os.chmod(tmp_path, 0o644)

    if os.path.exists(path):
        os.remove(path)
    os.rename(tmp_path, path)


def _safe_write_json_atomic(path, data):
    content = json.dumps(data, indent=2)
    if not content.strip().startswith('{'):
        raise ValueError("JSON must be an object at top-level")
    _safe_write_atomic(path, content, make_executable=False)


def _serialize_tag_count(tg):
    """Serialize an OutputTagCount object to a JSON-compatible dict.

    Preserves pkFields so the config round-trips correctly.
    """
    d = {
        'categoryName': tg.category_name,
        'columnMap': {k: {'type': v.type, 'required': v.required}
                      for k, v in tg.column_map.items()},
        'foldRecType': tg.fold_rec_type,
    }
    if tg.pk_fields:
        d['pkFields'] = list(tg.pk_fields)
    return d


def _serialize_kv_table(kv):
    """Serialize an OutputKvTable object to a JSON-compatible dict.

    Preserves pkFields so the config round-trips correctly.
    """
    d = {
        'categoryName': kv.category_name,
        'columnMap': {k: {'type': v.type, 'required': v.required}
                      for k, v in kv.column_map.items()},
    }
    if kv.pk_fields:
        d['pkFields'] = list(kv.pk_fields)
    return d


class DuplicateScriptFileError(Exception):
    """Raised when a job registration would reuse a script file name that is
    already owned by a different job.

    Jobs are deduplicated by id only, so two jobs sharing one script file
    name used to silently overwrite each other's script file, leaving a
    single script executed on two schedules.  Such registrations must be
    rejected before any file is written.
    """
    pass


def _check_duplicate_script_file(new_job_list, old_job_list):
    """Reject registrations whose script fileName collides with another job.

    The check runs against the FINAL state after the incoming job list is
    merged over the existing config (merge replaces entries by job id), so:
    - A job that keeps its own file name (same job id) is never a conflict.
    - A job renamed in this request releases its previous file name, which
      may then be taken by another job in the same request.
    - A conflict is raised when an incoming file name is also held, in the
      final state, by a job with a different id (including two jobs inside
      one request sharing a file name).

    Raises DuplicateScriptFileError on the first conflict found.
    """
    final_files = {}
    for j in old_job_list:
        fname = j.script.file_name if j.script else ''
        if fname:
            final_files[j.id] = fname
    for job_data in new_job_list:
        job_id = job_data.get('id', '')
        fname = (job_data.get('script') or {}).get('fileName', '')
        if fname:
            final_files[job_id] = fname
        else:
            final_files.pop(job_id, None)

    for job_data in new_job_list:
        job_id = job_data.get('id', '')
        fname = (job_data.get('script') or {}).get('fileName', '')
        if not fname:
            continue
        for other_id, other_fname in final_files.items():
            if other_id != job_id and other_fname == fname:
                raise DuplicateScriptFileError(
                    "script file name '{}' is already used by job '{}'; "
                    "registration of job '{}' rejected".format(
                        fname, other_id, job_id))


def save_script_file(new_cfg):
    conf = config.GetConfig()
    cron_dir = getattr(conf, 'CronjobPath', '/usr/whatap/infra/cronjob')
    config_path = os.path.join(cron_dir, getattr(conf, 'CronjobConf', 'cronjob.json'))

    job_list = new_cfg.get('jobList', [])

    # Validate before writing anything so a duplicate file name never
    # overwrites an existing job's script file.
    old_cfg = _load_config(config_path)
    _check_duplicate_script_file(job_list, old_cfg.job_list)
    for job in job_list:
        job_id = job.get('id', '')
        script = job.get('script', {})
        script_config = job.get('config', {})

        file_name = script.get('fileName', '')
        content_b64 = script.get('content', '')

        if file_name and content_b64:
            try:
                script_content = base64.b64decode(content_b64)
                if isinstance(script_content, bytes):
                    script_content = script_content.decode('utf-8', 'replace')
            except Exception as e:
                logging_util.error("[CronJob] SaveScriptFile invalid base64 content ({} : {}): {}".format(
                    job_id, file_name, e))
                raise e

            script_path = os.path.join(cron_dir, file_name)
            logging_util.info("[CronJob] SaveScriptFile scriptPath: {}, content size: {}".format(
                script_path, len(script_content)))

            try:
                _safe_write_atomic(script_path, script_content, make_executable=True)
                logging_util.info("[CronJob] SaveScriptFile success: {}".format(script_path))
            except Exception as e:
                logging_util.error("[CronJob] SaveScriptFile failed ({} : {}): {}".format(
                    job_id, file_name, e))
                raise e

            script['content'] = ""

        config_file_name = script_config.get('fileName', '')
        config_content_b64 = script_config.get('content', '')

        if config_file_name and config_content_b64:
            try:
                config_content = base64.b64decode(config_content_b64)
                if isinstance(config_content, bytes):
                    config_content = config_content.decode('utf-8', 'replace')
            except Exception as e:
                logging_util.error("[CronJob] SaveScriptConfig invalid base64 content ({} : {}): {}".format(
                    job_id, config_file_name, e))
                raise e

            config_path = os.path.join(cron_dir, config_file_name)
            try:
                _safe_write_atomic(config_path, config_content, make_executable=False)
                logging_util.info("[CronJob] SaveScriptConfig success: {}".format(config_path))
            except Exception as e:
                logging_util.error("[CronJob] SaveScriptConfig failed ({} : {}): {}".format(
                    job_id, config_file_name, e))
                raise e

            script_config['content'] = ""


def _serialize_trigger(trigger):
    """Serialize a Trigger object to a JSON-compatible dict.

    Includes all windowed fields when present so they are not lost on save.
    None-valued keys are omitted to keep the JSON compact.
    """
    d = {}
    if trigger.cron is not None:
        d['cron'] = trigger.cron
    if trigger.interval is not None:
        d['interval'] = trigger.interval
    if trigger.schedule_type is not None:
        d['scheduleType'] = trigger.schedule_type
    if trigger.start_time is not None:
        d['startTime'] = trigger.start_time
    if trigger.duration is not None:
        d['duration'] = trigger.duration
    if trigger.days:
        d['days'] = list(trigger.days)
    return d


def save_cron_config(new_cfg):
    global _config_path

    conf = config.GetConfig()
    cron_dir = getattr(conf, 'CronjobPath', '/usr/whatap/infra/cronjob')
    config_path = os.path.join(cron_dir, getattr(conf, 'CronjobConf', 'cronjob.json'))

    logging_util.info("[CronJob] SaveCronConfig called")

    old_cfg = _load_config(config_path)
    old_job_list = old_cfg.job_list if hasattr(old_cfg, 'job_list') else []

    idx = {}
    for i, j in enumerate(old_job_list):
        idx[j.id] = i

    new_job_list = new_cfg.get('jobList', [])

    # Same guard as save_script_file: never register two jobs that point at
    # one script file (kept here as well so direct callers are also safe).
    _check_duplicate_script_file(new_job_list, old_job_list)

    merged_jobs = list(old_job_list)

    for new_job_data in new_job_list:
        new_job = Job(new_job_data)
        new_job.script.content = ""
        if new_job.script_config:
            new_job.script_config.content = ""

        if new_job.id in idx:
            merged_jobs[idx[new_job.id]] = new_job
        else:
            merged_jobs.append(new_job)

    merged_cfg = {
        'jobList': [
            {
                'id': j.id,
                'runUser': getattr(j, 'run_user', '') or '',
                'inspection': bool(getattr(j, 'inspection', False)),
                'script': {
                    'fileName': j.script.file_name,
                    'type': j.script.type,
                    'delimiter': j.script.delimiter,
                    'content': ''
                },
                'config': {
                    'fileName': j.script_config.file_name if j.script_config else '',
                    'content': ''
                },
                'trigger': _serialize_trigger(j.trigger) if j.trigger else {},
                'output': {
                    'tagCount': _serialize_tag_count(j.output.tag_count) if j.output and j.output.tag_count else None,
                    'kvTable': _serialize_kv_table(j.output.kv_table) if j.output and j.output.kv_table else None
                } if j.output else {}
            }
            for j in merged_jobs
        ]
    }

    for job_data in merged_cfg['jobList']:
        if job_data['output'].get('tagCount') is None:
            del job_data['output']['tagCount']
        if job_data['output'].get('kvTable') is None:
            del job_data['output']['kvTable']
        # SERVER-1943: omit the flag when false so files written for jobs that
        # predate it keep their existing shape and produce no spurious diff.
        if not job_data.get('inspection'):
            job_data.pop('inspection', None)

    logging_util.info("[CronJob] SaveCronConfig merged config to save")

    try:
        _safe_write_json_atomic(config_path, merged_cfg)
        logging_util.info("[CronJob] SaveCronConfig success: {}".format(config_path))
    except Exception as e:
        logging_util.error("[CronJob] SaveCronConfig failed: {}".format(e))
        raise e


def delete_cron_config(job_ids):
    global _config_path

    conf = config.GetConfig()
    cron_dir = getattr(conf, 'CronjobPath', '/usr/whatap/infra/cronjob')
    config_path = os.path.join(cron_dir, getattr(conf, 'CronjobConf', 'cronjob.json'))

    old_cfg = _load_config(config_path)
    old_job_list = old_cfg.job_list if hasattr(old_cfg, 'job_list') else []

    jobs_to_delete = set(job_ids) if isinstance(job_ids, list) else set(job_ids.keys())

    remaining_jobs = []
    for job in old_job_list:
        if job.id in jobs_to_delete:
            _remove_job_status(job.id)
            logging_util.debug("[CronJob] DeleteCronConfig removing job: {}".format(job.id))
        else:
            remaining_jobs.append(job)

    remaining_cfg = {
        'jobList': [
            {
                'id': j.id,
                'runUser': getattr(j, 'run_user', '') or '',
                'inspection': bool(getattr(j, 'inspection', False)),
                'script': {
                    'fileName': j.script.file_name,
                    'type': j.script.type,
                    'delimiter': j.script.delimiter,
                    'content': ''
                },
                'config': {
                    'fileName': j.script_config.file_name if j.script_config else '',
                    'content': ''
                },
                'trigger': _serialize_trigger(j.trigger) if j.trigger else {},
                'output': {
                    'tagCount': _serialize_tag_count(j.output.tag_count) if j.output and j.output.tag_count else None,
                    'kvTable': _serialize_kv_table(j.output.kv_table) if j.output and j.output.kv_table else None
                } if j.output else {}
            }
            for j in remaining_jobs
        ]
    }

    for job_data in remaining_cfg['jobList']:
        if job_data['output'].get('tagCount') is None:
            del job_data['output']['tagCount']
        if job_data['output'].get('kvTable') is None:
            del job_data['output']['kvTable']
        # SERVER-1943: omit the flag when false so files written for jobs that
        # predate it keep their existing shape and produce no spurious diff.
        if not job_data.get('inspection'):
            job_data.pop('inspection', None)

    try:
        _safe_write_json_atomic(config_path, remaining_cfg)
        logging_util.info("[CronJob] DeleteCronConfig success: {}".format(config_path))
    except Exception as e:
        logging_util.error("[CronJob] DeleteCronConfig failed: {}".format(e))
        raise e
