#!/usr/bin/python3
# @file
# @ingroup ifw-iptool_validator
# @copyright
#   SPDX-FileCopyrightText: 2026 European Southern Observatory (ESO)
#   SPDX-License-Identifier: LGPL-3.0-only
#
# @brief CLI to validate a generated OTTO Instrument Package against the

"""
OTTO `ip_schemata` JSON Schemas.

Unlike the prototype's validator (which only checked Template Signatures), this validates
the full IP set produced by ifwIpToolCompiler:

  format.json        -> Format.json
  templates.json     -> TemplateIndex.json
  templates/*.json   -> Fmt1TemplateSignature.json  (acquisition templates)
                    or TemplateSignature.json       (science / calib / test)
  constraints.json   -> Constraints.json (if present)
  checklist.json     -> Checklist.json   (if present)
  laser.json         -> Laser.json       (if present)

OTTO ship two TemplateSignature schemas because acquisition templates carry
a different phase1 shape (Phase1VLT, with referenceTargets) than science /
calib templates (Phase1, without referenceTargets). We pick per-Template-Signature based
on the `type` field.

OBDs are not checked here (no OTTO JSON Schema for OBDs is shipped in the
ip_schemata set; they are validated end-to-end by the Sequencer integration
test).

The schemata root is discovered via:
  1. `--otto-ip-schemata <path>` if given.
  2. Otherwise the first CFGPATH entry containing `schema/otto/ip_schemata`.
  3. Otherwise a bundled copy under `<repo>/lib/resource/schema/otto/ip_schemata`.
"""
from __future__ import annotations

import argparse
import json
import logging
import os
import sys
import warnings
from pathlib import Path
from typing import Dict, List, Optional

# Silence jsonschema's RefResolver deprecation warning BEFORE the import
# that triggers it (otherwise the warning fires at module-load time and
# users see it on every CLI invocation).
# TODO: migrate to the `referencing` library when jsonschema removes
# RefResolver (deprecated since jsonschema v4.18.0); safe to keep using
# until the next jsonschema major-version bump.
warnings.filterwarnings("ignore", category=DeprecationWarning, module="jsonschema")
warnings.filterwarnings("ignore", message=".*RefResolver is deprecated.*",
                        category=DeprecationWarning)

import jsonschema
from jsonschema import Draft202012Validator, RefResolver


log = logging.getLogger("ifwIpToolValidator")


# -- Schema discovery + loading ----------------------------------------------


_ARTEFACT_TO_SCHEMA: Dict[str, str] = {
    "format.json":      "Format.json",
    "templates.json":   "TemplateIndex.json",
    "constraints.json": "Constraints.json",
    "checklist.json":   "Checklist.json",
    "laser.json":       "Laser.json",
}
# Acquisition templates carry phase1.referenceTargets and validate against
# the format-1 schema; science / calib / test templates use the default
# TemplateSignature schema.
_ACQ_TEMPLATE_SCHEMA = "Fmt1TemplateSignature.json"
_DEFAULT_TEMPLATE_SCHEMA = "TemplateSignature.json"


def _template_schema_for(template_signature: Dict) -> str:
    if template_signature.get("type") == "acquisition":
        return _ACQ_TEMPLATE_SCHEMA
    return _DEFAULT_TEMPLATE_SCHEMA


def _bundled_schema_root() -> Optional[Path]:
    """Return the repo-bundled schema root if running from the source tree."""
    here = Path(__file__).resolve()
    for parent in here.parents:
        candidate = parent / "lib" / "resource" / "schema" / "otto" / "ip_schemata"
        if candidate.is_dir():
            return candidate
    return None


def _cfgpath_schema_root() -> Optional[Path]:
    raw = os.environ.get("CFGPATH", "")
    for entry in raw.split(os.pathsep):
        if not entry:
            continue
        candidate = Path(entry) / "schema" / "otto" / "ip_schemata"
        if candidate.is_dir():
            return candidate
    return None


