<?php

// Where the uploads space goes, read from the attachment metadata. Since WordPress 6.0 the
// metadata stores the size of every file it describes, so this needs no scan and no walk of the
// uploads folder; only uploads older than that are measured on disk, within the time budget.
// Every number is something Media Cleaner can act on, which is the point of showing it.
class MeowPro_WPMC_Storage {

	const TRANSIENT = 'wpmc_storage';
	const BUDGET = 10;
	const PAGE = 500;
	const LARGEST = 12;

	private $core;
	private $deadline = 0;

	public function __construct( $core ) {
		$this->core = $core;
	}

	public function get( $refresh = false ) {
		$cached = $refresh ? false : get_transient( self::TRANSIENT );
		if ( is_array( $cached ) ) return $cached;
		$stats = $this->compute();
		set_transient( self::TRANSIENT, $stats, DAY_IN_SECONDS );
		return $stats;
	}

	// The whole site on disk, not only the Media Library: each folder of wp-content, and WordPress
	// itself. Walking plugins and caches can take long on big sites, so it has its own budget and
	// a folder cut short is reported as a lower bound rather than guessed.
	public function folders( $refresh = false ) {
		$cached = $refresh ? false : get_transient( self::TRANSIENT . '_folders' );
		if ( is_array( $cached ) ) return $cached;
		$this->deadline = microtime( true ) + 20;
		$folders = array();
		$content = trailingslashit( WP_CONTENT_DIR );
		foreach ( new DirectoryIterator( $content ) as $entry ) {
			if ( $entry->isDot() || !$entry->isDir() || $entry->isLink() ) continue;
			$folders[] = $this->measure( $entry->getFilename(), $entry->getPathname() );
		}
		$core = array( 'name' => 'WordPress', 'path' => '', 'bytes' => 0, 'files' => 0, 'complete' => true, 'kind' => 'core' );
		foreach ( array( ABSPATH . 'wp-admin', ABSPATH . WPINC ) as $dir ) {
			$part = $this->measure( basename( $dir ), $dir );
			$core['bytes'] += $part['bytes'];
			$core['files'] += $part['files'];
			$core['complete'] = $core['complete'] && $part['complete'];
		}
		$folders[] = $core;
		usort( $folders, function ( $a, $b ) { return $b['bytes'] <=> $a['bytes']; } );
		$result = array( 'folders' => $folders, 'computed_at' => time() );
		set_transient( self::TRANSIENT . '_folders', $result, DAY_IN_SECONDS );
		return $result;
	}

	private function measure( $name, $path ) {
		$bytes = 0;
		$files = 0;
		$complete = true;
		try {
			$iterator = new RecursiveIteratorIterator( new RecursiveDirectoryIterator( $path, FilesystemIterator::SKIP_DOTS ) );
			foreach ( $iterator as $file ) {
				if ( ( $files & 255 ) === 0 && microtime( true ) > $this->deadline ) {
					$complete = false;
					break;
				}
				if ( $file->isFile() && !$file->isLink() ) {
					$bytes += (int) $file->getSize();
					$files++;
				}
			}
		}
		catch ( Exception $e ) {
			$complete = false;
		}
		return array( 'name' => $name, 'path' => 'wp-content/' . $name, 'bytes' => $bytes, 'files' => $files, 'complete' => $complete, 'kind' => $this->kind_of( $name ) );
	}

	// Only a hint for the reader: a name that says cache or backup is usually safe to look at first.
	private function kind_of( $name ) {
		if ( $name === 'uploads' ) return 'uploads';
		if ( strpos( $name, '.media-cleaner-private' ) === 0 ) return 'trash';
		if ( in_array( $name, array( 'plugins', 'mu-plugins', 'themes', 'languages' ), true ) ) return 'code';
		if ( preg_match( '/cache|cached|tmp|temp|upgrade/i', $name ) ) return 'cache';
		if ( preg_match( '/backup|updraft|ai1wm|wpvivid|backwpup|duplicator|snapshots/i', $name ) ) return 'backup';
		return 'other';
	}

