<?php
/**
 * Streamed CSV exports for visits, 404s, redirects and full settings.
 *
 * The export endpoint is `admin-post.php?action=seoforge_export&type=...&_wpnonce=...`.
 * Streams row-by-row so multi-million-row visit tables don't blow up memory.
 *
 * @package Brainwerk SEO Suite
 */

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

class Seoforge_Export {

    const NONCE = 'seoforge_export';

    public static function register( Seoforge_Loader $loader ) {
        $loader->add_action( 'admin_post_seoforge_export', __CLASS__, 'handle' );
    }

    /**
     * Writes one CSV row, neutralising formula/DDE injection (visitor-controlled
     * fields like referrer host, search term, UTM values, URL paths and user
     * agent end up here). Excel/LibreOffice/Sheets treat a cell starting with
     * =, +, -, @, tab or CR as a formula to evaluate — including DDE payloads
     * like `=cmd|'/c calc'!A1`. The standard mitigation is to prefix such a
     * cell with a single quote, which every one of those apps strips back out
     * on CSV import while showing the rest of the text as inert.
     *
     * A plain signed number (e.g. "-5" in a hits/status_code column) is left
     * alone on purpose: it's not a formula in any meaningful sense (it just
     * evaluates to itself), and quoting it would make a legitimate negative
     * value display as text instead of a number for the person opening the
     * export.
     *
     * Centralised here — the single place every stream_*() method writes
     * through — so no future export can forget the guard.
     */
    private static function put_row( $handle, array $row ) {
        static $dangerous = array( '=', '+', '-', '@', "\t", "\r" );
        foreach ( $row as $key => $value ) {
            if ( ! is_string( $value ) || '' === $value ) {
                continue;
            }
            if ( ! in_array( $value[0], $dangerous, true ) ) {
                continue;
            }
            if ( preg_match( '/^[+-]?\d+(\.\d+)?$/', $value ) ) {
                continue; // plain signed number — safe, don't mangle it.
            }
            $row[ $key ] = "'" . $value;
        }
        fputcsv( $handle, $row );
    }

    public static function handle() {
        if ( ! current_user_can( 'manage_options' ) && ! current_user_can( 'manage_network_options' ) ) {
            wp_die( 'forbidden', 403 );
        }
        check_admin_referer( self::NONCE );
        $type = isset( $_GET['type'] ) ? sanitize_key( wp_unslash( $_GET['type'] ) ) : '';
        $days = isset( $_GET['days'] ) ? max( 1, min( 3650, (int) $_GET['days'] ) ) : 30;

        // Validate before sending any headers — an unknown type used to fall
        // through to the default branch AFTER the CSV headers were already
        // sent, so it downloaded as a "valid" seoforge-<type>.csv containing
        // only the text "Unknown export type" instead of failing loudly.
        $known_types = array( 'visits', 'sessions', '404', 'redirects', 'audit' );
        if ( ! in_array( $type, $known_types, true ) ) {
            wp_die( esc_html__( 'Unknown export type.', 'brainwerk-seo-suite' ), 400 );
        }
        // Audit log CSV export is a Pro feature (see admin/views/page-audit.php);
        // the Free build's UI never links here, but the endpoint itself must
        // enforce the gate too, not just hide the button.
        if ( 'audit' === $type && ! ( class_exists( 'Seoforge_License' ) && Seoforge_License::is_pro() ) ) {
            wp_die( esc_html__( 'This export requires a Pro license.', 'brainwerk-seo-suite' ), 403 );
        }

        nocache_headers();
        header( 'Content-Type: text/csv; charset=UTF-8' );
        header( 'Content-Disposition: attachment; filename="seoforge-' . $type . '-' . gmdate( 'Y-m-d' ) . '.csv"' );

        switch ( $type ) {
            case 'visits':    self::stream_visits( $days ); break;
            case 'sessions':  self::stream_sessions( $days ); break;
            case '404':       self::stream_404(); break;
            case 'redirects': self::stream_redirects(); break;
            case 'audit':     self::stream_audit(); break;
        }
        exit;
    }

    private static function stream_visits( $days ) {
        global $wpdb;
        $tbl   = Seoforge_Utils::table( 'visits' );
        $blog  = Seoforge_Utils::current_blog_id();
        $since = gmdate( 'Y-m-d H:i:s', time() - DAY_IN_SECONDS * (int) $days );

        $out = fopen( 'php://output', 'w' );
        self::put_row( $out, array(
            'created_at', 'url_path', 'title', 'source_type', 'source_name', 'search_term',
            'utm_source', 'utm_medium', 'utm_campaign', 'referrer_host',
            'device_type', 'browser', 'os', 'country', 'is_bot', 'session_id',
        ) );

        // Stream in chunks of 5000 rows to keep memory bounded.
        $batch = 5000;
        $last_id = 0;
        do {
            $rows = $wpdb->get_results( $wpdb->prepare(
                "SELECT id, created_at, url_path, title, source_type, source_name, search_term,
                        utm_source, utm_medium, utm_campaign, referrer_host,
                        device_type, browser, os, country, is_bot, session_id
                 FROM %i WHERE blog_id = %d AND id > %d AND created_at >= %s
                 ORDER BY id ASC LIMIT %d",
                $tbl, $blog, $last_id, $since, $batch
            ), ARRAY_A );
            foreach ( (array) $rows as $r ) {
                $last_id = (int) $r['id'];
                unset( $r['id'] );
                self::put_row( $out, $r );
            }
        } while ( count( (array) $rows ) === $batch );
        fclose( $out );
    }