def _resolve_schema_root(arg: Optional[Path]) -> Optional[Path]:
    if arg is not None:
        # Resolve relative paths against CWD, then auto-descend into
        # ip_schemata/ if the user pointed at its parent (i.e. they gave
        # .../schema/otto rather than .../schema/otto/ip_schemata).
        candidate = arg.expanduser().resolve()
        if candidate.is_dir():
            if (candidate / "Constraint.json").is_file():
                return candidate
            nested = candidate / "ip_schemata"
            if (nested / "Constraint.json").is_file():
                return nested
        return None
    return _cfgpath_schema_root() or _bundled_schema_root()


def _load_validator(schema_root: Path, schema_name: str) -> Draft202012Validator:
    schema_path = schema_root / schema_name
    if not schema_path.is_file():
        raise FileNotFoundError(f"missing schema {schema_name} in {schema_root}")
    schema = json.loads(schema_path.read_text())
    resolver = RefResolver(base_uri=schema_path.as_uri(), referrer=schema)
    return Draft202012Validator(schema, resolver=resolver)


# -- Validation ---------------------------------------------------------------


_UMBRELLA_VALIDATORS = {"oneOf", "anyOf", "allOf"}


def _is_umbrella(err: jsonschema.exceptions.ValidationError) -> bool:
    return err.validator in _UMBRELLA_VALIDATORS


def _shorten_message(msg: str, *, limit: int = 200) -> str:
    """Trim very long jsonschema messages (which embed the offending
    object verbatim) so terminal output stays readable.
    """
    msg = msg.strip()
    if len(msg) <= limit:
        return msg
    return msg[:limit].rstrip() + "..."


def _explain(err: jsonschema.exceptions.ValidationError
             ) -> List[jsonschema.exceptions.ValidationError]:
    """Walk to the most informative leaf errors under `err`.

    For oneOf/anyOf failures, jsonschema returns one umbrella error with
    one sub-context per failing branch. Reporting every branch produces
    a flood of noise (every parameter type, every constraint enum,
    etc.).

    We pick a single branch using a two-stage heuristic:

    1. If the failing instance has a ``type`` field AND exactly one
       branch declares a matching ``type`` const, prefer that branch.
       That makes errors like "type 'ra' is not one of ['binfile',
       'textfile']" disappear in favour of the actual problem inside the
       RaParam branch (the user wrote ``type: ra``, so report what's
       wrong with their RA parameter, not what's wrong against the
       BinfileParam branch).

    2. Otherwise fall back to the closest-miss heuristic: the branch
       with the fewest leaf errors.

    For allOf, every branch must hold, so we report all of them.
    """
    if not _is_umbrella(err) or not err.context:
        return [err]

    if err.validator == "allOf":
        leaves: List[jsonschema.exceptions.ValidationError] = []
        for ctx in err.context:
            leaves.extend(_explain(ctx))
        return leaves

    # oneOf / anyOf: group sub-errors by branch index.
    branches: Dict[int, List[jsonschema.exceptions.ValidationError]] = {}
    for ctx in err.context:
        idx = ctx.schema_path[0] if ctx.schema_path else 0
        branches.setdefault(idx, []).append(ctx)

    # Stage 1: type-discriminator preference.
    best_idx = _pick_branch_by_type_discriminator(err, branches)

    # Stage 2: closest-miss fallback.
    if best_idx is None:
        best_idx = min(
            branches,
            key=lambda i: sum(len(_explain(c)) for c in branches[i]),
        )
    leaves = []
    for ctx in branches[best_idx]:
        leaves.extend(_explain(ctx))
    return leaves


def _pick_branch_by_type_discriminator(
    err: jsonschema.exceptions.ValidationError,
    branches: Dict[int, List[jsonschema.exceptions.ValidationError]],
) -> Optional[int]:
    """If the instance has a ``type`` field and exactly one branch's
    schema requires a matching ``type`` const, return that branch's
    index. Otherwise None.
    """
    instance = err.instance
    if not isinstance(instance, dict):
        return None
    instance_type = instance.get("type")
    if not isinstance(instance_type, str):
        return None

    matching: List[int] = []
    for idx, ctxs in branches.items():
        # Each branch's sub-errors share the same branch schema; look at
        # the first to find the branch's declared type const.
        branch_schema = ctxs[0].schema if ctxs else {}
        if _branch_type_matches(branch_schema, instance_type):
            matching.append(idx)
    if len(matching) == 1:
        return matching[0]
    return None