	private function compute() {
		global $wpdb;
		$this->deadline = microtime( true ) + self::BUDGET;
		$uploads = wp_upload_dir();
		$basedir = trailingslashit( $uploads['basedir'] );
		$registered = array_keys( wp_get_registered_image_subsizes() );

		$stats = array(
			'total' => array( 'bytes' => 0, 'files' => 0, 'attachments' => 0 ),
			'types' => array(),
			'sizes' => array(),
			'originals' => array( 'bytes' => 0, 'files' => 0 ),
			'largest' => array(),
			'unmeasured' => 0,
			'missing' => array( 'main' => 0, 'registered' => 0, 'unregistered' => 0 ),
			'complete' => true,
		);
		$last_id = 0;
		do {
			$rows = $wpdb->get_results( $wpdb->prepare(
				"SELECT p.ID, p.post_title, p.post_mime_type, p.post_date, f.meta_value AS file, m.meta_value AS meta
				FROM $wpdb->posts p
				LEFT JOIN $wpdb->postmeta f ON f.post_id = p.ID AND f.meta_key = '_wp_attached_file'
				LEFT JOIN $wpdb->postmeta m ON m.post_id = p.ID AND m.meta_key = '_wp_attachment_metadata'
				WHERE p.post_type = 'attachment' AND p.ID > %d
				ORDER BY p.ID ASC LIMIT %d",
				$last_id, self::PAGE
			) );
			foreach ( $rows as $row ) {
				$last_id = (int) $row->ID;
				$this->add_attachment( $stats, $row, $basedir, $registered );
			}
			if ( microtime( true ) > $this->deadline ) {
				$stats['complete'] = false;
				break;
			}
		} while ( count( $rows ) === self::PAGE );

		usort( $stats['largest'], function ( $a, $b ) { return $b['bytes'] <=> $a['bytes']; } );
		$stats['largest'] = array_slice( $stats['largest'], 0, self::LARGEST );
		$unused = $this->unused_ids( array_column( $stats['largest'], 'id' ) );
		foreach ( $stats['largest'] as &$entry ) {
			$entry['thumbnail'] = $entry['type'] === 'image' ? wp_get_attachment_image_url( $entry['id'], 'medium' ) : null;
			$entry['unused'] = in_array( $entry['id'], $unused, true );
		}
		unset( $entry );
		uasort( $stats['sizes'], function ( $a, $b ) { return $b['bytes'] <=> $a['bytes']; } );
		arsort( $stats['types'] );
		$stats['sizes'] = array_values( $stats['sizes'] );
		$unregistered = array_column( array_filter( $stats['sizes'], function ( $s ) { return !$s['registered']; } ), 'name' );
		$requests = $this->requested_by( $unregistered );
		foreach ( $stats['sizes'] as &$size ) {
			$size['requested_by'] = $size['registered'] ? array() : $requests[ $size['name'] ];
		}
		unset( $size );
		$stats['types'] = array_map( function ( $type, $bytes ) { return array( 'type' => $type, 'bytes' => $bytes ); },
			array_keys( $stats['types'] ), $stats['types'] );
		$stats['outside'] = ( new Meow_WPMC_Uploads_Estimate( $this->core ) )->get();
		$stats['computed_at'] = time();
		return $stats;
	}

	private function add_attachment( &$stats, $row, $basedir, $registered ) {
		$file = ltrim( (string) $row->file, '/' );
		if ( $file === '' ) return;
		$meta = maybe_unserialize( $row->meta );
		$meta = is_array( $meta ) ? $meta : array();
		$dir = dirname( $file ) === '.' ? '' : trailingslashit( dirname( $file ) );
		$type = $this->type_of( (string) $row->post_mime_type );

		$main = $this->bytes( isset( $meta['filesize'] ) ? $meta['filesize'] : null, $basedir . $file, $stats, 'main' );
		$own = (int) $main;
		$files = 1;
		$sizes = isset( $meta['sizes'] ) && is_array( $meta['sizes'] ) ? $meta['sizes'] : array();
		foreach ( $sizes as $name => $size ) {
			// "full" is the original itself, which some plugins also write among the sizes.
			if ( empty( $size['file'] ) || $name === 'full' ) continue;
			$is_registered = in_array( $name, $registered, true );
			$bytes = $this->bytes( isset( $size['filesize'] ) ? $size['filesize'] : null, $basedir . $dir . $size['file'], $stats,
				$is_registered ? 'registered' : 'unregistered' );
			if ( $bytes === null ) continue;
			if ( !isset( $stats['sizes'][ $name ] ) ) {
				$stats['sizes'][ $name ] = array( 'name' => (string) $name, 'bytes' => 0, 'files' => 0, 'registered' => $is_registered );
			}
			$stats['sizes'][ $name ]['bytes'] += $bytes;
			$stats['sizes'][ $name ]['files']++;
			$own += $bytes;
			$files++;
		}
		// WordPress keeps the uploaded original next to a "-scaled" copy and never tracks its size.
		if ( !empty( $meta['original_image'] ) && file_exists( $basedir . $dir . $meta['original_image'] ) ) {
			$bytes = (int) filesize( $basedir . $dir . $meta['original_image'] );
			$stats['originals']['bytes'] += $bytes;
			$stats['originals']['files']++;
			$own += $bytes;
			$files++;
		}

		$stats['total']['bytes'] += $own;
		$stats['total']['files'] += $files;
		$stats['total']['attachments']++;
		$stats['types'][ $type ] = ( isset( $stats['types'][ $type ] ) ? $stats['types'][ $type ] : 0 ) + $own;
		$this->keep_largest( $stats, array( 'id' => (int) $row->ID, 'title' => (string) $row->post_title, 'file' => $file, 'type' => $type,
			'bytes' => $own, 'main' => (int) $main, 'files' => $files ) );
	}

