#!/usr/bin/env php
<?php
/**
 * markproof — check web pages for accessibility problems from the command line, in continuous
 * integration, or on a schedule of your own.
 *
 * Examples:
 *   markproof scan https://example.com/ https://example.com/pricing
 *   markproof scan --sitemap https://example.com/sitemap.xml --max-pages 25
 *   markproof scan --sitemap https://example.com/sitemap.xml --format github
 *   markproof scan https://example.com/ --baseline .markproof.json   # only fail on what is new
 *   markproof scan https://example.com/ --baseline .markproof.json --update-baseline
 *
 * Exit codes: 0 nothing new above the threshold, 1 new findings, 2 a usage or network problem.
 *
 * It checks the problems that can be proven from HTML, and it says so: it cannot see colour
 * contrast, judge whether alt text is useful, or know how a page behaves in a screen reader.
 * It is not a statement of compliance with anything.
 *
 * @package MarkproofCli
 */

declare( strict_types = 1 );

define( 'CLI_VERSION', '0.1.0' );

/* The rules are the same file the WordPress plugin ships. This shim provides the handful of
 * WordPress functions it calls, so there is exactly one copy of the checks anywhere. */
if ( ! defined( 'ABSPATH' ) ) {
	define( 'ABSPATH', __DIR__ . '/' );
}
if ( ! function_exists( '__' ) ) {
	function __( $text, $domain = null ) { return $text; } // phpcs:ignore
}
if ( ! function_exists( 'esc_html' ) ) {
	function esc_html( $text ) { return htmlspecialchars( (string) $text, ENT_QUOTES, 'UTF-8' ); } // phpcs:ignore
}
if ( ! function_exists( 'esc_attr' ) ) {
	function esc_attr( $text ) { return htmlspecialchars( (string) $text, ENT_QUOTES, 'UTF-8' ); } // phpcs:ignore
}
if ( ! function_exists( 'apply_filters' ) ) {
	function apply_filters( $tag, $value ) { return $value; } // phpcs:ignore
}
require_once __DIR__ . '/rules.php';

const UA = 'Markproof-CLI/' . CLI_VERSION . ' (+https://markproof.dev; accessibility checks)';

/* ------------------------------------------------------------------ helpers */

function cli_fail( $message, $code = 2 ) {
	fwrite( STDERR, 'markproof: ' . $message . "\n" );
	exit( $code );
}

/** One HTTP GET, with a size cap and a timeout. */
function cli_get( $url, $max = 5000000 ) {
	$chunks = '';
	$ch = curl_init( $url );
	curl_setopt_array(
		$ch,
		array(
			CURLOPT_RETURNTRANSFER => true,
			CURLOPT_FOLLOWLOCATION => true,
			CURLOPT_MAXREDIRS      => 3,
			CURLOPT_TIMEOUT        => 25,
			CURLOPT_CONNECTTIMEOUT => 8,
			CURLOPT_USERAGENT      => UA,
			CURLOPT_HTTPHEADER     => array( 'Accept: text/html,application/xhtml+xml,text/xml,*/*;q=0.8' ),
		)
	);
	curl_setopt(
		$ch,
		CURLOPT_WRITEFUNCTION,
		function ( $handle, $chunk ) use ( &$chunks, $max ) {
			$chunks .= $chunk;
			return ( strlen( $chunks ) > $max ) ? 0 : strlen( $chunk );
		}
	);
	$ok   = curl_exec( $ch );
	$code = (int) curl_getinfo( $ch, CURLINFO_RESPONSE_CODE );
	$type = (string) curl_getinfo( $ch, CURLINFO_CONTENT_TYPE );
	$err  = curl_error( $ch );
	curl_close( $ch );
	if ( ! $ok && '' === $chunks ) {
		return array( 'error' => $err ? $err : 'request failed' );
	}
	if ( $code < 200 || $code >= 400 ) {
		return array( 'error' => 'HTTP ' . $code );
	}
	return array( 'body' => $chunks, 'type' => $type );
}