def _branch_type_matches(branch_schema: Dict, instance_type: str) -> bool:
    """Check whether ``branch_schema`` constrains ``type`` to a const or
    enum that includes ``instance_type``. Walks into ``allOf`` /
    referenced subschemas one level deep (enough for the common
    ``allOf: [{$ref: Param.json}, {type: const ...}]`` pattern).
    """
    candidates = [branch_schema]
    for sub in branch_schema.get("allOf", []) or []:
        if isinstance(sub, dict):
            candidates.append(sub)
    for cand in candidates:
        props = cand.get("properties") or {}
        type_spec = props.get("type")
        if not isinstance(type_spec, dict):
            continue
        if type_spec.get("const") == instance_type:
            return True
        enum = type_spec.get("enum")
        if isinstance(enum, list) and instance_type in enum:
            return True
    return False


def _format_location(absolute_path, data) -> str:
    """Render a validation error's absolute_path as a dotted string,
    substituting parameter / constraint / checklist names for array
    indices where present so users see ``parameters.DET.EXP.TIME.type``
    instead of ``parameters.2.type``.
    """
    parts: List[str] = []
    cursor = data
    for step in absolute_path:
        if isinstance(step, int) and isinstance(cursor, list) and 0 <= step < len(cursor):
            item = cursor[step]
            label = None
            if isinstance(item, dict):
                # Pick the first identifier-looking field that's set.
                for key in ("name", "templateName", "id"):
                    val = item.get(key)
                    if isinstance(val, str) and val:
                        label = val
                        break
            parts.append(label if label is not None else str(step))
            cursor = item
        else:
            parts.append(str(step))
            try:
                cursor = cursor[step] if cursor is not None else None
            except (KeyError, IndexError, TypeError):
                cursor = None
    return ".".join(parts) if parts else "<root>"


def _validate_one(path: Path, validator: Draft202012Validator) -> List[str]:
    try:
        data = json.loads(path.read_text())
    except Exception as exc:
        return [f"{path}: failed to read/parse ({exc})"]

    leaves: List[jsonschema.exceptions.ValidationError] = []
    for err in validator.iter_errors(data):
        leaves.extend(_explain(err))

    # De-dup by (location, message): oneOf/anyOf often surface the same
    # leaf via multiple branches; we want each unique cause once.
    seen = set()
    errors: List[str] = []
    for err in sorted(leaves, key=lambda e: list(e.absolute_path)):
        loc = _format_location(err.absolute_path, data)
        msg = _shorten_message(err.message)
        key = (loc, msg)
        if key in seen:
            continue
        seen.add(key)
        errors.append(f"{path}: {loc}: {msg}")
    return errors


def _find_instrument_ip_root(path: Path) -> Optional[Path]:
    """Locate the IP directory (the one containing ``format.json`` + ``templates/``).

    Accepts any of (INSTR stands for the instrument directory name):

      - direct:                                  ``.../INSTR/ip/``
      - one level up (instrument dir):           ``.../INSTR/`` (descends into ip/)
      - one level up (parent of instrument dir): ``.../resource/ip/INSTR/`` (descends into ip/)
      - two levels up:                           ``.../out/resource/`` (descends into ip/INSTR/ip/)
    """
    p = path.resolve()
    # direct: .../INSTR/ip/
    if (p / "format.json").is_file() and (p / "templates").is_dir():
        return p
    # instrument dir: .../INSTR/ -> descend into ip/
    candidate = p / "ip"
    if (candidate / "format.json").is_file() and (candidate / "templates").is_dir():
        return candidate
    # parent of instrument dir: .../resource/ip/INSTR/ + auto-descend into ip/
    for child in sorted(p.glob("*/ip")):
        if (child / "format.json").is_file():
            return child
    # two levels up: .../out/resource -> resource/ip/INSTR/ip/
    for match in sorted(p.glob("ip/*/ip/format.json")):
        return match.parent
    return None


