# SGP.22 v2.3: https://www.gsma.com/solutions-and-impact/technologies/esim/wp-content/uploads/2021/07/SGP.22-v2.3.pdf

import atexit
import base64
import fcntl
import hashlib
import os
import requests
import subprocess
import sys
import termios
import time

from collections.abc import Callable, Generator
from contextlib import contextmanager
from typing import Any

from pathlib import Path

from openpilot.common.time_helpers import system_time_valid
from openpilot.common.esim.base import LPABase, LPAError, LPAProfileNotFoundError, Profile
from openpilot.common.serial import Serial, SerialException

GSMA_CI_BUNDLE = str(Path(__file__).parent / "gsma_ci_bundle.pem")

DEFAULT_DEVICE = "/dev/modem_at0"
DEFAULT_BAUD = 9600
DEFAULT_TIMEOUT = 5.0
# https://euicc-manual.osmocom.org/docs/lpa/applet-id/
ISDR_AID = "A0000005591010FFFFFFFF8900000100"
ES10X_MSS = 120
HTTP_TIMEOUT = 30
OPEN_ISDR_RETRIES = 10
OPEN_ISDR_RETRY_DELAY_S = 0.25
OPEN_ISDR_RESET_ATTEMPT = 5
SEND_APDU_RETRIES = 3
LOCK_FILE = '/dev/shm/modem.lock'
DEBUG = os.environ.get("DEBUG") == "1"


# TLV Tags
TAG_ICCID = 0x5A
TAG_STATUS = 0x80
TAG_EUICC_INFO = 0xBF20
TAG_PREPARE_DOWNLOAD = 0xBF21
TAG_BPP_COMMAND = 0xBF23
TAG_PROFILE_METADATA = 0xBF25
TAG_INSTALL_RESULT_DATA = 0xBF27
TAG_LIST_NOTIFICATION = 0xBF28
TAG_SET_NICKNAME = 0xBF29
TAG_RETRIEVE_NOTIFICATION = 0xBF2B
TAG_PROFILE_INFO_LIST = 0xBF2D
TAG_EUICC_CHALLENGE = 0xBF2E
TAG_NOTIFICATION_METADATA = 0xBF2F
TAG_NOTIFICATION_SENT = 0xBF30
TAG_ENABLE_PROFILE = 0xBF31
TAG_DELETE_PROFILE = 0xBF33
TAG_BPP = 0xBF36
TAG_PROFILE_INSTALL_RESULT = 0xBF37
TAG_AUTH_SERVER = 0xBF38
TAG_CANCEL_SESSION = 0xBF41
TAG_OK = 0xA0

PROFILE_OK = 0x00
PROFILE_NOT_IN_DISABLED_STATE = 0x02
PROFILE_CAT_BUSY = 0x05

PROFILE_ERROR_CODES = {
  0x01: "iccidOrAidNotFound", PROFILE_NOT_IN_DISABLED_STATE: "profileNotInDisabledState",
  0x03: "disallowedByPolicy", 0x04: "wrongProfileReenabling",
  PROFILE_CAT_BUSY: "catBusy", 0x06: "undefinedError",
}
AUTH_SERVER_ERROR_CODES = {
  0x01: "eUICCVerificationFailed", 0x02: "eUICCCertificateExpired",
  0x03: "eUICCCertificateRevoked", 0x05: "invalidServerSignature",
  0x06: "euiccCiPKUnknown", 0x0A: "matchingIdRefused",
  0x10: "insufficientMemory",
}
BPP_COMMAND_NAMES = {
  0: "initialiseSecureChannel", 1: "configureISDP", 2: "storeMetadata",
  3: "storeMetadata2", 4: "replaceSessionKeys", 5: "loadProfileElements",
}
BPP_ERROR_REASONS = {
  1: "incorrectInputValues", 2: "invalidSignature", 3: "invalidTransactionId",
  4: "unsupportedCrtValues", 5: "unsupportedRemoteOperationType",
  6: "unsupportedProfileClass", 7: "scp03tStructureError", 8: "scp03tSecurityError",
  9: "iccidAlreadyExistsOnEuicc", 10: "insufficientMemoryForProfile",
  11: "installInterrupted", 12: "peProcessingError", 13: "dataMismatch",
  14: "invalidNAA",
}
BPP_ERROR_MESSAGES = {
  9: "This eSIM profile is already installed on this device.",
  10: "Not enough memory on the eUICC to install this profile.",
  12: "Profile installation failed. The QR code may have already been used.",
}