/** Refuse anything that is not http(s) on a public host. */
function cli_validate_url( $url ) {
	if ( ! preg_match( '#^https?://#i', (string) $url ) ) {
		cli_fail( 'only http and https addresses can be checked: ' . $url );
	}
	$host = strtolower( (string) parse_url( $url, PHP_URL_HOST ) );
	if ( '' === $host ) {
		cli_fail( 'that does not look like a URL: ' . $url );
	}
	$ips = array();
	if ( filter_var( $host, FILTER_VALIDATE_IP ) ) {
		$ips[] = $host;
	} else {
		foreach ( (array) @dns_get_record( $host, DNS_A | DNS_AAAA ) as $record ) {
			foreach ( array( 'ip', 'ipv6' ) as $key ) {
				if ( ! empty( $record[ $key ] ) ) {
					$ips[] = $record[ $key ];
				}
			}
		}
	}
	foreach ( $ips as $ip ) {
		if ( ! filter_var( $ip, FILTER_VALIDATE_IP, FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE ) ) {
			// A private address is almost always a local staging site, which is a legitimate use.
			if ( ! getenv( 'ACCESSAUDIT_ALLOW_PRIVATE' ) ) {
				cli_fail( $url . ' resolves to a private address. Set ACCESSAUDIT_ALLOW_PRIVATE=1 if that is intentional (for example a staging site).' );
			}
		}
	}
	return $url;
}

/** A small robots.txt check: enough to be polite, not a full implementation. */
function cli_robots_allowed( $url ) {
	$parts = parse_url( $url );
	$origin = $parts['scheme'] . '://' . $parts['host'] . ( isset( $parts['port'] ) ? ':' . $parts['port'] : '' );
	$cache  = sys_get_temp_dir() . '/markproof-robots-' . md5( $origin );
	if ( is_file( $cache ) && ( time() - filemtime( $cache ) ) < 3600 ) {
		$rules = json_decode( (string) file_get_contents( $cache ), true );
	} else {
		$rules = array( 'disallow' => array(), 'allow' => array() );
		$res   = cli_get( $origin . '/robots.txt', 200000 );
		if ( isset( $res['body'] ) ) {
			$ours = false;
			foreach ( preg_split( '/\r\n|\r|\n/', (string) $res['body'] ) as $line ) {
				$line = trim( preg_replace( '/#.*$/', '', $line ) );
				if ( '' === $line || false === strpos( $line, ':' ) ) {
					continue;
				}
				list( $key, $value ) = array_map( 'trim', explode( ':', $line, 2 ) );
				$key = strtolower( $key );
				if ( 'user-agent' === $key ) {
					$ours = ( '*' === $value || 0 === stripos( $value, 'markproof' ) );
				} elseif ( $ours && 'disallow' === $key && '' !== $value ) {
					$rules['disallow'][] = $value;
				} elseif ( $ours && 'allow' === $key && '' !== $value ) {
					$rules['allow'][] = $value;
				}
			}
		}
		@file_put_contents( $cache, (string) json_encode( $rules ), LOCK_EX );
	}
	$path = ( $parts['path'] ?? '/' ) . ( isset( $parts['query'] ) ? '?' . $parts['query'] : '' );
	$best = -1;
	$deny = false;
	foreach ( array( 'allow' => false, 'disallow' => true ) as $kind => $is_deny ) {
		foreach ( $rules[ $kind ] as $pattern ) {
			$regex = '#^' . str_replace( array( '\*', '\$' ), array( '.*', '$' ), preg_quote( $pattern, '#' ) ) . '#';
			if ( preg_match( $regex, $path ) && strlen( $pattern ) > $best ) {
				$best = strlen( $pattern );
				$deny = $is_deny;
			}
		}
	}
	return ! $deny;
}

/** URLs from a sitemap, one level of index included. */
function cli_urls_from_sitemap( $sitemap, $limit, $budget = 6 ) {
	$res = cli_get( $sitemap, 3000000 );
	if ( isset( $res['error'] ) ) {
		cli_fail( 'could not read the sitemap: ' . $res['error'] );
	}
	$body = (string) $res['body'];
	preg_match_all( '#<loc>\s*([^<\s]+)\s*</loc>#i', $body, $m );
	$urls = array();
	foreach ( $m[1] as $loc ) {
		if ( preg_match( '#\.xml($|\?)#i', $loc ) && $budget > 0 ) {
			$budget--;
			$sub = cli_get( $loc, 3000000 );
			if ( isset( $sub['body'] ) && preg_match_all( '#<loc>\s*([^<\s]+)\s*</loc>#i', (string) $sub['body'], $m2 ) ) {
				foreach ( $m2[1] as $child_loc ) {
					$urls[] = $child_loc;
				}
			}
		} else {
			$urls[] = $loc;
		}
		if ( count( $urls ) >= $limit ) {
			break;
		}
	}
	return array_slice( array_values( array_unique( $urls ) ), 0, $limit );
}

/* ------------------------------------------------------------------ scanning */

