import os
import sys
import json
import time
import signal
import subprocess
import threading
import csv
import base64
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)

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
from whatap.util.date_util import DateUtil as dateutil
from whatap.util.hash_util import HashUtil
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"

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')


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)


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)


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', {}))


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 _run_command_with_timeout(script_path, timeout):
    result = ""
    error = None
    output_data = [None]
    proc_error = [None]

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

        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


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

        if tg.fold_rec_type == 2:
            p = tagcount_pack.TagCountPack()
            p.category = category_name
            p.pcode = secu.pcode
            p.oid = secu.oid
            p.time = dateutil.now()
            p.putTag("oname", str(secu.oname))
            p.putTag("!rectype", "2")

            list_value_map = {}
            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)

            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))
                data.SendHide(p)
        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
                p.time = dateutil.now()
                p.putTag("oname", str(secu.oname))

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

                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))
                data.SendHide(p)

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

        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)

            key_data = "{}-{}-{}-{}".format(secu.oid, p.time, job.id, i if length > 1 else "")
            pk = HashUtil.hash(key_data.encode('utf-8'))

            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))
            data.SendHide(p)

    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

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

    result, error = _run_command_with_timeout(script_path, timeout)
    _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

    return timeout


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)

                    _scheduled_jobs = {}
                    for job in jobs:
                        if job.trigger.cron:
                            _initialize_job_status(job, job.trigger.cron, SCHEDULE_TYPE_CRON)
                            try:
                                cron = get_croniter(job.trigger.cron, datetime.now())
                                _scheduled_jobs[job.id] = {
                                    'type': SCHEDULE_TYPE_CRON,
                                    'cron': job.trigger.cron,
                                    'next_run': cron.get_next(datetime),
                                    'job': job
                                }
                            except Exception as e:
                                logging_util.error("[CronJob] Failed to parse cron spec '{}': {}".format(job.trigger.cron, e))
                        elif job.trigger.interval:
                            _initialize_job_status(job, str(job.trigger.interval), SCHEDULE_TYPE_INTERVAL)
                            _scheduled_jobs[job.id] = {
                                'type': SCHEDULE_TYPE_INTERVAL,
                                'interval': job.trigger.interval,
                                'next_run': datetime.now(),
                                'job': job
                            }

                    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)

                        _scheduled_jobs = {}
                        for job in jobs:
                            if job.trigger.cron:
                                _initialize_job_status(job, job.trigger.cron, SCHEDULE_TYPE_CRON)
                                try:
                                    cron = get_croniter(job.trigger.cron, datetime.now())
                                    _scheduled_jobs[job.id] = {
                                        'type': SCHEDULE_TYPE_CRON,
                                        'cron': job.trigger.cron,
                                        'next_run': cron.get_next(datetime),
                                        'job': job
                                    }
                                except Exception as e:
                                    logging_util.error("[CronJob] Failed to parse cron spec '{}': {}".format(job.trigger.cron, e))
                            elif job.trigger.interval:
                                _initialize_job_status(job, str(job.trigger.interval), SCHEDULE_TYPE_INTERVAL)
                                _scheduled_jobs[job.id] = {
                                    'type': SCHEDULE_TYPE_INTERVAL,
                                    'interval': job.trigger.interval,
                                    'next_run': datetime.now(),
                                    'job': job
                                }

                        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']
                        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 save_script_file(new_cfg):
    conf = config.GetConfig()
    cron_dir = getattr(conf, 'CronjobPath', '/usr/whatap/infra/cronjob')

    job_list = new_cfg.get('jobList', [])
    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 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', [])
    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,
                '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': {
                    'cron': j.trigger.cron,
                    'interval': j.trigger.interval
                } if j.trigger else {},
                'output': {
                    'tagCount': {
                        'categoryName': j.output.tag_count.category_name,
                        'columnMap': {k: {'type': v.type, 'required': v.required}
                                      for k, v in j.output.tag_count.column_map.items()},
                        'foldRecType': j.output.tag_count.fold_rec_type
                    } if j.output and j.output.tag_count else None,
                    'kvTable': {
                        'categoryName': j.output.kv_table.category_name,
                        'columnMap': {k: {'type': v.type, 'required': v.required}
                                      for k, v in j.output.kv_table.column_map.items()}
                    } 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']
        if job_data['trigger'].get('cron') is None:
            del job_data['trigger']['cron']
        if job_data['trigger'].get('interval') is None:
            del job_data['trigger']['interval']

    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,
                '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': {
                    'cron': j.trigger.cron,
                    'interval': j.trigger.interval
                } if j.trigger else {},
                'output': {
                    'tagCount': {
                        'categoryName': j.output.tag_count.category_name,
                        'columnMap': {k: {'type': v.type, 'required': v.required}
                                      for k, v in j.output.tag_count.column_map.items()},
                        'foldRecType': j.output.tag_count.fold_rec_type
                    } if j.output and j.output.tag_count else None,
                    'kvTable': {
                        'categoryName': j.output.kv_table.category_name,
                        'columnMap': {k: {'type': v.type, 'required': v.required}
                                      for k, v in j.output.kv_table.column_map.items()}
                    } 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']
        if job_data['trigger'].get('cron') is None:
            del job_data['trigger']['cron']
        if job_data['trigger'].get('interval') is None:
            del job_data['trigger']['interval']

    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