# SGP.22 §5.2.6 SM-DP+ reason/subject codes mapped to user-friendly messages
ES9P_ERROR_MESSAGES: dict[tuple[str, str], str] = {
  ('3.8', '8.2.6'): "This eSIM profile is already installed on another device. Please use a new QR code.",
  ('3.8', '8.2.1'): "This eSIM profile has expired. Please request a new QR code.",
  ('3.8', '8.1'): "The SM-DP+ server refused this request.",
  ('3.1', '8.2.6'): "This eSIM profile has been revoked by the carrier.",
  ('3.9', '8.2.6'): "This eSIM profile download has already been completed.",
  ('2.1', '8.8'): "The device is not compatible with this eSIM profile.",
  ('1.2', '8.1'): "The SM-DP+ server is temporarily unavailable. Try again later.",
}

NOTIFICATION_OPERATIONS = {0x80: "install", 0x40: "enable", 0x20: "disable", 0x10: "delete"}

STATE_LABELS = {0: "disabled", 1: "enabled", 255: "unknown"}
ICON_LABELS = {0: "jpeg", 1: "png", 255: "unknown"}
CLASS_LABELS = {0: "test", 1: "provisioning", 2: "operational", 255: "unknown"}

# TLV tag -> (field_name, decoder)
FieldMap = dict[int, tuple[str, Callable[[bytes], Any]]]


def b64e(data: bytes) -> str:
  return base64.b64encode(data).decode("ascii")


def base64_trim(s: str) -> str:
  return "".join(c for c in s if c not in "\n\r \t")


def b64d(s: str) -> bytes:
  return base64.b64decode(base64_trim(s))


class AtClient:
  def __init__(self, device: str, baud: int, timeout: float) -> None:
    self.channel: str | None = None
    self._device = device
    self._baud = baud
    self._timeout = timeout
    self._serial: Serial | None = None

  def send_raw(self, data: bytes) -> None:
    self._ensure_serial()
    self._serial.reset_input_buffer()
    self._serial.write(data)
    self._serial.flush()

  def close(self) -> None:
    try:
      if self.channel:
        try:
          self.query(f"AT+CCHC={self.channel}")
        except (RuntimeError, TimeoutError):
          pass
        self.channel = None
    finally:
      if self._serial:
        self._serial.close()

  def _send(self, cmd: str) -> None:
    if DEBUG:
      print(f"SER >> {cmd}", file=sys.stderr)
    self._serial.write((cmd + "\r").encode("ascii"))

  def _expect(self) -> list[str]:
    lines: list[str] = []
    while True:
      raw = self._serial.readline()
      if not raw:
        raise TimeoutError("AT command timed out")
      line = raw.decode(errors="ignore").strip()
      if not line:
        continue
      if DEBUG:
        print(f"SER << {line}", file=sys.stderr)
      if line == "OK":
        return lines
      if line == "ERROR" or line.startswith("+CME ERROR"):
        raise RuntimeError(f"AT command failed: {line}")
      lines.append(line)

  def _ensure_serial(self, reconnect: bool = False) -> None:
    if reconnect:
      self.channel = None
      try:
        if self._serial:
          self._serial.close()
      except Exception:
        pass
      self._serial = None
    if self._serial is None:
      self._serial = Serial(self._device, baudrate=self._baud, timeout=self._timeout)

  def query(self, cmd: str) -> list[str]:
    self._ensure_serial()
    try:
      self._send(cmd)
      return self._expect()
    except SerialException:
      self._ensure_serial(reconnect=True)
      self._send(cmd)
      return self._expect()

  def _open_isdr_once(self) -> None:
    if self.channel:
      try:
        self.query(f"AT+CCHC={self.channel}")
      except RuntimeError:
        pass
      self.channel = None
    # drain any unsolicited responses before opening
    if self._serial:
      try:
        self._serial.reset_input_buffer()
      except (OSError, SerialException, termios.error):
        self._ensure_serial(reconnect=True)
    for line in self.query(f'AT+CCHO="{ISDR_AID}"'):
      if line.startswith("+CCHO:") and (ch := line.split(":", 1)[1].strip()):
        self.channel = ch
        return
    raise RuntimeError("Failed to open ISD-R application")

  def _reset_modem(self) -> None:
    if self._serial:
      try:
        self._serial.close()
      except Exception:
        pass
      self._serial = None
    subprocess.run(['/usr/comma/lte/lte.sh', 'start'], capture_output=True)

  def open_isdr(self) -> None:
    for attempt in range(OPEN_ISDR_RETRIES):
      try:
        self._open_isdr_once()
        return
      except (RuntimeError, TimeoutError, termios.error, SerialException):
        time.sleep(OPEN_ISDR_RETRY_DELAY_S)
        if attempt == OPEN_ISDR_RESET_ATTEMPT:
          self._reset_modem()
    raise RuntimeError("Failed to open ISD-R after retries")

  def send_apdu(self, apdu: bytes) -> tuple[bytes, int, int]:
    for attempt in range(SEND_APDU_RETRIES):
      try:
        if not self.channel:
          self.open_isdr()
        hex_payload = apdu.hex().upper()
        for line in self.query(f'AT+CGLA={self.channel},{len(hex_payload)},"{hex_payload}"'):
          if line.startswith("+CGLA:"):
            parts = line.split(":", 1)[1].split(",", 1)
            if len(parts) == 2:
              data = bytes.fromhex(parts[1].strip().strip('"'))
              if len(data) >= 2:
                return data[:-2], data[-2], data[-1]
        raise RuntimeError("Missing +CGLA response")
      except (RuntimeError, ValueError):
        self.channel = None
        if attempt == SEND_APDU_RETRIES - 1:
          raise
    raise RuntimeError("send_apdu failed")