function cli_scan_one( $url ) {
	$res = cli_get( $url );
	if ( isset( $res['error'] ) ) {
		return array( 'url' => $url, 'error' => $res['error'], 'findings' => array() );
	}
	if ( false === stripos( (string) ( $res['type'] ?? '' ), 'html' ) ) {
		return array( 'url' => $url, 'error' => 'not an HTML page (' . ( $res['type'] ?? 'unknown type' ) . ')', 'findings' => array() );
	}
	$issues = Markproof_Rules::analyse( (string) $res['body'] );
	$meta   = Markproof_Rules::metadata();
	$out    = array();
	foreach ( $issues as $issue ) {
		$out[] = array(
			'rule'     => $issue['rule_id'],
			'title'    => $meta[ $issue['rule_id'] ]['title'] ?? $issue['rule_id'],
			'severity' => $issue['severity'],
			'fix'      => $meta[ $issue['rule_id'] ]['fix'] ?? '',
			'snippet'  => $issue['snippet'],
		);
	}
	return array( 'url' => $url, 'error' => '', 'findings' => $out );
}

function cli_key( $url, $finding ) {
	return $url . '|' . $finding['rule'] . '|' . md5( $finding['snippet'] );
}

/* ------------------------------------------------------------------ arguments */

$argv = array_slice( $argv, 1 );
if ( ! $argv || in_array( $argv[0], array( '-h', '--help', 'help' ), true ) ) {
	echo "markproof " . CLI_VERSION . " — accessibility checks from the command line\n\n";
	echo "Usage:\n";
	echo "  markproof scan <url> [<url> ...] [options]\n";
	echo "  markproof scan --sitemap <url> [options]\n\n";
	echo "Options:\n";
	echo "  --sitemap <url>       discover pages from a sitemap\n";
	echo "  --max-pages <n>       stop after n pages (default 25)\n";
	echo "  --format <text|json|github>   github prints ::error lines that annotate a pull request\n";
	echo "  --baseline <file>     only fail on findings that are not in this file yet\n";
	echo "  --update-baseline     write the current findings to the baseline file and exit 0\n";
	echo "  --fail-on <none|warning|error>   what counts as a failure (default error)\n";
	echo "  --ignore-robots       do not respect robots.txt (only for sites you own)\n";
	echo "  --quiet               print only failures\n\n";
	echo "This checks what can be proven from HTML. It cannot see colour contrast, judge alt text or\n";
	echo "describe a screen reader's experience, and it is not a statement of compliance.\n";
	exit( 0 );
}

$command = array_shift( $argv );
if ( 'scan' !== $command ) {
	cli_fail( 'unknown command: ' . $command . ' (try: markproof help)' );
}

$urls         = array();
$sitemap      = '';
$max_pages    = 25;
$format       = 'text';
$baseline     = '';
$update       = false;
$fail_on      = 'error';
$ignore_robots = false;
$quiet        = false;

while ( $argv ) {
	$arg = array_shift( $argv );
	switch ( $arg ) {
		case '--sitemap':
			$sitemap = (string) array_shift( $argv );
			break;
		case '--max-pages':
			$max_pages = max( 1, (int) array_shift( $argv ) );
			break;
		case '--format':
			$format = (string) array_shift( $argv );
			break;
		case '--baseline':
			$baseline = (string) array_shift( $argv );
			break;
		case '--update-baseline':
			$update = true;
			break;
		case '--fail-on':
			$fail_on = (string) array_shift( $argv );
			break;
		case '--ignore-robots':
			$ignore_robots = true;
			break;
		case '--quiet':
			$quiet = true;
			break;
		default:
			if ( 0 === strpos( $arg, '--' ) ) {
				cli_fail( 'unknown option: ' . $arg );
			}
			$urls[] = $arg;
	}
}

if ( $sitemap ) {
	foreach ( cli_urls_from_sitemap( cli_validate_url( $sitemap ), $max_pages ) as $url ) {
		$urls[] = $url;
	}
}
if ( ! $urls ) {
	cli_fail( 'give me at least one URL, or a --sitemap' );
}
$urls = array_slice( array_unique( $urls ), 0, $max_pages );

/* ------------------------------------------------------------------ run */

$results = array();
$failed_pages = 0;
foreach ( $urls as $url ) {
	$url = cli_validate_url( $url );
	if ( ! $ignore_robots && ! cli_robots_allowed( $url ) ) {
		$results[] = array( 'url' => $url, 'error' => 'skipped: robots.txt asks us not to fetch this page', 'findings' => array() );
		continue;
	}
	$result = cli_scan_one( $url );
	if ( '' !== $result['error'] ) {
		$failed_pages++;
	}
	$results[] = $result;
}

