export interface DecodedBody {
  readonly text: string | null;
  readonly error?: string;
}

/**
 * Decoder for the iMessage `attributedBody` NSKeyedArchiver typedstream blob.
 *
 * Strategy (in order):
 *   1. Walk every NSString class-definition marker in the blob, attempt to
 *      read a length-prefixed UTF-8 run after each, keep the longest valid
 *      extraction. This handles the case where the class marker appears more
 *      than once due to back-references or appended attribute dictionaries.
 *   2. Fallback: longest printable byte run that contains at least one letter
 *      and is not a known typedstream class-name marker.
 *
 * Length-prefix variants supported at each NSString site:
 *   - 0x00..0x7F  → that byte is the length (short form)
 *   - 0x81 LO HI  → 16-bit little-endian length
 *   - 0x82 B0..B3 → 32-bit little-endian length
 *
 * Limits:
 *   - Single backing-string only. Multi-run NSAttributedString uses one
 *     backing string with run-length-encoded attribute spans — content is
 *     not split across runs, so this is fine for real iMessage data.
 *   - Inline NSTextAttachment objects appear as U+FFFC in the string and
 *     come through transparently.
 */
export function decodeAttributedBody(base64: string): DecodedBody {
  if (!base64) return { text: null };

  let bytes: Uint8Array;
  try {
    bytes = Uint8Array.from(Buffer.from(base64, 'base64'));
  } catch (err) {
    return { text: null, error: `invalid base64: ${(err as Error).message}` };
  }
  if (bytes.length === 0) return { text: null };

  const fromMarkers = bestNSStringRun(bytes);
  if (fromMarkers !== null) return { text: fromMarkers };

  return { text: longestPrintableRun(bytes) };
}

const NS_STRING_MARKER = Uint8Array.from([
  0x4e, 0x53, 0x53, 0x74, 0x72, 0x69, 0x6e, 0x67, // "NSString"
]);

/**
 * Padding between the "NSString" marker and the length byte in the iMessage
 * typedstream layout. Empirically 5 bytes (type-signature + alignment) — same
 * value the Mac-side extractor uses.
 */
const NS_STRING_PADDING = 5;

function bestNSStringRun(bytes: Uint8Array): string | null {
  let best: string | null = null;
  let bestLen = 0;

  let searchFrom = 0;
  while (searchFrom <= bytes.length - NS_STRING_MARKER.length) {
    const idx = findSubsequenceFrom(bytes, NS_STRING_MARKER, searchFrom);
    if (idx < 0) break;

    const candidate = readNSStringRun(bytes, idx);
    if (candidate !== null && candidate.length > bestLen) {
      best = candidate;
      bestLen = candidate.length;
    }

    searchFrom = idx + 1;
  }

  return best;
}

function readNSStringRun(bytes: Uint8Array, markerIndex: number): string | null {
  const contentStart = markerIndex + NS_STRING_MARKER.length + NS_STRING_PADDING;
  if (contentStart >= bytes.length) return null;

  const len = readVarLength(bytes, contentStart);
  if (len === null || len.length <= 0) return null;
  if (len.nextOffset + len.length > bytes.length) return null;

  const slice = bytes.subarray(len.nextOffset, len.nextOffset + len.length);
  const decoded = safeUtf8(slice);
  if (decoded === null) return null;
  const trimmed = decoded.trim();
  return trimmed.length > 0 ? trimmed : null;
}

interface VarLength {
  readonly length: number;
  readonly nextOffset: number;
}

function readVarLength(bytes: Uint8Array, offset: number): VarLength | null {
  if (offset >= bytes.length) return null;
  const tag = bytes[offset] ?? 0;

  if (tag < 0x81) {
    return { length: tag, nextOffset: offset + 1 };
  }
  if (tag === 0x81) {
    if (offset + 3 > bytes.length) return null;
    const lo = bytes[offset + 1] ?? 0;
    const hi = bytes[offset + 2] ?? 0;
    return { length: lo | (hi << 8), nextOffset: offset + 3 };
  }
  if (tag === 0x82) {
    if (offset + 5 > bytes.length) return null;
    const b0 = bytes[offset + 1] ?? 0;
    const b1 = bytes[offset + 2] ?? 0;
    const b2 = bytes[offset + 3] ?? 0;
    const b3 = bytes[offset + 4] ?? 0;
    // Unsigned 32-bit assembly; >>> 0 forces unsigned interpretation.
    const length = (b0 | (b1 << 8) | (b2 << 16) | (b3 << 24)) >>> 0;
    return { length, nextOffset: offset + 5 };
  }
  return null;
}

function findSubsequenceFrom(haystack: Uint8Array, needle: Uint8Array, from: number): number {
  if (needle.length === 0) return -1;
  const last = haystack.length - needle.length;
  outer: for (let i = Math.max(0, from); i <= last; i++) {
    for (let j = 0; j < needle.length; j++) {
      if (haystack[i + j] !== needle[j]) continue outer;
    }
    return i;
  }
  return -1;
}

/**
 * NSKeyedArchiver class-name markers that often appear as the longest
 * printable run when the structured extract path fails. Treating them as
 * content would mean polluting a downstream classifier with the literal
 * string "NSString" — clearly wrong. The Mac-side heuristic shares this
 * blind spot; the TS port deliberately filters them out.
 */
const TYPEDSTREAM_MARKER_WORDS = new Set([
  'NSString',
  'NSMutableString',
  'NSAttributedString',
  'NSMutableAttributedString',
  'NSDictionary',
  'NSMutableDictionary',
  'NSArray',
  'NSMutableArray',
  'NSNumber',
  'NSObject',
  'NSValue',
  'NSData',
  'NSDate',
  'streamtyped',
]);

function longestPrintableRun(bytes: Uint8Array): string | null {
  let bestText: string | null = null;
  let bestLen = 0;
  let runStart = -1;

  const flush = (endExclusive: number): void => {
    if (runStart < 0) return;
    if (endExclusive - runStart > 3) {
      const slice = bytes.subarray(runStart, endExclusive);
      const decoded = safeUtf8(slice);
      if (decoded !== null && /\p{L}/u.test(decoded)) {
        const trimmed = decoded.trim();
        if (
          trimmed.length > bestLen &&
          !TYPEDSTREAM_MARKER_WORDS.has(trimmed) &&
          !trimmed.startsWith('NS') &&
          !trimmed.startsWith('__') &&
          !trimmed.includes('attributedString')
        ) {
          bestText = trimmed;
          bestLen = trimmed.length;
        }
      }
    }
    runStart = -1;
  };

  for (let i = 0; i < bytes.length; i++) {
    const b = bytes[i] ?? 0;
    const printable =
      (b >= 0x20 && b <= 0x7e) ||
      b >= 0x80 ||
      b === 0x0a ||
      b === 0x0d;
    if (printable) {
      if (runStart < 0) runStart = i;
    } else {
      flush(i);
    }
  }
  flush(bytes.length);

  return bestText;
}

const utf8Decoder = new TextDecoder('utf-8', { fatal: true });
function safeUtf8(slice: Uint8Array): string | null {
  try {
    return utf8Decoder.decode(slice);
  } catch {
    return null;
  }
}