# --- TLV utilities ---

def iter_tlv(data: bytes, with_positions: bool = False) -> Generator:
  idx, length = 0, len(data)
  while idx < length:
    start_pos = idx
    tag = data[idx]
    idx += 1
    if tag & 0x1F == 0x1F:  # Multi-byte tag
      tag_value = tag
      while idx < length:
        next_byte = data[idx]
        idx += 1
        tag_value = (tag_value << 8) | next_byte
        if not (next_byte & 0x80):
          break
    else:
      tag_value = tag
    if idx >= length:
      break
    size = data[idx]
    idx += 1
    if size & 0x80:  # Multi-byte length
      num_bytes = size & 0x7F
      if idx + num_bytes > length:
        break
      size = int.from_bytes(data[idx : idx + num_bytes], "big")
      idx += num_bytes
    if idx + size > length:
      break
    value = data[idx : idx + size]
    idx += size
    yield (tag_value, value, start_pos, idx) if with_positions else (tag_value, value)


def find_tag(data: bytes, target: int) -> bytes | None:
  return next((v for t, v in iter_tlv(data) if t == target), None)


def require_tag(data: bytes, target: int, label: str = "") -> bytes:
  v = find_tag(data, target)
  if v is None:
    raise RuntimeError(f"Missing {label or f'tag 0x{target:X}'}")
  return v


def tbcd_to_string(raw: bytes) -> str:
  return "".join(str(n) for b in raw for n in (b & 0x0F, b >> 4) if n <= 9)


def string_to_tbcd(s: str) -> bytes:
  digits = [int(c) for c in s if c.isdigit()]
  return bytes(digits[i] | ((digits[i + 1] if i + 1 < len(digits) else 0xF) << 4) for i in range(0, len(digits), 2))


