<?php
/**
 * numbers.online — CID Superfecta caller-name source for FreePBX.
 *
 * Looks up inbound caller names (CNAM) against the numbers.online phone-intelligence
 * API and, optionally, flags high-risk callers using its supplementary spam signal.
 *
 *   Endpoint : GET https://numbers.online/v1/lookup/{e164}  (Bearer header auth)
 *   Signup   : https://numbers.online/v1/account/signup
 *   Docs     : https://numbers.online/docs
 *
 * Install one file only:
 *   /var/www/html/admin/modules/superfecta/sources/source-NumbersOnline.module
 * then `fwconsole chown` and enable it in your CID Superfecta scheme. Full operator
 * guide: https://numbers.online/docs (FreePBX / CID Superfecta).
 *
 * Design notes for maintainers:
 *   - Superfecta's inherited get_url_contents() helper cannot send custom HTTP
 *     headers, so we use raw curl (as source-Telnyx.module does) to carry the
 *     Authorization: Bearer header. The API key is therefore NEVER placed in a URL.
 *   - Fail-open: caller-name lookup must never stall or block call setup. Tight
 *     curl timeouts (2s connect / 5s total); on any error we return null so the
 *     call proceeds and other Superfecta sources can try.
 *   - PHP 7.4 and 8.2 compatible: no match/enum/readonly, every array access is
 *     guarded with isset()/is_array(), so 8.x emits no undefined-key warnings.
 *   - Privacy/liability: we never DebugPrint the API key or the caller's number.
 *     The spam signal is a low-confidence supplementary signal — the operator opts
 *     in (Spam_Flag) and chooses the threshold. We only set $this->spam; Superfecta
 *     decides what to do with a flagged call.
 */

// No closing PHP tag — Superfecta sources must not emit trailing whitespace.

class NumbersOnline extends superfecta_base {

	public $description = 'numbers.online — caller-name (CNAM) lookup with an optional, low-confidence supplementary spam signal. A drop-in caller-ID source backed by the numbers.online phone-intelligence API. Sign up and read the docs at https://numbers.online/docs';

	// Minimum Superfecta version this source format targets.
	public $version_requirement = '2.11';

	// Set true by get_caller_id() when the spam signal meets the operator threshold.
	// Superfecta reads this to apply the scheme's spam handling.
	public $spam = false;

	// Config schema rendered in the CID Superfecta admin UI.
	public $source_param = array(
		'API_Key' => array(
			'description' => 'Your numbers.online API key. Create one for free at https://numbers.online/v1/account/signup (the key is shown once at signup). Sent only as an Authorization header over TLS — never placed in a URL or logged.',
			'type'        => 'password',
		),
		'Spam_Flag' => array(
			'description' => 'Flag high-risk callers using the numbers.online supplementary spam signal. This is a labeled, low-confidence signal — not an assertion that a caller is unlawful. When enabled, Superfecta applies your scheme\'s spam handling to flagged calls.',
			'type'        => 'checkbox',
			'default'     => 'checked',
		),
		'Spam_Threshold' => array(
			'description' => 'Flag a caller only when its spam_score (1–99, higher = riskier) is at or above this value. Default 80 flags only the highest-risk callers.',
			'type'        => 'number',
			'default'     => '80',
		),
		'Ignore_Keywords' => array(
			'description' => 'Comma-separated words. If the returned caller name contains any of them, it is discarded so other Superfecta sources can supply a name (e.g. placeholder names you do not want shown).',
			'type'        => 'textarea',
			'default'     => 'unavailable, unknown',
		),
	);

