#!/usr/bin/env python3
"""
voxl-vins-autoconf -- inspect, validate and auto-configure the voxl-open-vins-server
.conf + VoxlConfig yaml tree as ONE coherent set.

The server's configuration is split across /etc/modalai/voxl-open-vins-server.conf and the
yaml folder it points at (estimator_config.yaml + kalibr chains). The two drift: a conf from
one era pointing at a yaml tree from another, a chain the server's strict ChainYaml parser
refuses at boot, a rolling-shutter camera with a zero readout, calib hot-start enabled with no
committed snapshot. Every one of those is a boot abort or a silent behavior change. This tool
makes the whole set coherent BEFORE the server ever runs, and explains what it found in plain
language.

MODES
  voxl-vins-autoconf                       inspect + validate the live system (DRY RUN)
  voxl-vins-autoconf --apply               fix everything fixable (idempotent; .bak saved once)
  voxl-vins-autoconf --profile fpvhiresM   point yaml_folder at a shipped VoxlConfig profile
                                           (+ validate the pair; conf keys untouched otherwise)
  voxl-vins-autoconf --rs-convention center
                                           write the RS row-anchor convention into the
                                           estimator yaml (top | center | bottom -- the ONE
                                           knob that drives both frame stamps and the filter's
                                           RS model; see VoxlIngest.h frame_timestamp_s)
  voxl-vins-autoconf --enable-zcal | --disable-zcal
                                           flip the calibration hot-start group as a set
                                           (en_calib_hotstart / calibrate_first /
                                           calib_restore_governor / calib_hotstart_on_reset)
  voxl-vins-autoconf --log <dir>           host mode: validate a voxl-logger capture's
                                           config tree, report its stamp-convention era, and
                                           print the exact voxl-vins-lab invocation for it

EDITING DOCTRINE (matches the server's own): the conf is edited textually -- only the value
of a targeted key changes, comments and layout survive; yaml gets the same patch_yaml_scalar
treatment the live sync pass uses (single-line values, trailing comments preserved, %YAML:1.0
prepended when absent). Writes are atomic (tmp + rename) and --apply saves a one-time .bak.
Nothing is written without --apply / an explicit action flag.

EXIT CODES: 0 = coherent, 1 = findings reported (dry run) or fixed with warnings remaining,
2 = hard error (unparseable file, unknown profile, ...).
"""

import argparse
import json
import os
import re
import shutil
import sys

CONF_DEFAULT = "/etc/modalai/voxl-open-vins-server.conf"
VOXLCONFIG_ROOT = "/usr/share/modalai/voxl-open-vins/VoxlConfig"
SNAPSHOT_PATH = "/data/modalai/ov_calib/ov_calib_result.yaml"

# zcal-generation conf keys, appended to the live conf on the first post-upgrade boot.
# Their ABSENCE from a captured conf identifies a pre-flip (raw-SOF-stamp) recording.
ZCAL_KEYS = ["en_calib_hotstart", "calibrate_first",
             "calib_restore_governor", "calib_hotstart_on_reset"]

RS_STAMP_FORMULA = {
    "top": "t = HAL3 SOF (raw; that era's calibrations absorb exposure into td)",
    "center": "t = HAL3 SOF + (readout + exposure)/2   (center-row mid-exposure)",
    "bottom": "t = HAL3 SOF + readout + exposure/2     (last-row mid-exposure)",
}

# ---------------------------------------------------------------------------- findings
FINDINGS = []          # (severity, code, message, fix_fn or None)
SEV_ORDER = {"FAIL": 0, "WARN": 1, "INFO": 2}


def finding(severity, code, message, fix=None):
    FINDINGS.append((severity, code, message, fix))


def report_and_apply(apply_fixes):
    if not FINDINGS:
        print("[ OK ] configuration is coherent -- nothing to do")
        return 0
    FINDINGS.sort(key=lambda f: SEV_ORDER[f[0]])
    fixed = 0
    remaining = 0
    for severity, code, message, fix in FINDINGS:
        tag = severity
        if fix is not None and apply_fixes:
            try:
                fix()
                tag = "FIX "
                fixed += 1
            except Exception as e:  # a failed fix is a real failure, never silent
                tag = "FAIL"
                message += "  (fix FAILED: %s)" % e
                remaining += 1
        elif severity != "INFO":
            remaining += 1
        fixable = "" if (fix is None or apply_fixes) else "  [fixable: --apply]"
        print("[%s] %-22s %s%s" % (tag, code, message, fixable))
    if apply_fixes and fixed:
        print("applied %d fix(es)" % fixed)
    return 1 if remaining else 0