def validate_ip(ip_root: Path, schema_root: Path) -> int:
    """Validate an instrument IP directory. Returns error count."""
    errors: List[str] = []

    # Template Signatures: pick Fmt1TemplateSignature for acquisition
    # templates, TemplateSignature for the rest. Load each validator at
    # most once.
    tpl_validators: Dict[str, Draft202012Validator] = {}
    tpl_dir = ip_root / "templates"
    template_signature_files = sorted(tpl_dir.glob("*.json")) if tpl_dir.is_dir() else []
    for ts_path in template_signature_files:
        try:
            template_signature_data = json.loads(ts_path.read_text())
        except Exception as exc:
            errors.append(f"{ts_path}: failed to read/parse ({exc})")
            continue
        schema_name = _template_schema_for(template_signature_data)
        if schema_name not in tpl_validators:
            tpl_validators[schema_name] = _load_validator(schema_root, schema_name)
        errors.extend(_validate_one(ts_path, tpl_validators[schema_name]))

    # IP-wide artefacts
    for filename, schema_name in _ARTEFACT_TO_SCHEMA.items():
        path = ip_root / filename
        if not path.is_file():
            if filename in ("format.json", "templates.json"):
                errors.append(f"{ip_root}: required artefact missing: {filename}")
            continue
        validator = _load_validator(schema_root, schema_name)
        errors.extend(_validate_one(path, validator))

    if errors:
        for e in errors:
            print(e)
        print(f"FAIL: {len(errors)} issue(s) in {ip_root}")
        return len(errors)

    emitted = ["templates/"] + [f for f in _ARTEFACT_TO_SCHEMA if (ip_root / f).is_file()]
    print(f"OK: {ip_root} ({len(template_signature_files)} Template Signature(s); {', '.join(emitted)})")
    return 0


# -- CLI ---------------------------------------------------------------------


def parse_args(argv=None) -> argparse.Namespace:
    p = argparse.ArgumentParser(
        description=(
            "Validate an IpTool-generated OTTO Instrument Package against the "
            "OTTO ip_schemata JSON schemas (Format, TemplateIndex, "
            "TemplateSignature, Constraints, Checklist, Laser)."
        ),
        epilog="IFW IpTool - Instrument Package Validator.",
    )
    p.add_argument("-i", "--ip", type=Path, required=True,
                   help="Path to the instrument IP directory (the one containing "
                        "format.json), or a parent that contains ip/INSTR/ip/.")
    p.add_argument("-o", "--otto-ip-schemata", type=Path,
                   help="Path to OTTO ip_schemata/ directory, or to its parent "
                        "(the tool auto-descends into ip_schemata/ if needed). "
                        "Relative paths are resolved against the current "
                        "directory. Overrides CFGPATH lookup.")
    p.add_argument("-l", "--log-level", default="WARNING",
                   help="Log level (DEBUG, INFO, WARN, ERROR).")
    return p.parse_args(argv)


def main(argv=None) -> int:
    args = parse_args(argv)
    logging.basicConfig(
        level=getattr(logging, args.log_level.upper(), logging.WARNING),
        format="%(levelname)s:%(name)s: %(message)s",
    )

    schema_root = _resolve_schema_root(args.otto_ip_schemata)
    if schema_root is None:
        print("ERROR: could not find OTTO ip_schemata. "
              "Pass --otto-ip-schemata or set CFGPATH to include a resource/ root.")
        return 2
    log.info("using schema root: %s", schema_root)

    ip_root = _find_instrument_ip_root(args.ip)
    if ip_root is None:
        print(f"ERROR: could not find an IP directory under {args.ip}.")
        print("  An IP directory must contain BOTH:")
        print("    - format.json     (mandatory: IP format, instrument, version)")
        print("    - templates/      (directory of <templateName>.json files)")
        print("  The path can be the IP directory itself, the instrument dir,")
        print("  or a parent like .../resource/ (the tool auto-descends).")
        return 2
    log.info("validating IP: %s", ip_root)

    return 0 if validate_ip(ip_root, schema_root) == 0 else 1


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