<?php
/**
 * Git Sync, fetch raw file contents from a git provider (GitHub or GitLab, via
 * CSDT_Git_Providers) and extract marker-bounded snippets for the CloudScale Code
 * Block. On-demand sync via REST + a nightly cron sweep. Public repos only.
 *
 * @package CloudScale_DevTools
 */

if ( ! defined( 'ABSPATH' ) ) {
	exit; }

class CSDT_GitHub_Sync {

    // Maps file extensions to highlight.js language IDs used by the block's language dropdown.
    private const EXT_TO_LANG = array(
        'sh'         => 'bash',
        'bash'       => 'bash',
        'zsh'        => 'bash',
        'c'          => 'c',
        'h'          => 'c',
        'cpp'        => 'cpp',
        'cc'         => 'cpp',
        'cs'         => 'csharp',
        'css'        => 'css',
        'dockerfile' => 'dockerfile',
        'go'         => 'go',
        'graphql'    => 'graphql',
        'gql'        => 'graphql',
        'tf'         => 'hcl',
        'hcl'        => 'hcl',
        'html'       => 'xml',
        'htm'        => 'xml',
        'xml'        => 'xml',
        'java'       => 'java',
        'js'         => 'javascript',
        'mjs'        => 'javascript',
        'cjs'        => 'javascript',
        'jsx'        => 'javascript',
        'json'       => 'json',
        'kt'         => 'kotlin',
        'lua'        => 'lua',
        'mk'         => 'makefile',
        'makefile'   => 'makefile',
        'md'         => 'markdown',
        'pl'         => 'perl',
        'php'        => 'php',
        'py'         => 'python',
        'r'          => 'r',
        'rb'         => 'ruby',
        'rs'         => 'rust',
        'scss'       => 'scss',
        'sql'        => 'sql',
        'swift'      => 'swift',
        'toml'       => 'toml',
        'ts'         => 'typescript',
        'tsx'        => 'typescript',
        'yml'        => 'yaml',
        'yaml'       => 'yaml',
    );

    private const TABLE_VERSION        = '1';
    private const TABLE_VERSION_OPTION = 'csdt_github_sync_db_version';
    public const CRON_HOOK             = 'csdt_github_sync_daily';
    private const CRON_BATCH           = 100; // max rows synced per nightly tick

    // Re-entry guard so the cron-triggered wp_update_post doesn't recurse back through save_post.
    private static bool $is_cron_syncing = false;

    /**
     * Returns the fully-qualified index table name for the current site prefix.
     *
     * @since  1.9.0
     * @return string Table name (e.g. wp_csdt_github_blocks).
     */
    public static function table_name(): string {
        global $wpdb;
        return $wpdb->prefix . 'csdt_github_blocks';
    }