# ---------------------------------------------------------------------------- file plumbing
def read_text(path):
    with open(path, "r", encoding="utf-8", errors="replace") as f:
        return f.read()


def write_atomic(path, content):
    """tmp + rename in the target dir; never leaves a truncated file behind."""
    tmp = path + ".autoconf.tmp"
    with open(tmp, "w", encoding="utf-8") as f:
        f.write(content)
        f.flush()
        os.fsync(f.fileno())
    os.replace(tmp, path)


def backup_once(path):
    bak = path + ".bak"
    if os.path.exists(path) and not os.path.exists(bak):
        shutil.copy2(path, bak)


# ---------------------------------------------------------------------------- conf (modal_json)
def strip_json_comments(text):
    """modal_json tolerates // and /* */ comments; python's json does not."""
    text = re.sub(r"/\*.*?\*/", "", text, flags=re.S)
    # avoid eating "//" inside strings: strip only comments outside quotes
    out_lines = []
    for line in text.splitlines():
        in_str = False
        prev = ""
        cut = len(line)
        for i, ch in enumerate(line):
            if ch == '"' and prev != "\\":
                in_str = not in_str
            if not in_str and ch == "/" and i + 1 < len(line) and line[i + 1] == "/":
                cut = i
                break
            prev = ch
        out_lines.append(line[:cut])
    return "\n".join(out_lines)


def parse_conf(path):
    """-> (dict, raw_text). Raises on unparseable json (a boot abort on the device too)."""
    raw = read_text(path)
    return json.loads(strip_json_comments(raw)), raw


def conf_set_key(raw, key, json_value):
    """Textual value replacement for one top-level key; appends before the closing brace when
    absent (comma fixup included). json_value is the literal text to write (already encoded)."""
    pat = re.compile(r'^(\s*"%s"\s*:\s*)([^,\n]*)(,?)(\s*(?://.*)?)$' % re.escape(key), re.M)
    if pat.search(raw):
        return pat.sub(lambda m: m.group(1) + json_value + m.group(3) + m.group(4), raw, count=1)
    # append: put it before the final closing brace, comma-terminating the previous entry
    close = raw.rfind("}")
    if close < 0:
        raise ValueError("conf has no closing brace")
    head = raw[:close].rstrip()
    if head.endswith(","):
        entry = '\n\t"%s":\t%s\n' % (key, json_value)
    elif head.endswith("{"):
        entry = '\n\t"%s":\t%s\n' % (key, json_value)
    else:
        head += ","
        entry = '\n\t"%s":\t%s\n' % (key, json_value)
    return head + entry + raw[close:]


# ---------------------------------------------------------------------------- yaml (OpenCV style)
def yaml_ensure_directive(text):
    """Prepend %YAML:1.0 unless the first non-blank (BOM-skipped) token is a %YAML directive --
    the one thing cv::FileStorage needs that pristine kalibr files lack (mirrors the lab)."""
    p = 0
    if text[:3] == "﻿":
        p = 3
    while p < len(text) and text[p] in "\r\n \t":
        p += 1
    if text[p:p + 5] == "%YAML":
        return text[p:] if p else text, False
    return "%YAML:1.0\n\n" + text[p:], True


def yaml_get_scalar(text, key):
    """Value string of a single-line top-level scalar key, else None."""
    m = re.search(r'^%s\s*:\s*([^#\n]*)' % re.escape(key), text, re.M)
    return m.group(1).strip().strip('"') if m else None


def yaml_patch_scalar(text, key, value, append_comment=None):
    """The live patch_yaml_scalar semantics: replace the value region of an uncommented,
    line-starting 'key:' occurrence, preserving any trailing comment. Appends 'key: value'
    (plus an optional comment line) at EOF when the key is absent."""
    pat = re.compile(r'^(%s\s*:\s*)([^#\n]*?)(\s*(?:#.*)?)$' % re.escape(key), re.M)
    if pat.search(text):
        return pat.sub(lambda m: m.group(1) + str(value) + m.group(3), text, count=1)
    block = ""
    if append_comment:
        block += "\n" + "\n".join("# " + c for c in append_comment.split("\n"))
    block += "\n%s: %s\n" % (key, value)
    return text.rstrip("\n") + block


