#!/usr/bin/env python3
"""HLDS emulator profile/session helpers.

Profiles are intentionally split:
  * device profile: drive/firmware/model capabilities
  * media profile: inserted disc/image state
  * session profile: runtime binding of device + media + bridge + safety/logging

The loader accepts strict JSON and authoring JSONC (// and /* */ comments,
plus trailing commas). The emulator writes/consumes strict JSON at runtime, but
JSONC templates are easier to review and generate from analyzer tooling.
"""
from __future__ import annotations

import json, re, copy
from pathlib import Path
from typing import Any

_JSON_BLOCK_COMMENT_RE = re.compile(r"/\*.*?\*/", re.S)
_JSON_LINE_COMMENT_RE = re.compile(r"(^|[^:\\])//.*?$", re.M)
_JSON_TRAILING_COMMA_RE = re.compile(r",\s*([}\]])")


def strip_jsonc(text: str) -> str:
    """Return JSON text with JSONC comments and trailing commas removed.

    This is deliberately small and conservative. It is suitable for our schema
    templates and generated files, not arbitrary JavaScript.
    """
    text = _JSON_BLOCK_COMMENT_RE.sub("", text)
    # Do not kill URL-like strings containing :// by requiring previous char not ':' or '\\'.
    text = _JSON_LINE_COMMENT_RE.sub(lambda m: m.group(1), text)
    text = _JSON_TRAILING_COMMA_RE.sub(r"\1", text)
    return text


def load_json_or_jsonc(path: str | Path | None) -> dict[str, Any]:
    if not path:
        return {}
    p = Path(path)
    if not p.exists():
        raise FileNotFoundError(p)
    return json.loads(strip_jsonc(p.read_text(encoding='utf-8')))


def deep_merge(base: dict[str, Any], override: dict[str, Any]) -> dict[str, Any]:
    out = copy.deepcopy(base)
    for k, v in (override or {}).items():
        if isinstance(v, dict) and isinstance(out.get(k), dict):
            out[k] = deep_merge(out[k], v)
        else:
            out[k] = copy.deepcopy(v)
    return out


def normalize_profile_path(path: str | Path | None, base_dir: Path | None = None) -> str | None:
    if not path:
        return None
    p = Path(str(path))
    if not p.is_absolute() and base_dir:
        p = base_dir / p
    return str(p)


def load_device_profile(path: str | Path | None) -> dict[str, Any]:
    doc = load_json_or_jsonc(path)
    if doc and doc.get('format') != 'hlds-device-profile':
        raise ValueError(f"device profile has unexpected format: {doc.get('format')!r}")
    return doc


def load_media_profile(path: str | Path | None) -> dict[str, Any]:
    doc = load_json_or_jsonc(path)
    # Existing xdvd sidecars are accepted as media profiles for compatibility.
    fmt = doc.get('format')
    if doc and fmt not in ('xdvd-media-profile', 'xdvd-sidecar'):
        raise ValueError(f"media profile has unexpected format: {fmt!r}")
    return doc


def load_session_profile(path: str | Path | None) -> dict[str, Any]:
    doc = load_json_or_jsonc(path)
    if doc and doc.get('format') != 'hlds-emulator-session':
        raise ValueError(f"session profile has unexpected format: {doc.get('format')!r}")
    return doc


def session_base_dir(session_path: str | Path | None) -> Path | None:
    return Path(session_path).resolve().parent if session_path else None


def default_log_for_media_profile(path: str | Path | None) -> str | None:
    if not path:
        return None
    p = Path(path)
    name = p.name
    if name.lower().endswith('.xdvd.json'):
        return str(p.with_name(name[:-10] + '.log'))
    return str(p.with_suffix('.log'))


def session_value(session: dict[str, Any], dotted: str, default: Any = None) -> Any:
    cur: Any = session
    for part in dotted.split('.'):
        if not isinstance(cur, dict) or part not in cur:
            return default
        cur = cur[part]
    return cur


def infer_target_name_from_device(device: dict[str, Any], fallback: str = 'iqn.2026-06.local.mn103s:gdr8050l') -> str:
    bridge = device.get('bridge_defaults') or {}
    if bridge.get('target_name'):
        return str(bridge.get('target_name'))
    model = str(device.get('model') or device.get('identity', {}).get('inquiry_product') or 'hlds').lower()
    model = re.sub(r'[^a-z0-9]+', '', model) or 'hlds'
    return f'iqn.2026-06.local.mn103s:{model}' if model != 'gdr8050l' else fallback