	// The metadata keeps a file's size even after the file is gone, so every file is checked on
	// disk: counting a deleted thumbnail as space to free would promise space that does not exist.
	// A missing file returns null and is counted by kind, since each kind means something else.
	// Past the time budget the stored size is trusted, and files without one count as unmeasured.
	private function bytes( $stored, $path, &$stats, $kind ) {
		if ( microtime( true ) >= $this->deadline ) {
			if ( is_numeric( $stored ) ) return (int) $stored;
			$stats['unmeasured']++;
			return 0;
		}
		if ( !file_exists( $path ) ) {
			$stats['missing'][ $kind ]++;
			return null;
		}
		return is_numeric( $stored ) ? (int) $stored : (int) filesize( $path );
	}

	// A size no longer registered is not created for new uploads, but code can still ask for it by
	// name and WordPress then serves the stored thumbnail. Searching the active theme and plugins
	// for the name tells a leftover from a size still in use. It is a hint, so it errs on the side
	// of "still used": any mention of the name in quotes counts.
	private function requested_by( $names ) {
		$found = array_fill_keys( $names, array() );
		if ( empty( $names ) ) return $found;
		$sources = array();
		$theme = wp_get_theme();
		$sources[ $theme->get_stylesheet_directory() ] = $theme->get( 'Name' );
		if ( $theme->parent() ) $sources[ $theme->get_template_directory() ] = $theme->parent()->get( 'Name' );
		if ( !function_exists( 'get_plugins' ) ) require_once ABSPATH . 'wp-admin/includes/plugin.php';
		$plugins = get_plugins();
		foreach ( (array) get_option( 'active_plugins', array() ) as $plugin ) {
			$dir = dirname( $plugin );
			// Media Cleaner names every size itself, which would make each one look requested.
			if ( $dir === '.' || !isset( $plugins[ $plugin ] ) || $dir === dirname( plugin_basename( WPMC_ENTRY ) ) ) continue;
			$sources[ WP_PLUGIN_DIR . '/' . $dir ] = $plugins[ $plugin ]['Name'];
		}
		$pattern = '/[\'"](' . implode( '|', array_map( function ( $n ) { return preg_quote( $n, '/' ); }, $names ) ) . ')[\'"]/';
		$deadline = microtime( true ) + 8;
		foreach ( $sources as $dir => $label ) {
			if ( !is_dir( $dir ) ) continue;
			$iterator = new RecursiveIteratorIterator( new RecursiveDirectoryIterator( $dir, FilesystemIterator::SKIP_DOTS ) );
			foreach ( $iterator as $file ) {
				if ( microtime( true ) > $deadline ) break 2;
				if ( !$file->isFile() || !preg_match( '/\.(php|js|json)$/', $file->getFilename() ) || $file->getSize() > 2000000 ) continue;
				if ( !preg_match_all( $pattern, (string) file_get_contents( $file->getPathname() ), $m ) ) continue;
				foreach ( array_unique( $m[1] ) as $name ) {
					if ( !in_array( $label, $found[ $name ], true ) ) $found[ $name ][] = $label;
				}
			}
		}
		return $found;
	}

	// Which of these entries the published scan found unused, so the page can suggest reviewing them.
	private function unused_ids( $ids ) {
		global $wpdb;
		$run_id = $this->core->runs ? (int) $this->core->runs->get_active_id() : 0;
		if ( $run_id <= 0 || empty( $ids ) ) return array();
		$placeholders = implode( ',', array_fill( 0, count( $ids ), '%d' ) );
		return array_map( 'intval', $wpdb->get_col( $wpdb->prepare(
			"SELECT DISTINCT postId FROM {$wpdb->prefix}mclean_scan
			WHERE run_id = %d AND type = 1 AND ignored = 0 AND deleted = 0 AND postId IN ($placeholders)",
			array_merge( array( $run_id ), array_map( 'intval', $ids ) )
		) ) );
	}

	private function keep_largest( &$stats, $entry ) {
		$stats['largest'][] = $entry;
		if ( count( $stats['largest'] ) <= self::LARGEST * 2 ) return;
		usort( $stats['largest'], function ( $a, $b ) { return $b['bytes'] <=> $a['bytes']; } );
		$stats['largest'] = array_slice( $stats['largest'], 0, self::LARGEST );
	}

	private function type_of( $mime ) {
		if ( strpos( $mime, 'image/' ) === 0 ) return 'image';
		if ( strpos( $mime, 'video/' ) === 0 ) return 'video';
		if ( strpos( $mime, 'audio/' ) === 0 ) return 'audio';
		if ( $mime === 'application/pdf' ) return 'pdf';
		return 'other';
	}
}