def chain_validate(text, path):
    """Mirror ChainYaml's strictness: top-level camN/imuN maps whose entries are single-line
    scalars, flat [..] vectors, or a nested block of '- [..]' matrix rows. Anything deeper is
    a BOOT ABORT on the live server (sync_cam_config refuses) -- report, don't guess."""
    cams = []
    problems = []
    cur = None
    pending_mat_key = None
    for ln, line in enumerate(text.splitlines(), 1):
        if not line.strip() or line.lstrip().startswith("#") or line.startswith("%YAML"):
            continue
        indent = len(line) - len(line.lstrip(" "))
        s = line.strip()
        if indent == 0:
            m = re.match(r"^([A-Za-z0-9_]+)\s*:\s*(.*)$", s)
            if not m:
                problems.append("%s:%d: unparseable top-level line" % (path, ln))
                cur = None
                continue
            cur, rest = m.group(1), m.group(2).split("#")[0].strip()
            pending_mat_key = None
            if rest:
                problems.append("%s:%d: top-level '%s' is not a map" % (path, ln, cur))
            elif re.match(r"^cam\d+$", cur):
                cams.append(cur)
        elif cur is not None:
            if s.startswith("- ["):
                if pending_mat_key is None:
                    problems.append("%s:%d: matrix row outside a matrix key" % (path, ln))
                continue
            m = re.match(r"^([A-Za-z0-9_]+)\s*:\s*(.*)$", s)
            if not m:
                problems.append("%s:%d: unparseable entry under '%s'" % (path, ln, cur))
                continue
            val = m.group(2).split("#")[0].strip()
            pending_mat_key = m.group(1) if val == "" else None
            if val.startswith("[") and not val.endswith("]"):
                problems.append("%s:%d: multi-line flow sequence (ChainYaml refuses)" % (path, ln))
    return cams, problems


# ---------------------------------------------------------------------------- estimator yaml checks
# A ':' inside a TRAILING comment makes cv::FileStorage split the line and parse the node as a
# MAP -- the key then silently reads as its default (or, for strict keys like rs_convention,
# aborts the boot). Own-line comments are safe whatever they contain.
TRAILING_COLON = re.compile(r'^([A-Za-z0-9_]+:\s*(?:"[^"]*"|[^#\n]*?))\s+(#.*)$')


def hoist_trailing_colon_comments(text):
    """-> (fixed_text, offending_keys): move colon-bearing trailing comments above their key."""
    out, keys = [], []
    for line in text.splitlines():
        m = TRAILING_COLON.match(line)
        if m and ":" in m.group(2)[1:]:
            out.append(m.group(2))
            out.append(m.group(1).rstrip())
            keys.append(m.group(1).split(":")[0])
        else:
            out.append(line)
    return "\n".join(out) + "\n", keys