def encode_tlv(tag: int, value: bytes) -> bytes:
  tag_bytes = bytes([(tag >> 8) & 0xFF, tag & 0xFF]) if tag > 255 else bytes([tag])
  vlen = len(value)
  if vlen <= 127:
    return tag_bytes + bytes([vlen]) + value
  length_bytes = vlen.to_bytes((vlen.bit_length() + 7) // 8, "big")
  return tag_bytes + bytes([0x80 | len(length_bytes)]) + length_bytes + value


def int_bytes(n: int) -> bytes:
  """Encode a positive integer as minimal big-endian bytes (at least 1 byte)."""
  return n.to_bytes((n.bit_length() + 7) // 8 or 1, "big")


PROFILE: FieldMap = {
  TAG_ICCID: ("iccid", tbcd_to_string),
  0x4F: ("isdpAid", lambda v: v.hex().upper()),
  0x9F70: ("profileState", lambda v: STATE_LABELS.get(v[0], "unknown")),
  0x90: ("profileNickname", lambda v: v.decode("utf-8", errors="ignore") or None),
  0x91: ("serviceProviderName", lambda v: v.decode("utf-8", errors="ignore") or None),
  0x92: ("profileName", lambda v: v.decode("utf-8", errors="ignore") or None),
  0x93: ("iconType", lambda v: ICON_LABELS.get(v[0], "unknown")),
  0x94: ("icon", b64e),
  0x95: ("profileClass", lambda v: CLASS_LABELS.get(v[0], "unknown")),
}


def decode_struct(data: bytes, field_map: FieldMap) -> dict[str, Any]:
  """Parse TLV data using a {tag: (field_name, decoder)} map into a dict."""
  result: dict[str, Any] = {name: None for name, _ in field_map.values()}
  for tag, value in iter_tlv(data):
    if (field := field_map.get(tag)):
      result[field[0]] = field[1](value)
  return result


# --- ES10x command transport ---

def es10x_command(client: AtClient, data: bytes) -> bytes:
  response = bytearray()
  sequence = 0
  offset = 0
  while offset < len(data):
    chunk = data[offset : offset + ES10X_MSS]
    offset += len(chunk)
    is_last = offset == len(data)
    apdu = bytes([0x80, 0xE2, 0x91 if is_last else 0x11, sequence & 0xFF, len(chunk)]) + chunk
    segment, sw1, sw2 = client.send_apdu(apdu)
    response.extend(segment)
    while True:
      if sw1 == 0x61:  # More data available
        segment, sw1, sw2 = client.send_apdu(bytes([0x80, 0xC0, 0x00, 0x00, sw2 or 0]))
        response.extend(segment)
        continue
      if (sw1 & 0xF0) == 0x90:
        break
      raise RuntimeError(f"APDU failed with SW={sw1:02X}{sw2:02X}")
    sequence += 1
  return bytes(response)


# --- Profile operations ---

NOTIFICATION: FieldMap = {
  TAG_STATUS: ("seqNumber", lambda v: int.from_bytes(v, "big")),
  0x81: ("profileManagementOperation",
         lambda v: NOTIFICATION_OPERATIONS.get(next((m for m in NOTIFICATION_OPERATIONS if len(v) >= 2 and v[1] & m), 0), "unknown")),
  0x0C: ("notificationAddress", lambda v: v.decode("utf-8", errors="ignore")),
  TAG_ICCID: ("iccid", tbcd_to_string),
}


def decode_profiles(blob: bytes) -> list[dict]:
  root = require_tag(blob, TAG_PROFILE_INFO_LIST, "ProfileInfoList")
  list_ok = find_tag(root, TAG_OK)
  if list_ok is None:
    return []
  return [decode_struct(value, PROFILE) for tag, value in iter_tlv(list_ok) if tag == 0xE3]


def list_profiles(client: AtClient) -> list[dict]:
  return decode_profiles(es10x_command(client, TAG_PROFILE_INFO_LIST.to_bytes(2, "big") + b"\x00"))


def set_profile_nickname(client: AtClient, iccid: str, nickname: str) -> None:
  nickname_bytes = nickname.encode("utf-8")
  if len(nickname_bytes) > 64:
    raise ValueError("Profile nickname must be 64 bytes or less")
  content = encode_tlv(TAG_ICCID, string_to_tbcd(iccid)) + encode_tlv(0x90, nickname_bytes)
  response = es10x_command(client, encode_tlv(TAG_SET_NICKNAME, content))
  code = require_tag(require_tag(response, TAG_SET_NICKNAME, "SetNicknameResponse"), TAG_STATUS, "SetNickname status")[0]
  if code == 0x01:
    raise LPAError(f"profile {iccid} not found")
  if code != 0x00:
    raise RuntimeError(f"SetNickname failed with status 0x{code:02X}")


# --- ES9P HTTP ---

def es9p_request(smdp_address: str, endpoint: str, payload: dict, error_prefix: str = "Request", session: requests.Session | None = None) -> dict:
  url = f"https://{smdp_address}/gsma/rsp2/es9plus/{endpoint}"
  headers = {"User-Agent": "gsma-rsp-lpad", "X-Admin-Protocol": "gsma/rsp/v2.3.0", "Content-Type": "application/json"}
  http = session or requests
  resp = http.post(url, json=payload, headers=headers, timeout=HTTP_TIMEOUT, verify=GSMA_CI_BUNDLE)
  resp.raise_for_status()
  if not resp.content:
    return {}
  data = resp.json()
  if "header" in data and "functionExecutionStatus" in data["header"]:
    status = data["header"]["functionExecutionStatus"]
    if status.get("status") == "Failed":
      sd = status.get("statusCodeData", {})
      reason = sd.get("reasonCode", "unknown")
      subject = sd.get("subjectCode", "unknown")
      msg = ES9P_ERROR_MESSAGES.get((reason, subject),
            f"{error_prefix} failed: {reason}/{subject} - {sd.get('message', 'unknown')}")
      raise RuntimeError(msg)
  return data


# --- Notifications ---

def list_notifications(client: AtClient) -> list[dict]:
  response = es10x_command(client, encode_tlv(TAG_LIST_NOTIFICATION, b""))
  root = require_tag(response, TAG_LIST_NOTIFICATION, "ListNotificationResponse")
  metadata_list = find_tag(root, TAG_OK)
  if metadata_list is None:
    return []
  return [decode_struct(value, NOTIFICATION) for tag, value in iter_tlv(metadata_list) if tag == TAG_NOTIFICATION_METADATA]


def process_notifications(client: AtClient) -> None:
  for notification in list_notifications(client):
    seq_number, smdp_address = notification["seqNumber"], notification["notificationAddress"]
    try:
      request = encode_tlv(TAG_RETRIEVE_NOTIFICATION, encode_tlv(TAG_OK, encode_tlv(TAG_STATUS, int_bytes(seq_number))))
      response = es10x_command(client, request)
      content = require_tag(require_tag(response, TAG_RETRIEVE_NOTIFICATION, "RetrieveNotificationsListResponse"),
                            TAG_OK, "RetrieveNotificationsListResponse")
      pending_notif = next((content[start:end] for t, _, start, end in iter_tlv(content, with_positions=True) if t in (TAG_PROFILE_INSTALL_RESULT, 0x30)), None)
      if pending_notif is None:
        raise RuntimeError("Missing PendingNotification")

      es9p_request(smdp_address, "handleNotification", {"pendingNotification": b64e(pending_notif)}, "HandleNotification")

      response = es10x_command(client, encode_tlv(TAG_NOTIFICATION_SENT, encode_tlv(TAG_STATUS, int_bytes(seq_number))))
      root = require_tag(response, TAG_NOTIFICATION_SENT, "NotificationSentResponse")
      if int.from_bytes(require_tag(root, TAG_STATUS, "RemoveNotificationFromList status"), "big") != 0:
        raise RuntimeError("RemoveNotificationFromList failed")
    except Exception as e:
      print(f"notification {seq_number} failed: {e}", file=sys.stderr)


# --- Authentication & Download ---

def get_challenge_and_info(client: AtClient) -> tuple[bytes, bytes]:
  challenge_resp = es10x_command(client, encode_tlv(TAG_EUICC_CHALLENGE, b""))
  challenge = require_tag(require_tag(challenge_resp, TAG_EUICC_CHALLENGE, "GetEuiccDataResponse"),
                          TAG_STATUS, "challenge in response")
  info_resp = es10x_command(client, encode_tlv(TAG_EUICC_INFO, b""))
  require_tag(info_resp, TAG_EUICC_INFO, "GetEuiccInfo1Response")
  return challenge, info_resp


def authenticate_server(client: AtClient, b64_signed1: str, b64_sig1: str, b64_pk_id: str, b64_cert: str, matching_id: str) -> str:
  tac = bytes([0x35, 0x29, 0x06, 0x11])
  device_info = encode_tlv(TAG_STATUS, tac) + encode_tlv(0xA1, b"")
  ctx_inner = encode_tlv(TAG_STATUS, matching_id.encode("utf-8")) + encode_tlv(0xA1, device_info)
  content = b64d(b64_signed1) + b64d(b64_sig1) + b64d(b64_pk_id) + b64d(b64_cert) + encode_tlv(0xA0, ctx_inner)
  response = es10x_command(client, encode_tlv(TAG_AUTH_SERVER, content))
  root = require_tag(response, TAG_AUTH_SERVER, "AuthenticateServerResponse")
  error_tag = find_tag(root, 0xA1)
  if error_tag is not None:
    code = int.from_bytes(error_tag, "big") if error_tag else 0
    raise RuntimeError(f"AuthenticateServer rejected by eUICC: {AUTH_SERVER_ERROR_CODES.get(code, 'unknown')} (0x{code:02X})")
  return b64e(response)


def prepare_download(client: AtClient, b64_signed2: str, b64_sig2: str, b64_cert: str, cc: str | None = None) -> str:
  smdp_signed2 = b64d(b64_signed2)
  smdp_signature2 = b64d(b64_sig2)
  smdp_certificate = b64d(b64_cert)
  smdp_signed2_root = find_tag(smdp_signed2, 0x30)
  if smdp_signed2_root is None:
    raise RuntimeError("Invalid smdpSigned2")
  transaction_id = find_tag(smdp_signed2_root, TAG_STATUS)
  cc_required_flag = find_tag(smdp_signed2_root, 0x01)
  if transaction_id is None or cc_required_flag is None:
    raise RuntimeError("Invalid smdpSigned2")
  content = smdp_signed2 + smdp_signature2
  if int.from_bytes(cc_required_flag, "big") != 0:
    if not cc:
      raise RuntimeError("Confirmation code required but not provided")
    content += encode_tlv(0x04, hashlib.sha256(hashlib.sha256(cc.encode("utf-8")).digest() + transaction_id).digest())
  content += smdp_certificate
  response = es10x_command(client, encode_tlv(TAG_PREPARE_DOWNLOAD, content))
  require_tag(response, TAG_PREPARE_DOWNLOAD, "PrepareDownloadResponse")
  return b64e(response)


def _parse_tlv_header_len(data: bytes) -> int:
  tag_len = 2 if data[0] & 0x1F == 0x1F else 1
  length_byte = data[tag_len]
  return tag_len + (1 + (length_byte & 0x7F) if length_byte & 0x80 else 1)


def _split_bpp(bpp: bytes) -> list[bytes]:
  """Split a BoundProfilePackage into APDU chunks per SGP.22 §5.7.6."""
  root_value = None
  for tag, value, start, end in iter_tlv(bpp, with_positions=True):
    if tag == TAG_BPP:
      root_value = value
      val_start = start + _parse_tlv_header_len(bpp[start:end])
      break
  if root_value is None:
    raise RuntimeError("Invalid BoundProfilePackage")

  chunks: list[bytes] = []
  for tag, value, start, end in iter_tlv(root_value, with_positions=True):
    if tag == TAG_BPP_COMMAND:
      chunks.append(bpp[0 : val_start + end])
    elif tag in (0xA0, 0xA2):
      chunks.append(bpp[val_start + start : val_start + end])
    elif tag in (0xA1, 0xA3):
      hdr_len = _parse_tlv_header_len(root_value[start:end])
      chunks.append(bpp[val_start + start : val_start + start + hdr_len])
      for _, _, cs, ce in iter_tlv(value, with_positions=True):
        chunks.append(value[cs:ce])
  return chunks


def _parse_install_result(response: bytes) -> dict[str, Any] | None:
  """Parse a ProfileInstallResult from an APDU response, or None if not present."""
  root = find_tag(response, TAG_PROFILE_INSTALL_RESULT)
  if not root:
    return None
  result_data = find_tag(root, TAG_INSTALL_RESULT_DATA)
  if not result_data:
    return None
  result: dict[str, Any] = {"seqNumber": 0, "success": False, "bppCommandId": None, "errorReason": None}
  notif_meta = find_tag(result_data, TAG_NOTIFICATION_METADATA)
  if notif_meta:
    seq_num = find_tag(notif_meta, TAG_STATUS)
    if seq_num:
      result["seqNumber"] = int.from_bytes(seq_num, "big")
  final_result = find_tag(result_data, 0xA2)
  if final_result:
    for tag, value in iter_tlv(final_result):
      if tag == 0xA0:
        result["success"] = True
      elif tag == 0xA1:
        bpp_cmd = find_tag(value, TAG_STATUS)
        if bpp_cmd:
          result["bppCommandId"] = int.from_bytes(bpp_cmd, "big")
        err = find_tag(value, 0x81)
        if err:
          result["errorReason"] = int.from_bytes(err, "big")
  return result


def load_bpp(client: AtClient, b64_bpp: str) -> dict:
  bpp = b64d(b64_bpp)
  result = None
  for chunk in _split_bpp(bpp):
    response = es10x_command(client, chunk)
    if response and (parsed := _parse_install_result(response)):
      result = parsed
      break

  if result is None:
    raise RuntimeError("Profile installation failed: no result from eUICC")
  if not result["success"] and result["errorReason"] is not None:
    msg = BPP_ERROR_MESSAGES.get(result["errorReason"])
    if not msg:
      cmd_name = BPP_COMMAND_NAMES.get(result["bppCommandId"], f"unknown({result['bppCommandId']})")
      err_name = BPP_ERROR_REASONS.get(result["errorReason"], f"unknown({result['errorReason']})")
      msg = f"Profile installation failed at {cmd_name}: {err_name}"
    raise RuntimeError(msg)
  if not result["success"]:
    raise RuntimeError("Profile installation failed: no result from eUICC")
  return result


def parse_metadata(b64_metadata: str) -> dict:
  root = find_tag(b64d(b64_metadata), TAG_PROFILE_METADATA)
  if root is None:
    raise RuntimeError("Invalid profileMetadata")
  return decode_struct(root, PROFILE)


def cancel_session(client: AtClient, transaction_id: bytes, reason: int = 127) -> str:
  content = encode_tlv(0x80, transaction_id) + encode_tlv(0x81, bytes([reason]))
  response = es10x_command(client, encode_tlv(TAG_CANCEL_SESSION, content))
  return b64e(response)


def parse_lpa_activation_code(activation_code: str) -> tuple[str, str]:
  """Parse 'LPA:1$smdp.example.com$MATCHING-ID' into (smdp_address, matching_id)."""
  if not activation_code.startswith("LPA:"):
    raise ValueError("Invalid activation code format")
  parts = activation_code[4:].split("$")
  if len(parts) != 3:
    raise ValueError("Invalid activation code format")
  return parts[1], parts[2]


def _b64_field(data: dict, key: str) -> str:
  return base64_trim(data[key])


def _cancel_session_safe(client: AtClient, smdp: str, tx_id: str, session: requests.Session) -> None:
  b64_cancel = ""
  try:
    b64_cancel = cancel_session(client, b64d(tx_id))
  except Exception:
    pass
  try:
    es9p_request(smdp, "cancelSession", {"transactionId": tx_id, "cancelSessionResponse": b64_cancel}, "CancelSession", session=session)
  except Exception:
    pass


def download_profile(client: AtClient, activation_code: str) -> str:
  """Download and install an eSIM profile. Returns the ICCID of the installed profile."""
  if not system_time_valid():
    raise RuntimeError("System time is not set; TLS certificate validation requires a valid clock")
  smdp, matching_id = parse_lpa_activation_code(activation_code)
  challenge, euicc_info = get_challenge_and_info(client)
  session = requests.Session()
  tx_id = None

  try:
    # step 1: initiate authentication
    auth = es9p_request(smdp, "initiateAuthentication", {
      "smdpAddress": smdp, "euiccChallenge": b64e(challenge),
      "euiccInfo1": b64e(euicc_info), "matchingId": matching_id,
    }, "Authentication", session=session)
    tx_id = _b64_field(auth, "transactionId")

    # step 2: authenticate server
    b64_auth = authenticate_server(client,
      _b64_field(auth, "serverSigned1"), _b64_field(auth, "serverSignature1"),
      _b64_field(auth, "euiccCiPKIdToBeUsed"), _b64_field(auth, "serverCertificate"),
      matching_id)

    # step 3: authenticate client + get metadata
    cli = es9p_request(smdp, "authenticateClient", {
      "transactionId": tx_id, "authenticateServerResponse": b64_auth,
    }, "Authentication", session=session)
    iccid = parse_metadata(_b64_field(cli, "profileMetadata"))["iccid"]

    # step 4: prepare download
    b64_prep = prepare_download(client,
      _b64_field(cli, "smdpSigned2"), _b64_field(cli, "smdpSignature2"),
      _b64_field(cli, "smdpCertificate"))

    # step 5: get and install bound profile package
    bpp = es9p_request(smdp, "getBoundProfilePackage", {
      "transactionId": tx_id, "prepareDownloadResponse": b64_prep,
    }, "GetBoundProfilePackage", session=session)
    load_bpp(client, _b64_field(bpp, "boundProfilePackage"))
    return iccid
  except Exception:
    if tx_id:
      _cancel_session_safe(client, smdp, tx_id, session)
    raise
  finally:
    session.close()


class LPA(LPABase):
  def __init__(self):
    if hasattr(self, '_client'):
      return
    self._client = AtClient(DEFAULT_DEVICE, DEFAULT_BAUD, DEFAULT_TIMEOUT)
    atexit.register(self._client.close)

  @contextmanager
  def _acquire_lock(self):
    fd = os.open(LOCK_FILE, os.O_CREAT | os.O_RDWR)
    try:
      fcntl.flock(fd, fcntl.LOCK_EX)
      yield
    finally:
      fcntl.flock(fd, fcntl.LOCK_UN)
      os.close(fd)

  @contextmanager
  def _acquire_channel(self):
    with self._acquire_lock():
      try:
        self._client.open_isdr()
        yield
      finally:
        if self._client.channel:
          try:
            self._client.query(f"AT+CCHC={self._client.channel}")
          except (RuntimeError, TimeoutError):
            pass
          self._client.channel = None

  def list_profiles(self) -> list[Profile]:
    with self._acquire_channel():
      return [
        Profile(
          iccid=p.get("iccid", ""),
          nickname=p.get("profileNickname") or "",
          enabled=p.get("profileState") == "enabled",
          provider=p.get("serviceProviderName") or "",
        )
        for p in list_profiles(self._client)
      ]

  def get_active_profile(self) -> Profile | None:
    return None

  def process_notifications(self) -> None:
    if not system_time_valid():
      raise RuntimeError("System time is not set; TLS certificate validation requires a valid clock")
    with self._acquire_channel():
      process_notifications(self._client)

  def delete_profile(self, iccid: str) -> None:
    profile = next((p for p in self.list_profiles() if p.iccid == iccid), None)
    if profile is None:
      raise LPAProfileNotFoundError(f"profile not found: {iccid}")
    if profile.is_comma:
      raise LPAError("refusing to delete a comma profile")
    with self._acquire_channel():
      request = encode_tlv(TAG_DELETE_PROFILE, encode_tlv(TAG_ICCID, string_to_tbcd(iccid)))
      response = es10x_command(self._client, request)
      code = require_tag(require_tag(response, TAG_DELETE_PROFILE, "DeleteProfileResponse"), TAG_STATUS, "DeleteProfile status")[0]
    if code != PROFILE_OK:
      raise LPAError(f"DeleteProfile failed: {PROFILE_ERROR_CODES.get(code, 'unknown')} (0x{code:02X})")

  def download_profile(self, qr: str, nickname: str | None = None) -> None:
    with self._acquire_channel():
      iccid = download_profile(self._client, qr)
      if nickname and iccid:
        set_profile_nickname(self._client, iccid, nickname)

  def nickname_profile(self, iccid: str, nickname: str) -> None:
    with self._acquire_channel():
      set_profile_nickname(self._client, iccid, nickname)

  def _enable_profile(self, iccid: str) -> int:
    inner = encode_tlv(TAG_OK, encode_tlv(TAG_ICCID, string_to_tbcd(iccid)))
    inner += b'\x01\x01\x01'  # refreshFlag=1
    response = es10x_command(self._client, encode_tlv(TAG_ENABLE_PROFILE, inner))
    return require_tag(require_tag(response, TAG_ENABLE_PROFILE, "EnableProfileResponse"), TAG_STATUS, "EnableProfile status")[0]

  def switch_profile(self, iccid: str) -> None:
    with self._acquire_channel():
      code = self._enable_profile(iccid)
      if code == PROFILE_CAT_BUSY:  # stale eUICC transaction, reset and retry
        self._client._reset_modem()
        self._client.open_isdr()
        code = self._enable_profile(iccid)
      if code not in (PROFILE_OK, PROFILE_NOT_IN_DISABLED_STATE):
        raise LPAError(f"EnableProfile failed: {PROFILE_ERROR_CODES.get(code, 'unknown')} (0x{code:02X})")

  def is_euicc(self) -> bool:
    # +CCHO:<n> -> ISD-R applet present, eUICC. Any error -> non-eUICC.
    with self._acquire_lock():
      try:
        lines = self._client.query(f'AT+CCHO="{ISDR_AID}"')
      except RuntimeError:
        return False
      for line in lines:
        if line.startswith("+CCHO:") and (ch := line.split(":", 1)[1].strip()):
          try:
            self._client.query(f"AT+CCHC={ch}")
          except (RuntimeError, TimeoutError):
            pass
          self._client.channel = None
          return True
      return False