	/**
	 * Resolve a caller name for $thenumber.
	 *
	 * @param string $thenumber  Inbound number in loose form (often 10- or 11-digit NANP).
	 * @param array  $run_param  Operator config from $source_param.
	 * @return string|null       Caller name, or null when none (so other sources run).
	 */
	function get_caller_id($thenumber, $run_param = array()) {
		// Seed from any name a prior source already found (EZCNAM convention).
		$caller_id = parent::get_caller_id($thenumber, $run_param);

		// API key is required; without it we are a no-op and other sources run.
		if (empty($run_param['API_Key'])) {
			$this->DebugPrint('numbers.online: no API key configured — skipping.');
			return $caller_id;
		}

		// Normalize to E.164. $thenumber arrives as loose digits.
		$e164 = $this->to_e164($thenumber);
		if ($e164 === null) {
			$this->DebugPrint('numbers.online: could not derive an E.164 number from the input — skipping.');
			return $caller_id;
		}

		// Path-encode the number; the key travels in a header, never the URL.
		$url = 'https://numbers.online/v1/lookup/' . rawurlencode($e164);
		// Privacy: never log the raw caller number — log only its length.
		$this->DebugPrint('numbers.online: looking up a ' . strlen($e164) . '-char E.164 number.');

		$crl = curl_init();
		if ($crl === false) {
			$this->DebugPrint('numbers.online: curl unavailable — failing open.');
			return $caller_id;
		}
		$headers = array(
			'Accept: application/json',
			'Authorization: Bearer ' . $run_param['API_Key'],
		);
		curl_setopt($crl, CURLOPT_URL, $url);
		curl_setopt($crl, CURLOPT_HTTPHEADER, $headers);
		curl_setopt($crl, CURLOPT_RETURNTRANSFER, true);
		curl_setopt($crl, CURLOPT_FOLLOWLOCATION, false);
		curl_setopt($crl, CURLOPT_SSL_VERIFYPEER, true);
		curl_setopt($crl, CURLOPT_SSL_VERIFYHOST, 2);
		// Fail-open timeouts: a slow lookup must not delay call setup.
		curl_setopt($crl, CURLOPT_CONNECTTIMEOUT, 2);
		curl_setopt($crl, CURLOPT_TIMEOUT, 5);

		$body = curl_exec($crl);
		$http = (int) curl_getinfo($crl, CURLINFO_HTTP_CODE);
		$err  = curl_error($crl);
		curl_close($crl);

		// Transport-level failure (DNS, TLS, timeout): fail open, never block.
		if ($body === false || $body === '') {
			$this->DebugPrint('numbers.online: request failed (' . ($err !== '' ? $err : 'empty response') . ') — failing open.');
			return $caller_id;
		}

		// Non-2xx (e.g. 401 bad key, 402 out of credit, 429 rate limited): fail open.
		// We never surface an error string as a caller name.
		if ($http < 200 || $http >= 300) {
			$this->DebugPrint('numbers.online: API returned HTTP ' . $http . ' — failing open (check key/balance/rate limit at https://numbers.online/docs).');
			return $caller_id;
		}

		$result = json_decode($body, true);
		if (!is_array($result)) {
			$this->DebugPrint('numbers.online: could not parse API response — failing open.');
			return $caller_id;
		}

		// Spam signal (operator opt-in). Set the flag before deciding on the name so
		// that a flagged-but-nameless caller still carries the signal downstream.
		if (!empty($run_param['Spam_Flag'])
			&& isset($result['spam_score'])
			&& is_numeric($result['spam_score'])) {
			$threshold = isset($run_param['Spam_Threshold']) ? (int) $run_param['Spam_Threshold'] : 80;
			if ((int) $result['spam_score'] >= $threshold) {
				$this->spam = true;
				$this->DebugPrint('numbers.online: spam_score >= threshold (' . $threshold . ') — flagging caller.');
			}
		}

		// Caller name. cnam is a string or null (no name / no supplier / suppressed).
		$name = (isset($result['cnam']) && is_string($result['cnam'])) ? trim($result['cnam']) : '';
		if ($name === '') {
			$this->DebugPrint('numbers.online: no name available for this number.');
			return $caller_id;
		}

		// Ignore-keywords filter (EZCNAM convention): discard placeholder names so
		// other sources can try. Return null (not ''), per Superfecta contract.
		if ($this->name_is_ignored($name, isset($run_param['Ignore_Keywords']) ? $run_param['Ignore_Keywords'] : '')) {
			$this->DebugPrint('numbers.online: name matched an Ignore_Keywords entry — discarding so other sources can try.');
			return $caller_id;
		}

		$this->DebugPrint('numbers.online: returning a caller name.');
		return $name;
	}

	/**
	 * Normalize a loose inbound number to E.164.
	 *   10 digits         -> assume NANP, prefix +1
	 *   11 digits, lead 1 -> NANP, prefix +
	 *   already had a '+'  -> keep the leading +, strip other non-digits
	 *   otherwise          -> treat remaining digits as country-coded, prefix +
	 * Returns null when there are no usable digits.
	 */
	private function to_e164($thenumber) {
		$raw    = (string) $thenumber;
		$had_plus = (strpos($raw, '+') === 0);
		$digits = preg_replace('/\D+/', '', $raw);
		if ($digits === null || $digits === '') {
			return null;
		}
		if ($had_plus) {
			return '+' . $digits;
		}
		$len = strlen($digits);
		if ($len === 10) {
			return '+1' . $digits;
		}
		if ($len === 11 && $digits[0] === '1') {
			return '+' . $digits;
		}
		return '+' . $digits;
	}

	/**
	 * True if $name contains any comma-separated keyword from $ignore_csv
	 * (case-insensitive substring match). Empty keyword list never matches.
	 */
	private function name_is_ignored($name, $ignore_csv) {
		$ignore_csv = (string) $ignore_csv;
		if (trim($ignore_csv) === '') {
			return false;
		}
		$haystack = strtolower($name);
		$parts = explode(',', $ignore_csv);
		foreach ($parts as $kw) {
			$kw = strtolower(trim($kw));
			if ($kw !== '' && strpos($haystack, $kw) !== false) {
				return true;
			}
		}
		return false;
	}
}