def check_estimator_yaml(est_path, chain_cams):
    text = read_text(est_path)

    fixed_text, needs_directive = yaml_ensure_directive(text)
    if needs_directive:
        finding("FAIL", "yaml-directive",
                "%s lacks the %%YAML:1.0 directive (cv::FileStorage refuses it)" % est_path,
                fix=lambda p=est_path, t=fixed_text: (backup_once(p), write_atomic(p, t)))
        text = fixed_text

    hoisted, bad_keys = hoist_trailing_colon_comments(text)
    if bad_keys:
        finding("FAIL", "comment-colon",
                "%s: ':' inside the trailing comment of %s -- cv::FileStorage splits there and "
                "the key silently reads as its DEFAULT (fix hoists the comment to its own line)"
                % (est_path, ", ".join(bad_keys)),
                fix=lambda p=est_path, t=hoisted: (backup_once(p), write_atomic(p, t)))
        text = hoisted

    max_cams = yaml_get_scalar(text, "max_cameras")
    if max_cams is not None and chain_cams is not None:
        try:
            if int(max_cams) > len(chain_cams):
                finding("FAIL", "max-cameras",
                        "max_cameras=%s but the chain defines only %d camera(s)"
                        % (max_cams, len(chain_cams)))
        except ValueError:
            finding("FAIL", "max-cameras", "max_cameras is not an integer: %r" % max_cams)

    rs_conv = yaml_get_scalar(text, "rs_convention")
    if rs_conv is None:
        # absent = center by parse default; write it explicitly so the file SAYS what it does
        finding("WARN", "rs-convention",
                "%s carries no rs_convention key (parse default: center) -- write it explicitly"
                % est_path,
                fix=lambda p=est_path: _write_rs_convention(p, "center"))
        rs_conv = "center"
    elif rs_conv not in RS_STAMP_FORMULA:
        finding("FAIL", "rs-convention",
                "invalid rs_convention '%s' (top|center|bottom) -- the estimator exits on this"
                % rs_conv)
        rs_conv = None

    n = int(max_cams) if (max_cams or "").isdigit() else 0
    for i in range(max(n, 1)):
        shutter = yaml_get_scalar(text, "cam%d_shutter" % i)
        readout = yaml_get_scalar(text, "cam%d_readout_time_s" % i)
        fps = yaml_get_scalar(text, "cam%d_fps" % i)
        try:
            readout_f = float(readout) if readout is not None else 0.0
        except ValueError:
            finding("FAIL", "readout", "cam%d_readout_time_s is not a number: %r" % (i, readout))
            continue
        if shutter == "rolling" and readout_f <= 0.0:
            finding("FAIL", "readout",
                    "cam%d declared rolling with readout %.6g -- silent 0 = unmodeled RS skew "
                    "on every row (set the HAL3 skew of the streamed mode)" % (i, readout_f))
        if shutter == "global" and readout_f != 0.0:
            finding("WARN", "readout",
                    "cam%d declared global with nonzero readout %.6g -- the parse zeroes it, "
                    "make the file say what runs" % (i, readout_f),
                    fix=lambda p=est_path, k="cam%d_readout_time_s" % i:
                        _patch_yaml_key(p, k, "0.0"))
        if fps is not None and readout is not None and shutter == "rolling":
            try:
                if readout_f > 1.0 / float(fps):
                    finding("FAIL", "readout",
                            "cam%d readout %.6gs exceeds the frame period at %s fps" %
                            (i, readout_f, fps))
            except (ValueError, ZeroDivisionError):
                pass
        calib_ro = yaml_get_scalar(text, "calib_cam_readout")
        if shutter == "rolling" and calib_ro == "true":
            finding("WARN", "calib-readout",
                    "calib_cam_readout=true on a rolling declaration -- readout is HAL3 "
                    "hardware truth on this platform; online refinement re-opens dt aliasing",
                    fix=lambda p=est_path: _patch_yaml_key(p, "calib_cam_readout", "false"))
    return rs_conv, yaml_get_scalar(text, "use_stereo")


def _patch_yaml_key(path, key, value):
    backup_once(path)
    write_atomic(path, yaml_patch_scalar(read_text(path), key, value))


def _write_rs_convention(path, value):
    backup_once(path)
    comment = ("RS row-anchor convention: which image row the frame stamp refers to; drives\n"
               "BOTH the producer stamps and the filter's RS model (one knob, cannot disagree).\n"
               "top = raw HAL3 SOF (pre-flip system / vins-lab era), center = center-row\n"
               "mid-exposure (default), bottom = last-row mid-exposure.")
    write_atomic(path, yaml_patch_scalar(read_text(path), "rs_convention", value,
                                         append_comment=comment))