    /**
     * Installs the linked-blocks index table on first activation and any later
     * schema bump (gated by an option-stored version). Safe to call on every
     * `init`, returns immediately when the stored version is current.
     *
     * @since  1.9.0
     * @return void
     */
    public static function maybe_install_table(): void {
        if ( get_option( self::TABLE_VERSION_OPTION ) === self::TABLE_VERSION ) {
            return;
        }
        global $wpdb;
        require_once ABSPATH . 'wp-admin/includes/upgrade.php';
        $table   = self::table_name();
        $charset = $wpdb->get_charset_collate();
        $sql     = "CREATE TABLE {$table} (
            id              BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
            post_id         BIGINT UNSIGNED NOT NULL,
            block_uid       VARCHAR(36)     NOT NULL,
            github_url      VARCHAR(2048)   NOT NULL,
            github_snippet  VARCHAR(128)    NOT NULL DEFAULT '',
            last_sync       INT UNSIGNED    NOT NULL DEFAULT 0,
            last_sha        CHAR(64)        NOT NULL DEFAULT '',
            last_status     VARCHAR(32)     NOT NULL DEFAULT 'pending',
            last_error      VARCHAR(255)    NOT NULL DEFAULT '',
            created_at      INT UNSIGNED    NOT NULL DEFAULT 0,
            updated_at      INT UNSIGNED    NOT NULL DEFAULT 0,
            PRIMARY KEY  (id),
            UNIQUE KEY post_block (post_id, block_uid),
            KEY last_sync (last_sync)
        ) {$charset};";
        dbDelta( $sql );
        update_option( self::TABLE_VERSION_OPTION, self::TABLE_VERSION, false );
    }

    /**
     * Walks parsed blocks recursively and collects every cloudscale/code-block
     * that has BOTH a non-empty githubUrl and a githubBlockId set.
     *
     * Blocks where the URL has been cleared (unlinked) are deliberately
     * excluded, their index rows will be removed by the reconciler.
     */
    private static function collect_linked_blocks( array $blocks ): array {
        $found = array();
        foreach ( $blocks as $b ) {
            if ( ! is_array( $b ) ) {
				continue; }
            if ( ( $b['blockName'] ?? '' ) === 'cloudscale/code-block' ) {
                $attrs = $b['attrs'] ?? array();
                $url   = isset( $attrs['githubUrl'] ) ? trim( (string) $attrs['githubUrl'] ) : '';
                $uid   = isset( $attrs['githubBlockId'] ) ? (string) $attrs['githubBlockId'] : '';
                if ( $url !== '' && $uid !== '' ) {
                    $found[] = array(
                        'uid'     => $uid,
                        'url'     => $url,
                        'snippet' => isset( $attrs['githubSnippet'] ) ? (string) $attrs['githubSnippet'] : '',
                        'sha'     => isset( $attrs['githubLastSha'] ) ? (string) $attrs['githubLastSha'] : '',
                    );
                }
            }
            if ( ! empty( $b['innerBlocks'] ) && is_array( $b['innerBlocks'] ) ) {
                $found = array_merge( $found, self::collect_linked_blocks( $b['innerBlocks'] ) );
            }
        }
        return $found;
    }

    /**
     * Reconciles the index table against the current post_content. Three cases handled:
     *   - new linked block  → insert row
     *   - existing block with changed url/snippet → update row
     *   - block was deleted OR had its URL cleared → row deleted
     */
    /**
     * `save_post` hook callback. Re-runs the indexer for the saved post so that
     * adding, editing, removing, or unlinking a code block is reflected in the
     * index immediately.
     *
     * @since  1.9.0
     * @param  int      $post_id Post being saved.
     * @param  \WP_Post $post    Full post object.
     * @param  bool     $update  True when updating an existing post.
     * @return void
     */
    public static function on_save_post( int $post_id, \WP_Post $post, bool $update ): void {
        if ( self::$is_cron_syncing ) {
			return; }
        if ( wp_is_post_revision( $post_id ) || wp_is_post_autosave( $post_id ) ) {
			return; }
        // Skip auto-drafts, trashed posts, and other non-content states.
        if ( ! in_array( $post->post_status, array( 'publish', 'draft', 'pending', 'future', 'private' ), true ) ) {
            return;
        }
        try {
            self::index_post( $post_id, $post->post_content );
        } catch ( \Throwable $e ) {
            if ( class_exists( 'CloudScale_Telegram' ) && CloudScale_Telegram::is_configured() ) {
                CloudScale_Telegram::send(
                    sprintf( "GitHub Sync index failed on post save\nPost: #%d\nError: %s", $post_id, $e->getMessage() ),
                    'CloudScale Cyber Devtools',
                    'error'
                );
            }
        }
    }

    /**
     * Reconciles the index for one post: upserts rows for currently-linked
     * blocks and deletes rows whose UID is no longer present (covers both
     * block-deleted and URL-cleared cases).
     *
     * @since  1.9.0
     * @param  int    $post_id Post whose blocks to index.
     * @param  string $content Raw post_content to parse for linked blocks.
     * @return void
     */
    private static function index_post( int $post_id, string $content ): void {
        global $wpdb;
        $table = self::table_name();
        $now   = time();

        $found        = self::collect_linked_blocks( parse_blocks( $content ) );
        $current_uids = array();

        foreach ( $found as $blk ) {
            $current_uids[] = $blk['uid'];
            $existing       = $wpdb->get_row( // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- GitHub Sync custom table; WP_Query not applicable
                $wpdb->prepare(
                    "SELECT id, github_url, github_snippet FROM {$table} WHERE post_id = %d AND block_uid = %s", // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
                    $post_id,
                    $blk['uid']
                )
            );
            if ( $existing ) {
                if ( $existing->github_url !== $blk['url'] || $existing->github_snippet !== $blk['snippet'] ) {
                    $wpdb->update( // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- GitHub Sync custom table; WP_Query not applicable
                        $table,
                        array(
                            'github_url'     => $blk['url'],
                            'github_snippet' => $blk['snippet'],
                            'updated_at'     => $now,
                        ),
                        array( 'id' => $existing->id ),
                        array( '%s', '%s', '%d' ),
                        array( '%d' )
                    );
                }
            } else {
                $wpdb->insert( // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- GitHub Sync custom table; WP_Query not applicable
                    $table,
                    array(
                        'post_id'        => $post_id,
                        'block_uid'      => $blk['uid'],
                        'github_url'     => $blk['url'],
                        'github_snippet' => $blk['snippet'],
                        'last_sync'      => 0,
                        'last_sha'       => $blk['sha'],
                        'last_status'    => $blk['sha'] !== '' ? 'ok' : 'pending',
                        'last_error'     => '',
                        'created_at'     => $now,
                        'updated_at'     => $now,
                    ),
                    array( '%d', '%s', '%s', '%s', '%d', '%s', '%s', '%s', '%d', '%d' )
                );
            }
        }

        // Remove rows for blocks that no longer exist in the post (block deleted)
        // or whose link was cleared (URL removed). Both cases share this cleanup.
        if ( empty( $current_uids ) ) {
            $wpdb->delete( $table, array( 'post_id' => $post_id ), array( '%d' ) ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- GitHub Sync custom table; WP_Query not applicable
        } else {
            $placeholders = implode( ',', array_fill( 0, count( $current_uids ), '%s' ) );
            $wpdb->query( // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- GitHub Sync custom table; WP_Query not applicable
                $wpdb->prepare( // phpcs:ignore WordPress.DB.PreparedSQLPlaceholders.ReplacementsWrongNumber -- variadic spread incorrectly counted by PHPCS; actual count matches dynamic placeholders
                    "DELETE FROM {$table} WHERE post_id = %d AND block_uid NOT IN ({$placeholders})", // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
                    $post_id,
                    ...$current_uids
                )
            );
        }
    }

    /**
     * Drops every index row associated with a post. Wired to both
     * `before_delete_post` and `wp_trash_post` so trashed posts stop syncing.
     *
     * @since  1.9.0
     * @param  int $post_id Post being trashed or deleted.
     * @return void
     */
    public static function on_post_deleted( int $post_id ): void {
        global $wpdb;
        $wpdb->delete( self::table_name(), array( 'post_id' => $post_id ), array( '%d' ) ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- GitHub Sync custom table; WP_Query not applicable
    }

    // ── Cron sweep ─────────────────────────────────────────────────────────

    /**
     * Daily cron entry point. Pulls the oldest-synced batch of linked blocks
     * (capped at CRON_BATCH) and resyncs each one. Safe to call on demand.
     *
     * @since  1.9.0
     * @return void
     */
    public static function cron_sweep(): void {
        global $wpdb;
        $table = self::table_name();
        $rows  = $wpdb->get_results( // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- GitHub Sync custom table; WP_Query not applicable
            $wpdb->prepare(
                "SELECT * FROM {$table} ORDER BY last_sync ASC LIMIT %d", // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
                self::CRON_BATCH
            )
        );
        if ( ! is_array( $rows ) ) {
			return; }
        foreach ( $rows as $row ) {
            self::sync_one_row( $row );
        }
    }

    /**
     * Syncs a single index row: parses the stored URL, fetches the latest body,
     * skips the rewrite when the SHA is unchanged, otherwise extracts the
     * snippet, writes the post, and updates the index status.
     *
     * @since  1.9.0
     * @param  object $row Database row from the index table.
     * @return void
     */
    private static function sync_one_row( object $row ): void {
        global $wpdb;
        $table = self::table_name();
        $now   = time();

        $provider = CSDT_Git_Providers::detect( (string) $row->github_url );
        $parts    = $provider ? $provider->parse( (string) $row->github_url ) : null;
        if ( ! $provider || ! $parts ) {
            self::record_row_error( (int) $row->id, 'invalid url', $now );
            return;
        }

        $fetch = self::fetch_url( $provider->raw_url( $parts ) );
        if ( is_wp_error( $fetch ) ) {
            self::record_row_error( (int) $row->id, $fetch->get_error_message(), $now );
            return;
        }

        // SHA matches what we last synced → mark fresh but skip the rewrite.
        if ( $row->last_sha !== '' && $fetch['sha'] === $row->last_sha ) {
            $wpdb->update( // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- GitHub Sync custom table; WP_Query not applicable
                $table,
                array(
					'last_sync'   => $now,
					'last_status' => 'ok',
					'last_error'  => '',
					'updated_at'  => $now,
                ),
                array( 'id' => $row->id ),
                array( '%d', '%s', '%s', '%d' ),
                array( '%d' )
            );
            return;
        }

        $extracted = self::extract_snippet( $fetch['body'], (string) $row->github_snippet );
        if ( is_wp_error( $extracted ) ) {
            self::record_row_error( (int) $row->id, $extracted->get_error_message(), $now );
            return;
        }

        // Integrity check: confirm the row still describes the same block in
        // the same post (same UID + same githubUrl) before we rewrite content.
        // If the row diverged from the post (e.g. via a future SQL-write vuln,
        // or stale post state after an external edit), drop the row and let
        // save_post recreate it from authoritative content next time.
        $post = get_post( (int) $row->post_id );
        if ( ! $post || ! self::row_matches_post( $post->post_content, (string) $row->block_uid, (string) $row->github_url ) ) {
            $wpdb->delete( $table, array( 'id' => $row->id ), array( '%d' ) ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- GitHub Sync custom table; WP_Query not applicable
            return;
        }

        $ok = self::update_block_content_in_post(
            (int) $row->post_id,
            (string) $row->block_uid,
            $extracted['content'],
            $now,
            $fetch['sha']
        );
        if ( ! $ok ) {
            // The post or block UID disappeared between index time and sweep time.
            // Drop the orphan row so it stops getting retried.
            $wpdb->delete( $table, array( 'id' => $row->id ), array( '%d' ) ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- GitHub Sync custom table; WP_Query not applicable
            return;
        }

        $wpdb->update( // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- GitHub Sync custom table; WP_Query not applicable
            $table,
            array(
				'last_sync'   => $now,
				'last_sha'    => $fetch['sha'],
				'last_status' => 'ok',
				'last_error'  => '',
				'updated_at'  => $now,
            ),
            array( 'id' => $row->id ),
            array( '%d', '%s', '%s', '%s', '%d' ),
            array( '%d' )
        );
    }

    /**
     * Stamps an error onto an index row so the failure surfaces in the admin
     * and the row still rotates to the back of the next sync's priority queue.
     *
     * @since  1.9.0
     * @param  int    $row_id Index row id.
     * @param  string $msg    Error message (truncated to 250 chars).
     * @param  int    $now    Unix timestamp to record as the sync attempt time.
     * @return void
     */
    private static function record_row_error( int $row_id, string $msg, int $now ): void {
        global $wpdb;
        $wpdb->update( // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- GitHub Sync custom table; WP_Query not applicable
            self::table_name(),
            array(
                'last_sync'   => $now,
                'last_status' => 'error',
                'last_error'  => substr( $msg, 0, 250 ),
                'updated_at'  => $now,
            ),
            array( 'id' => $row_id ),
            array( '%d', '%s', '%s', '%d' ),
            array( '%d' )
        );
    }

    /**
     * Locates a cloudscale/code-block by githubBlockId inside the post's content,
     * updates its content/lastSync/lastSha attrs, and writes the post back without
     * triggering a revision or re-entering save_post.
     */
    private static function update_block_content_in_post( int $post_id, string $uid, string $new_content, int $synced_at, string $sha ): bool {
        $post = get_post( $post_id );
        if ( ! $post ) {
			return false; }

        $blocks  = parse_blocks( $post->post_content );
        $updated = false;
        self::walk_and_update( $blocks, $uid, $new_content, $synced_at, $sha, $updated );
        if ( ! $updated ) {
			return false; }

        $serialized = serialize_blocks( $blocks );

        self::$is_cron_syncing = true;
        $no_revisions          = static function () {
			return 0;
		};
        add_filter( 'wp_revisions_to_keep', $no_revisions, 99 );
        // wp_update_post calls wp_unslash internally and expects slashed data.
        // Without wp_slash, JSON-encoded block attrs (e.g. > for `>`)
        // lose their backslashes and become literal `u003e` in the DB.
        wp_update_post(
            array(
				'ID'           => $post_id,
				'post_content' => wp_slash( $serialized ),
            )
        );
        remove_filter( 'wp_revisions_to_keep', $no_revisions, 99 );
        self::$is_cron_syncing = false;

        return true;
    }

    /**
     * Recursive helper for update_block_content_in_post. Mutates the parsed
     * block tree in place when it finds the matching `githubBlockId`.
     *
     * @since  1.9.0
     * @param  array  &$blocks     Parsed blocks (by reference for mutation).
     * @param  string  $uid        Block UID to match.
     * @param  string  $new_content New content to install on the matching block.
     * @param  int     $synced_at   Timestamp to stamp as the last sync.
     * @param  string  $sha         SHA of the new content.
     * @param  bool   &$updated    Flag set true when a match is found.
     * @return void
     */
    /**
     * Verifies that a block with the given UID exists in the post's content
     * AND its githubUrl attribute equals the supplied URL. Returns false if
     * the block is missing, or its URL has diverged from what the index row
     * claims. Defence against a tampered index table writing back to a post
     * with attacker-chosen URL content.
     *
     * @since  1.9.0
     * @param  string $content      Raw post_content.
     * @param  string $expected_uid Block UID to find.
     * @param  string $expected_url URL the index row says the block points at.
     * @return bool
     */
    private static function row_matches_post( string $content, string $expected_uid, string $expected_url ): bool {
        $found = false;
        $walk  = static function ( array $blocks ) use ( &$walk, $expected_uid, $expected_url, &$found ): void {
            foreach ( $blocks as $b ) {
                if ( $found ) {
					return; }
                if ( ! is_array( $b ) ) {
					continue; }
                if ( ( $b['blockName'] ?? '' ) === 'cloudscale/code-block' ) {
                    $attrs = $b['attrs'] ?? array();
                    if ( ( $attrs['githubBlockId'] ?? '' ) === $expected_uid
                        && ( $attrs['githubUrl'] ?? '' ) === $expected_url ) {
                        $found = true;
                        return;
                    }
                }
                if ( ! empty( $b['innerBlocks'] ) && is_array( $b['innerBlocks'] ) ) {
                    $walk( $b['innerBlocks'] );
                }
            }
        };
        $walk( parse_blocks( $content ) );
        return $found;
    }

    private static function walk_and_update( array &$blocks, string $uid, string $new_content, int $synced_at, string $sha, bool &$updated ): void {
        foreach ( $blocks as &$b ) {
            if ( ! is_array( $b ) ) {
				continue; }
            if ( ( $b['blockName'] ?? '' ) === 'cloudscale/code-block' ) {
                $attrs = $b['attrs'] ?? array();
                if ( ( $attrs['githubBlockId'] ?? '' ) === $uid ) {
                    $prefix                  = isset( $attrs['githubPrefix'] ) ? (string) $attrs['githubPrefix'] : '';
                    $suffix                  = isset( $attrs['githubSuffix'] ) ? (string) $attrs['githubSuffix'] : '';
                    $attrs['content']        = $prefix . $new_content . $suffix;
                    $attrs['githubLastSync'] = $synced_at;
                    $attrs['githubLastSha']  = $sha;
                    $b['attrs']              = $attrs;
                    $updated                 = true;
                }
            }
            if ( ! empty( $b['innerBlocks'] ) && is_array( $b['innerBlocks'] ) ) {
                self::walk_and_update( $b['innerBlocks'], $uid, $new_content, $synced_at, $sha, $updated );
            }
        }
        unset( $b );
    }

    public static function register_rest_routes(): void {
        register_rest_route(
            'csdt/v1',
            '/github-sync',
            array(
				'methods'             => 'POST',
				'callback'            => array( __CLASS__, 'rest_sync' ),
				// Tightened from edit_posts to edit_post($post_id): the caller must
				// be able to edit the SPECIFIC post they're syncing into. Without
				// this, any Contributor could trigger arbitrary outbound HTTP
				// through the WP host.
				'permission_callback' => static function ( \WP_REST_Request $request ) {
					$post_id = (int) $request->get_param( 'post_id' );
					if ( $post_id <= 0 ) {
						return new \WP_Error( 'missing_post_id', 'post_id is required.', array( 'status' => 400 ) );
					}
					if ( ! current_user_can( 'edit_post', $post_id ) ) {
						return new \WP_Error( 'rest_forbidden', 'You do not have permission to edit this post.', array( 'status' => 403 ) );
					}
					return true;
				},
				'args'                => array(
					'url'     => array(
						'type'     => 'string',
						'required' => true,
					),
					'snippet' => array(
						'type'     => 'string',
						'required' => false,
						'default'  => '',
					),
					'post_id' => array(
						'type'     => 'integer',
						'required' => true,
					),
				),
			)
        );
    }

    /**
     * Per-user rate limit: cap GitHub-sync REST calls at 30/min/user. Returns
     * a WP_Error if the user is over the cap; returns null if the call is OK.
     */
    private static function rest_rate_limit(): ?\WP_Error {
        $uid = get_current_user_id();
        if ( $uid <= 0 ) {
			return null; }
        $key  = 'csdt_ghsync_rl_' . $uid;
        $hits = (int) get_transient( $key );
        if ( $hits >= 30 ) {
            return new \WP_Error( 'rate_limited', 'Too many GitHub-sync calls in the last minute. Slow down.', array( 'status' => 429 ) );
        }
        set_transient( $key, $hits + 1, MINUTE_IN_SECONDS );
        return null;
    }

    public static function rest_sync( \WP_REST_Request $request ) {
        $rl = self::rest_rate_limit();
        if ( $rl ) {
			return $rl; }

        $url     = (string) $request->get_param( 'url' );
        $snippet = trim( (string) $request->get_param( 'snippet' ) );

        $provider = CSDT_Git_Providers::detect( $url );
        $parts    = $provider ? $provider->parse( $url ) : null;
        if ( ! $provider || ! $parts ) {
            return new \WP_Error( 'invalid_url', 'Not a valid GitHub or GitLab blob URL.', array( 'status' => 400 ) );
        }

        // URL hash snippet is a hint; the explicit snippet param wins when supplied.
        if ( $snippet === '' && $parts['snippet'] !== '' ) {
            $snippet = $parts['snippet'];
        }

        $fetch = self::fetch_url( $provider->raw_url( $parts ) );
        if ( is_wp_error( $fetch ) ) {
            return $fetch;
        }

        $extracted = self::extract_snippet( $fetch['body'], $snippet );
        if ( is_wp_error( $extracted ) ) {
            return $extracted;
        }

        // Anchor the view URL to the snippet's line range; whole-file links get no anchor.
        $anchor_start = ( $snippet !== '' && $extracted['lineStart'] > 0 ) ? $extracted['lineStart'] : 0;
        $view_url     = $provider->view_url( $parts, $anchor_start, $extracted['lineEnd'] );

        return new \WP_REST_Response(
            array(
				'content'   => $extracted['content'],
				'sha'       => $fetch['sha'],
				'lastSync'  => time(),
				'lineStart' => $extracted['lineStart'],
				'lineEnd'   => $extracted['lineEnd'],
				'language'  => self::guess_language( $parts['path'] ),
				'viewUrl'   => $view_url,
            ),
            200
        );
    }

    /**
     * Parses a github.com /blob/ URL. Returns null when the URL doesn't match.
     * Recognised hash forms: #snippet=NAME (preferred), #L42-L87 (informational).
     *
     * @deprecated 1.10.150 Use CSDT_Git_Providers::detect()->parse() for multi-provider
     *             support (GitHub + GitLab). Kept for any external callers.
     */
    public static function parse_github_url( string $url ): ?array {
        $url = trim( $url );
        if ( ! preg_match( '~^https?://github\.com/([^/\s]+)/([^/\s]+)/blob/([^/\s]+)/([^#\s]+)(?:#(.*))?$~', $url, $m ) ) {
            return null;
        }
        $hash    = $m[5] ?? '';
        $snippet = '';
        if ( preg_match( '/(?:^|[&;])snippet=([A-Za-z0-9_.\-]+)/', $hash, $sm ) ) {
            $snippet = $sm[1];
        }
        return array(
            'owner'   => $m[1],
            'repo'    => $m[2],
            'branch'  => rawurldecode( $m[3] ),
            'path'    => rawurldecode( $m[4] ),
            'snippet' => $snippet,
        );
    }

    /**
     * Back-compat shim: fetches a github.com raw file by its parts. New code
     * resolves a provider and calls fetch_url() with the provider's raw URL.
     *
     * @deprecated 1.10.150 Use CSDT_Git_Providers + fetch_url() instead.
     */
    public static function fetch_raw( string $owner, string $repo, string $branch, string $path ) {
        $raw_url = sprintf(
            'https://raw.githubusercontent.com/%s/%s/%s/%s',
            rawurlencode( $owner ),
            rawurlencode( $repo ),
            rawurlencode( $branch ),
            implode( '/', array_map( 'rawurlencode', explode( '/', $path ) ) )
        );
        return self::fetch_url( $raw_url );
    }

    /**
     * Fetches a raw file body from a provider-built URL (unauthenticated, public
     * repos only). Returns [ 'body', 'sha' ] or a WP_Error. The URL must already
     * be a fully-formed raw endpoint from a CSDT_Git_Provider::raw_url() call.
     *
     * @since  1.10.150
     * @param  string $raw_url Fully-qualified raw-content URL.
     * @return array{body:string,sha:string}|\WP_Error
     */
    public static function fetch_url( string $raw_url ) {
        $resp = wp_remote_get(
            $raw_url,
            array(
				'timeout'    => 10,
				'user-agent' => 'CloudScale-DevTools/GitSync',
				'headers'    => array( 'Accept' => 'text/plain' ),
			)
        );

        if ( is_wp_error( $resp ) ) {
            // Don't echo cURL/wp_http error messages back to callers, they can
            // leak internal DNS server IPs, proxy hostnames, and absolute paths
            // that disclose the WP host's outbound network topology. The raw
            // detail is still useful for debugging, so log it server-side.
            // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log
            error_log( '[CSDT GitSync] fetch_url failed: ' . $resp->get_error_message() );
            return new \WP_Error( 'fetch_failed', 'Network error contacting the source repository.', array( 'status' => 502 ) );
        }
        $code = wp_remote_retrieve_response_code( $resp );
        if ( $code === 404 ) {
            return new \WP_Error( 'not_found', 'File not found (404). Check owner, repo, branch and path.', array( 'status' => 404 ) );
        }
        if ( $code < 200 || $code >= 300 ) {
            return new \WP_Error( 'fetch_failed', 'The source repository returned an unexpected status code.', array( 'status' => 502 ) );
        }
        $body = (string) wp_remote_retrieve_body( $resp );
        // Normalise CRLF/CR to LF so marker matching is line-ending-agnostic.
        $body = str_replace( array( "\r\n", "\r" ), "\n", $body );
        return array(
			'body' => $body,
			'sha'  => hash( 'sha256', $body ),
		);
    }

    /**
     * Extracts a snippet bounded by csdt:start name=NAME / csdt:end name=NAME markers
     * (in any comment style), strips the marker lines, and dedents the common leading
     * whitespace. Pass an empty $name to return the full body unchanged.
     *
     * Returns ['content','lineStart','lineEnd'] or WP_Error.
     */
    public static function extract_snippet( string $body, string $name ) {
        $lines       = explode( "\n", $body );
        $total_lines = count( $lines );

        if ( $name === '' ) {
            return array(
                'content'   => self::trim_trailing_blank_lines( $body ),
                'lineStart' => 1,
                'lineEnd'   => $total_lines,
            );
        }

        // Allow letters, digits, underscore, dot, hyphen in marker names.
        if ( ! preg_match( '/^[A-Za-z0-9_.\-]+$/', $name ) ) {
            return new \WP_Error( 'invalid_snippet_name', 'Snippet name contains invalid characters.', array( 'status' => 400 ) );
        }

        $name_q  = preg_quote( $name, '/' );
        $start_p = '/\bcsdt:start\s+name=' . $name_q . '\b/';
        $end_p   = '/\bcsdt:end\s+name=' . $name_q . '\b/';

        $start_idx = -1;
        $end_idx   = -1;
        foreach ( $lines as $i => $line ) {
            if ( $start_idx === -1 && preg_match( $start_p, $line ) ) {
                $start_idx = $i;
                continue;
            }
            if ( $start_idx !== -1 && preg_match( $end_p, $line ) ) {
                $end_idx = $i;
                break;
            }
        }

        if ( $start_idx === -1 ) {
            return new \WP_Error( 'snippet_not_found', "Marker 'csdt:start name={$name}' was not found in the file.", array( 'status' => 404 ) );
        }
        if ( $end_idx === -1 ) {
            return new \WP_Error( 'snippet_unterminated', "Marker 'csdt:end name={$name}' was not found after the start marker.", array( 'status' => 422 ) );
        }

        $inner = array_slice( $lines, $start_idx + 1, $end_idx - $start_idx - 1 );
        $inner = self::dedent_lines( $inner );

        return array(
            'content'   => self::trim_trailing_blank_lines( implode( "\n", $inner ) ),
            'lineStart' => $start_idx + 2, // 1-indexed, first content line (after start marker)
            'lineEnd'   => $end_idx,       // 1-indexed, last content line (before end marker)
        );
    }

    private static function dedent_lines( array $lines ): array {
        $min_indent = PHP_INT_MAX;
        foreach ( $lines as $line ) {
            if ( trim( $line ) === '' ) {
				continue; }  // ignore blank lines for indent calc
            if ( preg_match( '/^([ \t]*)/', $line, $m ) ) {
                $indent = strlen( $m[1] );
                if ( $indent < $min_indent ) {
					$min_indent = $indent; }
            }
        }
        if ( $min_indent === PHP_INT_MAX || $min_indent === 0 ) {
			return $lines; }
        return array_map(
            static fn( $line ) => substr( $line, $min_indent ) !== false ? substr( $line, $min_indent ) : $line,
            $lines
        );
    }

    private static function trim_trailing_blank_lines( string $s ): string {
        return rtrim( $s, "\n" );
    }

    private static function guess_language( string $path ): string {
        $basename = strtolower( basename( $path ) );
        if ( $basename === 'dockerfile' || $basename === 'makefile' ) {
            return $basename;
        }
        $ext = strtolower( pathinfo( $path, PATHINFO_EXTENSION ) );
        return self::EXT_TO_LANG[ $ext ] ?? '';
    }
}