$known  = array();
$all    = array();
foreach ( $results as $result ) {
	foreach ( $result['findings'] as $finding ) {
		$all[ cli_key( $result['url'], $finding ) ] = $finding + array( 'url' => $result['url'] );
	}
}
if ( $baseline && is_file( $baseline ) ) {
	$decoded = json_decode( (string) file_get_contents( $baseline ), true );
	foreach ( (array) ( $decoded['findings'] ?? array() ) as $key => $row ) {
		$known[ $key ] = true;
	}
}

if ( $update ) {
	$payload = array( 'generated' => gmdate( 'c' ), 'note' => 'Known accessibility findings, ignored by markproof so that only new ones fail a build.', 'findings' => array() );
	foreach ( $all as $key => $row ) {
		$payload['findings'][ $key ] = array( 'url' => $row['url'], 'rule' => $row['rule'], 'severity' => $row['severity'], 'snippet' => mb_substr( $row['snippet'], 0, 200 ) );
	}
	file_put_contents( $baseline, (string) json_encode( $payload, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES ) );
	fwrite( STDERR, sprintf( "markproof: baseline updated with %d findings in %s\n", count( $all ), $baseline ) );
	exit( 0 );
}

$new = array();
foreach ( $all as $key => $row ) {
	if ( ! isset( $known[ $key ] ) ) {
		$new[ $key ] = $row;
	}
}

/* error ranks 0, warning ranks 1. "fail on error" therefore means rank must be 0. */
$threshold = array( 'none' => -1, 'error' => 0, 'warning' => 1 );
$minimum   = $threshold[ $fail_on ] ?? 0;
$failing   = array();
foreach ( $new as $row ) {
	$rank = ( 'error' === $row['severity'] ) ? 0 : 1;
	if ( $rank <= $minimum ) {
		$failing[ cli_key( $row['url'], $row ) ] = $row;
	}
}

/* ------------------------------------------------------------------ report */

if ( 'json' === $format ) {
	echo json_encode(
		array(
			'version'     => CLI_VERSION,
			'pages'       => array_map( function ( $r ) { return array( 'url' => $r['url'], 'error' => $r['error'], 'findings' => count( $r['findings'] ) ); }, $results ),
			'newFindings' => array_values( $new ),
			'failing'     => count( $failing ),
			'baseline'    => $baseline ?: null,
			'scope'       => 'HTML-level accessibility checks only: no colour contrast, no judgement of alt text, no screen reader experience, and not a statement of compliance.',
		),
		JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES
	) . "\n";
} elseif ( 'github' === $format ) {
	foreach ( $results as $result ) {
		if ( '' !== $result['error'] ) {
			echo '::warning file=' . $result['url'] . '::' . $result['error'] . "\n";
		}
	}
	foreach ( $new as $row ) {
		$message = sprintf( '%s: %s — %s', $row['severity'], $row['title'], mb_substr( $row['snippet'], 0, 200 ) );
		$command = isset( $failing[ cli_key( $row['url'], $row ) ] ) ? 'error' : 'warning';
		printf( "::%s file=%s::%s\n", $command, $row['url'], $message );
	}
	if ( $failing ) {
		printf( "::error::%d new accessibility finding(s). %s\n", count( $failing ), 'They are listed above with the exact markup responsible.' );
	}
} else {
	foreach ( $results as $result ) {
		if ( '' !== $result['error'] ) {
			printf( "%-70s %s\n", $result['url'], $result['error'] );
			continue;
		}
		printf( "%-70s %d finding(s)\n", $result['url'], count( $result['findings'] ) );
		if ( $quiet ) {
			continue;
		}
		foreach ( $result['findings'] as $finding ) {
			printf( "    %-8s %-40s %s\n", $finding['severity'], $finding['title'], mb_substr( $finding['snippet'], 0, 80 ) );
		}
	}
	if ( $baseline ) {
		printf( "\n%d new finding(s) since the baseline; %d in total.\n", count( $new ), count( $all ) );
	}
	printf(
		"\nChecked %d page(s). This finds HTML-level problems only: no colour contrast, no judgement of\nalt text, no screen reader experience, and it is not a statement of compliance.\n",
		count( $results )
	);
}

/* ------------------------------------------------------------------ exit */

if ( $failing ) {
	exit( 1 );
}
if ( $failed_pages && count( $results ) === $failed_pages ) {
	exit( 2 );
}
exit( 0 );