# ---------------------------------------------------------------------------- tree validation
def validate_tree(conf_path, yaml_dir, device_mode):
    """Run every check over one conf + yaml folder pair. Returns the parsed conf dict."""
    conf = {}
    if os.path.exists(conf_path):
        try:
            conf, _raw = parse_conf(conf_path)
        except Exception as e:
            finding("FAIL", "conf-parse", "%s does not parse: %s" % (conf_path, e))
            return None
    else:
        finding("WARN", "conf-missing",
                "%s does not exist (the server would create defaults on first boot)" % conf_path)

    if yaml_dir is None:
        yaml_dir = conf.get("yaml_folder", VOXLCONFIG_ROOT + "/starling2")
    if not os.path.isdir(yaml_dir):
        finding("FAIL", "yaml-folder", "yaml_folder does not exist: %s (boot abort)" % yaml_dir)
        return conf

    est = os.path.join(yaml_dir, "estimator_config.yaml")
    imu_chain = os.path.join(yaml_dir, "kalibr_imu_chain.yaml")
    cam_chain = os.path.join(yaml_dir, "kalibr_imucam_chain.yaml")
    for p in (est, imu_chain, cam_chain):
        if not os.path.exists(p):
            finding("FAIL", "yaml-missing", "missing %s (boot abort)" % p)
    if not os.path.exists(est) or not os.path.exists(cam_chain):
        return conf

    # chain strictness (ChainYaml refuses => _quit(-1) at boot)
    chain_text = read_text(cam_chain)
    fixed_chain, chain_needs_dir = yaml_ensure_directive(chain_text)
    if chain_needs_dir:
        finding("FAIL", "yaml-directive",
                "%s lacks the %%YAML:1.0 directive (ChainYaml/cv::FileStorage refuse it "
                "-- boot abort on sync)" % cam_chain,
                fix=lambda p=cam_chain, t=fixed_chain: (backup_once(p), write_atomic(p, t)))
        chain_text = fixed_chain
    cams, problems = chain_validate(chain_text, cam_chain)
    for p in problems:
        finding("FAIL", "chain-structure", p + " -- the live sync pass refuses this at boot")

    rs_conv, use_stereo = check_estimator_yaml(est, cams)

    # conf <-> yaml coherence
    if conf:
        using_stereo = conf.get("using_stereo", 0)
        if use_stereo == "true" and not using_stereo:
            finding("WARN", "stereo-coherence",
                    "estimator use_stereo=true but conf using_stereo=0 -- stereo constraints "
                    "want the stacked-stereo pipe layout")
        if use_stereo == "false" and using_stereo:
            finding("INFO", "stereo-coherence",
                    "conf using_stereo=1 (stacked pipes) with estimator use_stereo=false: "
                    "cameras arrive stacked but track as independent monos")
        for k in ZCAL_KEYS:
            if k not in conf:
                finding("WARN", "zcal-keys",
                        "conf lacks '%s' (pre-flip-era conf; the server appends defaults on "
                        "next boot)" % k,
                        fix=lambda cp=conf_path, key=k: _conf_write_key(cp, key, "false"))
        if conf.get("en_calib_hotstart") and device_mode and not os.path.exists(SNAPSHOT_PATH):
            finding("WARN", "hotstart-snapshot",
                    "en_calib_hotstart=true but no committed snapshot at %s -- boot falls back "
                    "to the raw chain (run --calibrate, or --disable-zcal)" % SNAPSHOT_PATH)
        if conf.get("calibrate_first") and not conf.get("en_calib_hotstart"):
            finding("FAIL", "calibrate-first",
                    "calibrate_first=true without en_calib_hotstart=true -- the server refuses "
                    "this combination at boot")
    if rs_conv:
        print("       rs_convention: %-6s  %s" % (rs_conv, RS_STAMP_FORMULA[rs_conv]))
    return conf


def _conf_write_key(conf_path, key, json_value):
    backup_once(conf_path)
    write_atomic(conf_path, conf_set_key(read_text(conf_path), key, json_value))


# ---------------------------------------------------------------------------- log mode (host)
def find_shallowest(root, name):
    best = None
    for dirpath, _dirs, files in os.walk(root):
        if name in files:
            depth = dirpath.count(os.sep)
            if best is None or depth < best[0]:
                best = (depth, os.path.join(dirpath, name))
    return best[1] if best else None


def run_log_mode(log_dir, apply_fixes):
    log_dir = log_dir.rstrip("/")
    if not os.path.isdir(log_dir):
        print("ERROR: no such log dir: %s" % log_dir, file=sys.stderr)
        return 2
    conf_path = os.path.join(log_dir, "etc/modalai/voxl-open-vins-server.conf")
    est = find_shallowest(log_dir, "estimator_config.yaml")
    yaml_dir = os.path.dirname(est) if est else None
    print("log:        %s" % log_dir)
    print("conf:       %s" % (conf_path if os.path.exists(conf_path) else "(none captured)"))
    print("yaml tree:  %s" % (yaml_dir if yaml_dir else "(none captured -- lab falls back to the system tree)"))

    era_preflip = True
    if os.path.exists(conf_path):
        try:
            conf, _ = parse_conf(conf_path)
            era_preflip = not any(k in conf for k in ZCAL_KEYS)
        except Exception as e:
            print("WARNING: captured conf does not parse (%s)" % e)
    print("era:        %s" % (
        "PRE-FLIP (raw HAL3 SOF stamps -- vins-lab generation or older)" if era_preflip
        else "post-flip (center-stamp generation)"))

    if yaml_dir:
        # validate the captured tree (fixes are only offered with --apply and touch the LOG's
        # captured copies, never the live system)
        validate_tree(conf_path if os.path.exists(conf_path) else "/nonexistent", yaml_dir,
                      device_mode=False)

    snapshot = os.path.join(log_dir, "data/modalai/ov_calib/ov_calib_result.yaml")
    print()
    print("replay invocation:")
    print("  voxl-vins-lab -l %s" % log_dir)
    if era_preflip:
        print("  (rs-convention auto resolves TOP for this log -- reproduces that era's results;")
        print("   pass --rs-convention center to re-run it under the current convention)")
    if os.path.exists(snapshot):
        print("  captured calibration snapshot present -- a flight that consumed it re-applies")
        print("  automatically; --no-calib-hotstart opts out, --calib <yaml> A/Bs another one")
    return report_and_apply(apply_fixes)