    private static function stream_sessions( $days ) {
        global $wpdb;
        $tbl   = Seoforge_Utils::table( 'sessions' );
        $blog  = Seoforge_Utils::current_blog_id();
        $since = gmdate( 'Y-m-d H:i:s', time() - DAY_IN_SECONDS * (int) $days );

        $out = fopen( 'php://output', 'w' );
        self::put_row( $out, array( 'started_at', 'ended_at', 'pageviews', 'duration_sec', 'is_bounce', 'source_type', 'source_name', 'device_type', 'entry_url', 'exit_url' ) );

        $batch = 5000;
        $last_id = 0;
        do {
            $rows = $wpdb->get_results( $wpdb->prepare(
                "SELECT id, started_at, ended_at, pageviews, duration_sec, is_bounce, source_type, source_name, device_type, entry_url, exit_url
                 FROM %i WHERE blog_id = %d AND id > %d AND started_at >= %s
                 ORDER BY id ASC LIMIT %d",
                $tbl, $blog, $last_id, $since, $batch
            ), ARRAY_A );
            foreach ( (array) $rows as $r ) {
                $last_id = (int) $r['id'];
                unset( $r['id'] );
                self::put_row( $out, $r );
            }
        } while ( count( (array) $rows ) === $batch );
        fclose( $out );
    }

    private static function stream_404() {
        global $wpdb;
        $tbl  = Seoforge_Utils::table( '404' );
        $blog = Seoforge_Utils::current_blog_id();
        $rows = $wpdb->get_results( $wpdb->prepare(
            "SELECT url_path, hits, last_referrer, last_user_agent, first_seen, last_seen, is_resolved
             FROM %i WHERE blog_id = %d ORDER BY last_seen DESC",
            $tbl, $blog
        ), ARRAY_A );

        $out = fopen( 'php://output', 'w' );
        self::put_row( $out, array( 'url_path', 'hits', 'last_referrer', 'last_user_agent', 'first_seen', 'last_seen', 'is_resolved' ) );
        foreach ( (array) $rows as $r ) {
            self::put_row( $out, $r );
        }
        fclose( $out );
    }

    private static function stream_redirects() {
        global $wpdb;
        $tbl  = Seoforge_Utils::table( 'redirects' );
        $blog = Seoforge_Utils::current_blog_id();
        $rows = $wpdb->get_results( $wpdb->prepare(
            "SELECT source_path, target_url, status_code, match_type, hits, last_hit_at, is_active, note, created_at
             FROM %i WHERE blog_id = %d ORDER BY id ASC",
            $tbl, $blog
        ), ARRAY_A );

        $out = fopen( 'php://output', 'w' );
        self::put_row( $out, array( 'source_path', 'target_url', 'status_code', 'match_type', 'hits', 'last_hit_at', 'is_active', 'note', 'created_at' ) );
        foreach ( (array) $rows as $r ) {
            self::put_row( $out, $r );
        }
        fclose( $out );
    }

    /** Pro only — see the is_pro() gate in handle(). */
    private static function stream_audit() {
        global $wpdb;
        $tbl  = Seoforge_Utils::table( 'audit' );
        $blog = Seoforge_Utils::current_blog_id();
        $rows = $wpdb->get_results( $wpdb->prepare(
            "SELECT created_at, user_login, action, object_type, object_id, summary
             FROM %i WHERE blog_id = %d ORDER BY id DESC",
            $tbl, $blog
        ), ARRAY_A );

        $out = fopen( 'php://output', 'w' );
        self::put_row( $out, array( 'created_at', 'user_login', 'action', 'object_type', 'object_id', 'summary' ) );
        foreach ( (array) $rows as $r ) {
            self::put_row( $out, $r );
        }
        fclose( $out );
    }

    /**
     * Bulk-import redirects from CSV with columns
     *   source_path,target_url[,status_code][,match_type][,note]
     * Returns ['ok'=>int,'fail'=>int].
     */
    public static function import_redirects_csv( $tmp_file ) {
        if ( ! current_user_can( 'manage_options' ) && ! current_user_can( 'manage_network_options' ) ) {
            return array( 'ok' => 0, 'fail' => 0, 'error' => 'forbidden' );
        }
        $ok = 0; $fail = 0;
        $fh = fopen( $tmp_file, 'r' );
        if ( ! $fh ) {
            return array( 'ok' => 0, 'fail' => 0, 'error' => 'cannot open file' );
        }
        $header = fgetcsv( $fh );
        if ( ! $header ) {
            fclose( $fh );
            return array( 'ok' => 0, 'fail' => 0, 'error' => 'empty file' );
        }
        $cols = array_flip( array_map( 'strtolower', array_map( 'trim', $header ) ) );
        while ( ( $row = fgetcsv( $fh ) ) !== false ) {
            $src = isset( $cols['source_path'] ) && isset( $row[ $cols['source_path'] ] ) ? $row[ $cols['source_path'] ] : '';
            $tgt = isset( $cols['target_url'] )  && isset( $row[ $cols['target_url'] ] )  ? $row[ $cols['target_url'] ]  : '';
            if ( ! $src || ! $tgt ) { $fail++; continue; }
            $status = isset( $cols['status_code'] ) && isset( $row[ $cols['status_code'] ] ) ? (int) $row[ $cols['status_code'] ] : 301;
            $match  = isset( $cols['match_type'] )  && isset( $row[ $cols['match_type'] ] )  ? $row[ $cols['match_type'] ]  : 'exact';
            $note   = isset( $cols['note'] )        && isset( $row[ $cols['note'] ] )        ? $row[ $cols['note'] ]        : '';
            $id = Seoforge_Redirects::add( $src, $tgt, $status, $match, $note );
            $id ? $ok++ : $fail++;
        }
        fclose( $fh );
        return array( 'ok' => $ok, 'fail' => $fail );
    }
}