def effective_session(session_path: str | Path | None = None, *,
                      device_profile: str | Path | None = None,
                      media_profile: str | Path | None = None,
                      firmware_image: str | Path | None = None,
                      overrides: dict[str, Any] | None = None) -> dict[str, Any]:
    """Load a session and apply explicit CLI/builder overrides.

    The returned object keeps path strings as supplied/resolved; it does not load
    the device/media docs unless requested by callers.
    """
    session = load_session_profile(session_path) if session_path else {}
    base = session_base_dir(session_path)
    if not session:
        session = {'format': 'hlds-emulator-session', 'version': 1}
    if device_profile is not None:
        session['device_profile'] = str(device_profile)
    if media_profile is not None:
        session['media_profile'] = str(media_profile) if media_profile else None
    if firmware_image is not None:
        session['firmware_image'] = str(firmware_image)
    if overrides:
        session = deep_merge(session, overrides)
    # Resolve relative paths from session location, not current shell.
    for key in ('device_profile', 'media_profile', 'firmware_image'):
        if session.get(key):
            session[key] = normalize_profile_path(session.get(key), base)
    return session


def apply_session_to_namespace(args: Any) -> Any:
    """Mutate an argparse namespace from --session-profile/--device-profile.

    CLI explicit values should win. Because argparse does not tell us whether a
    default was explicitly typed, this function only fills missing/empty values
    and intentionally leaves already-populated args alone.
    """
    session_path = getattr(args, 'session_profile', None)
    cli_device = getattr(args, 'device_profile', None)
    cli_media = getattr(args, 'media_profile', None)
    session = effective_session(session_path, device_profile=cli_device, media_profile=cli_media)
    device_path = session.get('device_profile') or cli_device
    media_path = session.get('media_profile') or cli_media
    device = load_device_profile(device_path) if device_path else {}
    media_doc = load_media_profile(media_path) if media_path else {}
    if media_doc:
        image_file = ((media_doc.get('image') or {}).get('file'))
        if image_file and not session_value(session, 'media.image.file'):
            media_base = Path(str(media_path)).resolve().parent
            p_img = Path(str(image_file))
            session.setdefault('media', {}).setdefault('image', {})['file'] = str(p_img if p_img.is_absolute() else (media_base / p_img))

    # Positional media path compatibility: xdvd_json remains the media profile path.
    if not getattr(args, 'xdvd_json', None) and media_path:
        setattr(args, 'xdvd_json', str(media_path))
    if hasattr(args, 'media_profile'):
        setattr(args, 'media_profile', str(media_path) if media_path else None)
    if hasattr(args, 'device_profile'):
        setattr(args, 'device_profile', str(device_path) if device_path else None)

    fw = session.get('firmware_image')
    if fw and not getattr(args, 'firmware_image', None):
        setattr(args, 'firmware_image', str(fw))

    # Common bridge/logging defaults.
    bridge = session.get('bridge') or {}
    logging = session.get('logging') or {}
    safety = session.get('safety') or {}

    def fill(attr: str, value: Any) -> None:
        if value is None:
            return
        if not hasattr(args, attr) or getattr(args, attr) in (None, ''):
            setattr(args, attr, value)

    # Default unlock log next to XDVD media profile unless session/CLI supplies one.
    fill('log', session_value(session, 'media.unlock_log') or session_value(session, 'inputs.unlock_log') or default_log_for_media_profile(media_path))

    # Only fill canonical attrs used by current scripts.
    fill('iso', session_value(session, 'media.image.file'))
    fill('stem', session.get('stem'))
    fill('iscsi_target_name', bridge.get('target_name') or infer_target_name_from_device(device) if device else bridge.get('target_name'))
    fill('target_name', bridge.get('target_name') or infer_target_name_from_device(device) if device else bridge.get('target_name'))
    fill('iscsi_listen_host', bridge.get('listen_host'))
    fill('host', bridge.get('listen_host'))
    fill('iscsi_listen_port', bridge.get('listen_port') or bridge.get('port'))
    fill('port', bridge.get('listen_port') or bridge.get('port'))
    fill('iscsi_advertise_host', bridge.get('advertise_host'))
    fill('advertise_host', bridge.get('advertise_host'))
    fill('iscsi_advertise_port', bridge.get('advertise_port'))
    fill('advertise_port', bridge.get('advertise_port'))
    fill('xdvd_read_map', bridge.get('read_map'))
    fill('live_xdvd_read_map', bridge.get('live_read_map') or bridge.get('read_map'))
    fill('read_trace_jsonl', logging.get('read_trace_jsonl'))
    fill('out', logging.get('live_jsonl') or logging.get('out'))
    fill('write_response_dir', logging.get('response_dir'))
    fill('read_trace_limit', logging.get('read_trace_limit'))

    # Attach docs for callers that want them.
    setattr(args, '_session_profile_doc', session)
    setattr(args, '_device_profile_doc', device)
    return args


def write_json(path: str | Path, obj: dict[str, Any]) -> None:
    p = Path(path)
    p.parent.mkdir(parents=True, exist_ok=True)
    p.write_text(json.dumps(obj, indent=2, sort_keys=False) + '\n', encoding='utf-8')