# ---------------------------------------------------------------------------- main
def main():
    ap = argparse.ArgumentParser(add_help=False)
    ap.add_argument("-h", "--help", action="store_true")
    ap.add_argument("--apply", action="store_true")
    ap.add_argument("--conf", default=CONF_DEFAULT)
    ap.add_argument("--yaml-dir", default=None)
    ap.add_argument("--profile", default=None)
    ap.add_argument("--rs-convention", choices=["top", "center", "bottom"], default=None)
    ap.add_argument("--enable-zcal", action="store_true")
    ap.add_argument("--disable-zcal", action="store_true")
    ap.add_argument("--log", default=None)
    args = ap.parse_args()

    if args.help:
        print(__doc__)
        return 0

    if args.log:
        return run_log_mode(args.log, args.apply)

    conf_path = args.conf
    mutating = args.profile or args.rs_convention or args.enable_zcal or args.disable_zcal

    if args.profile:
        pdir = os.path.join(VOXLCONFIG_ROOT, args.profile)
        if not os.path.isdir(pdir):
            avail = sorted(os.listdir(VOXLCONFIG_ROOT)) if os.path.isdir(VOXLCONFIG_ROOT) else []
            print("ERROR: unknown profile '%s'. Shipped profiles: %s"
                  % (args.profile, ", ".join(avail)), file=sys.stderr)
            return 2
        if not os.path.exists(conf_path):
            print("ERROR: %s does not exist -- run 'voxl-open-vins-server -c' (or the "
                  "voxl-configure-open-vins preset) first" % conf_path, file=sys.stderr)
            return 2
        _conf_write_key(conf_path, "yaml_folder", json.dumps(pdir))
        print("yaml_folder -> %s" % pdir)
        args.yaml_dir = pdir

    if args.enable_zcal or args.disable_zcal:
        if args.enable_zcal and args.disable_zcal:
            print("ERROR: pick one of --enable-zcal / --disable-zcal", file=sys.stderr)
            return 2
        val = "true" if args.enable_zcal else "false"
        _conf_write_key(conf_path, "en_calib_hotstart", val)
        if args.disable_zcal:
            for k in ("calibrate_first", "calib_hotstart_on_reset"):
                _conf_write_key(conf_path, k, "false")
        print("en_calib_hotstart -> %s" % val)
        if args.enable_zcal and not os.path.exists(SNAPSHOT_PATH):
            print("NOTE: no committed snapshot at %s yet -- run a calibration session "
                  "(voxl-open-vins-server --calibrate) or boot falls back to the raw chain"
                  % SNAPSHOT_PATH)

    if args.rs_convention:
        ydir = args.yaml_dir
        if ydir is None:
            try:
                conf, _ = parse_conf(conf_path)
                ydir = conf.get("yaml_folder")
            except Exception as e:
                print("ERROR: cannot resolve yaml_folder from %s: %s" % (conf_path, e),
                      file=sys.stderr)
                return 2
        est = os.path.join(ydir, "estimator_config.yaml")
        if not os.path.exists(est):
            print("ERROR: %s not found" % est, file=sys.stderr)
            return 2
        _write_rs_convention(est, args.rs_convention)
        print("rs_convention -> %s\n  stamps: %s"
              % (args.rs_convention, RS_STAMP_FORMULA[args.rs_convention]))
        if args.rs_convention != "center":
            print("  NOTE: the chain's timeshift_cam_imu is convention-defined -- values fitted"
                  "\n  under another anchor carry the anchor delta until re-earned (re-calibrate)")

    validate_tree(conf_path, args.yaml_dir, device_mode=True)
    rc = report_and_apply(args.apply or bool(mutating))
    if mutating:
        print("\nrestart the service to pick the changes up: systemctl restart voxl-open-vins-server")
    return rc


if __name__ == "__main__":
    sys.exit(main())
