<?php
/**
 * Team Survey workflow — module bootstrap.
 *
 * Owns: capability registration, rewrite rule + tag, settings helpers
 * (open/close/renew window, slug + token rotation), the canonical
 * survey-field map (form → schema), and the survey-question definition
 * Phase 1 ships with.
 *
 * See docs/SPEC-team-survey.md.
 *
 * @package BW_AI_Schema_Pro
 * @since 2.2.0
 */

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

class BW_Schema_Survey {

	const CAP                  = 'bw_schema_moderate_team_surveys';
	const QUERY_VAR            = 'bw_schema_survey_token';
	const MAX_OPEN_DAYS        = 7;

	/**
	 * Bump when the rewrite rule shape changes. Used by maybe_flush_rewrites()
	 * to detect "files updated but no activation cycle" deploys and re-flush
	 * once. Cheap (option lookup), runs at most once per deploy.
	 */
	const REWRITE_VERSION      = '2.2.0';
	const OPT_REWRITE_VERSION  = 'bw_schema_survey_rewrite_version';

	const OPT_ENABLED          = 'bw_schema_survey_enabled';
	const OPT_SLUG             = 'bw_schema_survey_slug';
	const OPT_TOKEN            = 'bw_schema_survey_token';
	const OPT_OPENS_AT         = 'bw_schema_survey_opens_at';
	const OPT_EXPIRES_AT       = 'bw_schema_survey_expires_at';
	const OPT_INTRO_HTML       = 'bw_schema_survey_intro_html';
	const OPT_NOTIFY_EMAIL     = 'bw_schema_survey_notify_email';
	const OPT_NOTIFY_ENABLED   = 'bw_schema_survey_notify_enabled';
	const OPT_GRANT_EDITORS    = 'bw_schema_survey_grant_editors';
	/**
	 * "Proceed without a team post type" toggle. When set to 1, the survey
	 * workflow is unlocked even though no team CPT is mapped — submissions
	 * just sit in the holding queue and the publish step shows a notice.
	 * Cleared automatically when a real CPT mapping is set via the inline
	 * setup UI (since 2.2.2).
	 */
	const OPT_PROCEED_WITHOUT_TEAM_CPT = 'bw_schema_survey_proceed_without_team_cpt';

	const DEFAULT_SLUG         = 'bw-team-survey';

	/**
	 * Initialize. Called from the plugin's bundled init hook at priority 10.
	 *
	 * IMPORTANT: register_rewrite() is called DIRECTLY here, not via
	 * `add_action('init', ...)`. WP_Hook's foreach iterates an array copy of
	 * the priority bucket, so adding a same-priority callback during iteration
	 * (which is what would happen if we hooked from inside this method) means
	 * the new callback would never fire on the current request. Calling
	 * inline guarantees the rule lands in $wp_rewrite->extra_rules_top every
	 * request, available for any flush that runs.
	 */
	public static function init() {
		self::ensure_capabilities();
		self::register_rewrite();
		add_filter( 'query_vars', array( __CLASS__, 'register_query_var' ) );
		self::maybe_flush_rewrites();
	}

	/**
	 * One-shot rewrite flush on version mismatch — handles file-only deploys
	 * (no activation cycle) and permalink-resave wipes. After the flush, the
	 * version option matches and this becomes a single option lookup per
	 * request.
	 */
	public static function maybe_flush_rewrites() {
		if ( get_option( self::OPT_REWRITE_VERSION ) === self::REWRITE_VERSION ) {
			return;
		}
		flush_rewrite_rules( false );
		update_option( self::OPT_REWRITE_VERSION, self::REWRITE_VERSION, false );
	}

	/**
	 * Plugin-activation hook. Install table, set defaults, flush rewrites.
	 */
	public static function activate() {
		BW_Schema_Survey_Store::maybe_install();
		self::ensure_capabilities();
		self::ensure_default_options();
		self::register_rewrite();
		flush_rewrite_rules( false );
		update_option( self::OPT_REWRITE_VERSION, self::REWRITE_VERSION, false );
	}

	/**
	 * Plugin-deactivation hook. Flush rewrites so our rule goes away.
	 */
	public static function deactivate() {
		flush_rewrite_rules( false );
	}

	/**
	 * Add the moderate cap to the administrator role (and editor if toggled).
	 */
	public static function ensure_capabilities() {
		$admin = get_role( 'administrator' );
		if ( $admin && ! $admin->has_cap( self::CAP ) ) {
			$admin->add_cap( self::CAP );
		}

		$editor = get_role( 'editor' );
		if ( $editor ) {
			if ( get_option( self::OPT_GRANT_EDITORS, 0 ) ) {
				if ( ! $editor->has_cap( self::CAP ) ) {
					$editor->add_cap( self::CAP );
				}
			} else {
				if ( $editor->has_cap( self::CAP ) ) {
					$editor->remove_cap( self::CAP );
				}
			}
		}
	}

	/**
	 * One-time default options. Won't overwrite existing values.
	 */
	public static function ensure_default_options() {
		add_option( self::OPT_ENABLED, 0 );
		add_option( self::OPT_SLUG, self::DEFAULT_SLUG );
		if ( '' === (string) get_option( self::OPT_TOKEN, '' ) ) {
			update_option( self::OPT_TOKEN, self::generate_token(), false );
		}
		add_option( self::OPT_OPENS_AT, 0 );
		add_option( self::OPT_EXPIRES_AT, 0 );
		add_option( self::OPT_INTRO_HTML, self::default_intro_html() );
		add_option( self::OPT_NOTIFY_EMAIL, get_option( 'admin_email' ) );
		add_option( self::OPT_NOTIFY_ENABLED, 0 );
		add_option( self::OPT_GRANT_EDITORS, 0 );
		add_option( self::OPT_PROCEED_WITHOUT_TEAM_CPT, 0 );
	}

	/* ---------------- Setup helpers (since 2.2.2) ---------------- */

	/**
	 * Setup is complete if a team CPT is mapped OR the admin has explicitly
	 * chosen to proceed without one. Used as the gate for the queue / add
	 * / settings pages and for is_open() — proceed_without unlocks the
	 * workflow without writing schema.
	 */
	public static function is_setup_complete() {
		if ( BW_Schema_Team_Member::get_team_post_type() ) {
			return true;
		}
		return (bool) get_option( self::OPT_PROCEED_WITHOUT_TEAM_CPT, 0 );
	}

	public static function is_proceeding_without_cpt() {
		return ! BW_Schema_Team_Member::get_team_post_type()
			&& (bool) get_option( self::OPT_PROCEED_WITHOUT_TEAM_CPT, 0 );
	}

	/**
	 * Save a chosen post type as the team CPT mapping. Writes the top-level
	 * `bw_schema_team_post_type` option (the legacy fallback that
	 * BW_Schema_Team_Member::get_team_post_type() reads after the sectioned
	 * settings). Clears the proceed-without flag on success.
	 *
	 * @param string $slug
	 * @return bool true on success, false if slug is empty or unknown.
	 */
	public static function set_team_post_type( $slug ) {
		$slug = sanitize_key( $slug );
		if ( ! $slug || ! post_type_exists( $slug ) ) {
			return false;
		}
		update_option( 'bw_schema_team_post_type', $slug );
		update_option( self::OPT_PROCEED_WITHOUT_TEAM_CPT, 0 );
		return true;
	}

	public static function set_proceed_without_team_cpt() {
		update_option( self::OPT_PROCEED_WITHOUT_TEAM_CPT, 1 );
	}

	/**
	 * Common slugs and labels people use for team-member CPTs. Used to score
	 * post types in suggest_team_post_types(). Kept here as a single source
	 * of truth so it's easy to tune.
	 */
	private static function team_cpt_known_exact_slugs() {
		// slug => score (higher = more confidence this is a team CPT)
		return array(
			'team'           => 100,
			'team_member'    => 100,
			'team-member'    => 100,
			'team_members'   => 100,
			'team-members'   => 100,
			'our_team'       =>  95,
			'our-team'       =>  95,
			'team-page'      =>  90,
			'teammate'       =>  88,
			'teammates'      =>  88,
			'staff'          =>  90,
			'staff_member'   =>  85,
			'staff-member'   =>  85,
			'staff_members'  =>  85,
			'staff-members'  =>  85,
			'our_staff'      =>  85,
			'our-staff'      =>  85,
			'people'         =>  80,
			'person'         =>  80,
			'employees'      =>  78,
			'employee'       =>  78,
			'members'        =>  65,
			'member'         =>  65,
			'leadership'     =>  62,
			'crew'           =>  60,
			'agents'         =>  60,
			'agent'          =>  60,
			'doctors'        =>  60,
			'doctor'         =>  60,
			'attorneys'      =>  60,
			'attorney'       =>  60,
			'authors'        =>  55, // lower — conflicts with WP authors concept
		);
	}

	private static function team_cpt_keywords() {
		return array( 'team', 'staff', 'people', 'employee', 'person', 'member', 'crew', 'agent', 'doctor', 'attorney', 'leader' );
	}

	private static function core_excluded_post_types() {
		return array(
			'post', 'page', 'attachment', 'revision', 'nav_menu_item',
			'custom_css', 'customize_changeset', 'oembed_cache', 'user_request',
			'wp_block', 'wp_template', 'wp_template_part', 'wp_global_styles',
			'wp_navigation', 'wp_font_family', 'wp_font_face',
			// Common plugin types that aren't team members:
			'acf-field-group', 'acf-field', 'product', 'product_variation',
			'shop_order', 'shop_coupon', 'kadence_form', 'kadence_query', 'kadence_element',
			'kadence_navigation', 'kadence_lock', 'wpforms', 'gravityforms',
			'tribe_events', 'tribe_venue', 'tribe_organizer',
		);
	}

	/**
	 * Score-rank likely team CPTs on the current site. Returns the top
	 * N matches (capped at the limit argument) as arrays with keys: slug,
	 * label, singular, count, score.
	 *
	 * Scoring tiers:
	 *   - Exact slug match in known list: 55..100
	 *   - Slug contains a team-related keyword: 60
	 *   - Label or singular_name contains a team keyword: 35
	 * Highest score wins; ties keep insertion order.
	 *
	 * @param int $limit
	 * @return array<int, array{slug:string,label:string,singular:string,count:int,score:int}>
	 */
	public static function suggest_team_post_types( $limit = 5 ) {
		$excluded = self::core_excluded_post_types();
		$known    = self::team_cpt_known_exact_slugs();
		$keywords = self::team_cpt_keywords();

		$scored = array();

		foreach ( get_post_types( array( 'public' => true ), 'objects' ) as $pt ) {
			if ( in_array( $pt->name, $excluded, true ) ) {
				continue;
			}
			if ( ! $pt->show_ui ) {
				continue;
			}

			$slug  = strtolower( $pt->name );
			$label = isset( $pt->labels->name ) ? strtolower( (string) $pt->labels->name ) : '';
			$sing  = isset( $pt->labels->singular_name ) ? strtolower( (string) $pt->labels->singular_name ) : '';

			$score = 0;
			if ( isset( $known[ $slug ] ) ) {
				$score = max( $score, $known[ $slug ] );
			}
			foreach ( $keywords as $kw ) {
				if ( false !== strpos( $slug, $kw ) ) {
					$score = max( $score, 60 );
					break;
				}
			}
			foreach ( $keywords as $kw ) {
				if ( false !== strpos( $label, $kw ) || false !== strpos( $sing, $kw ) ) {
					$score = max( $score, 35 );
					break;
				}
			}

			if ( $score <= 0 ) {
				continue;
			}

			$counts = wp_count_posts( $pt->name );
			$scored[] = array(
				'slug'     => $pt->name,
				'label'    => isset( $pt->labels->name ) ? $pt->labels->name : $pt->name,
				'singular' => isset( $pt->labels->singular_name ) ? $pt->labels->singular_name : $pt->name,
				'count'    => isset( $counts->publish ) ? (int) $counts->publish : 0,
				'score'    => $score,
			);
		}

		usort( $scored, function ( $a, $b ) {
			return $b['score'] - $a['score'];
		} );

		return array_slice( $scored, 0, max( 1, (int) $limit ) );
	}

	/**
	 * All non-core public CPTs as a fallback dropdown when our suggestions
	 * don't include what the user wants. Sorted alphabetically by label.
	 *
	 * @return array<int, array{slug:string,label:string,singular:string,count:int}>
	 */
	public static function all_eligible_post_types() {
		$excluded = self::core_excluded_post_types();
		$out = array();
		foreach ( get_post_types( array( 'public' => true ), 'objects' ) as $pt ) {
			if ( in_array( $pt->name, $excluded, true ) ) {
				continue;
			}
			if ( ! $pt->show_ui ) {
				continue;
			}
			$counts = wp_count_posts( $pt->name );
			$out[] = array(
				'slug'     => $pt->name,
				'label'    => isset( $pt->labels->name ) ? $pt->labels->name : $pt->name,
				'singular' => isset( $pt->labels->singular_name ) ? $pt->labels->singular_name : $pt->name,
				'count'    => isset( $counts->publish ) ? (int) $counts->publish : 0,
			);
		}
		usort( $out, function ( $a, $b ) {
			return strcasecmp( $a['label'], $b['label'] );
		} );
		return $out;
	}

	/**
	 * Register the rewrite rule that powers the public URL.
	 * Pattern: {slug}/{token}/?$  →  index.php?bw_schema_survey_token={token}
	 */
	public static function register_rewrite() {
		$slug = self::get_slug();
		add_rewrite_tag( '%' . self::QUERY_VAR . '%', '([a-f0-9]{8,64})' );
		add_rewrite_rule(
			'^' . preg_quote( $slug, '/' ) . '/([a-f0-9]{8,64})/?$',
			'index.php?' . self::QUERY_VAR . '=$matches[1]',
			'top'
		);
	}

	public static function register_query_var( $vars ) {
		$vars[] = self::QUERY_VAR;
		return $vars;
	}

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

	public static function get_slug() {
		$slug = sanitize_title( (string) get_option( self::OPT_SLUG, self::DEFAULT_SLUG ) );
		return $slug ?: self::DEFAULT_SLUG;
	}

	public static function get_token() {
		$token = (string) get_option( self::OPT_TOKEN, '' );
		if ( ! preg_match( '/^[a-f0-9]{8,64}$/', $token ) ) {
			$token = self::generate_token();
			update_option( self::OPT_TOKEN, $token, false );
		}
		return $token;
	}

	public static function generate_token() {
		return bin2hex( random_bytes( 16 ) );
	}

	public static function rotate_token() {
		$token = self::generate_token();
		update_option( self::OPT_TOKEN, $token, false );
		return $token;
	}

	public static function public_url() {
		return home_url( '/' . self::get_slug() . '/' . self::get_token() . '/' );
	}

	public static function is_enabled() {
		return (bool) get_option( self::OPT_ENABLED, 0 );
	}

	public static function expires_at() {
		return (int) get_option( self::OPT_EXPIRES_AT, 0 );
	}

	public static function opens_at() {
		return (int) get_option( self::OPT_OPENS_AT, 0 );
	}

	/**
	 * Survey-open check used by both the public form and the admin UI.
	 * Closed if: master toggle off, expiry past, or setup not complete.
	 * "Setup complete" is true if either a team CPT is mapped or the admin
	 * explicitly chose to proceed without one (since 2.2.2).
	 */
	public static function is_open() {
		if ( ! self::is_enabled() ) {
			return false;
		}
		if ( self::expires_at() <= time() ) {
			return false;
		}
		if ( ! self::is_setup_complete() ) {
			return false;
		}
		return true;
	}

	public static function days_remaining() {
		$remaining = self::expires_at() - time();
		if ( $remaining <= 0 ) {
			return 0;
		}
		return (int) ceil( $remaining / DAY_IN_SECONDS );
	}

	/**
	 * Open or renew the window. $days is clamped to [1, MAX_OPEN_DAYS].
	 * Sets opens_at=now, expires_at=now + days. Enables the master toggle.
	 */
	public static function open_for_days( $days ) {
		$days = max( 1, min( self::MAX_OPEN_DAYS, absint( $days ) ) );
		$now  = time();
		update_option( self::OPT_ENABLED, 1 );
		update_option( self::OPT_OPENS_AT, $now, false );
		update_option( self::OPT_EXPIRES_AT, $now + ( $days * DAY_IN_SECONDS ), false );
		return $days;
	}

	public static function close() {
		update_option( self::OPT_ENABLED, 0 );
		update_option( self::OPT_EXPIRES_AT, 0, false );
	}

	/* ---------------- Survey definition ---------------- */

	/**
	 * Canonical Phase 1 survey question set. Source of truth for both the
	 * public form rendering and the Stage 3 schema-review parsing.
	 *
	 * Each entry:
	 *   - key:          raw_payload key; also identifies the form input name
	 *   - label:        human label
	 *   - type:         text | textarea | number | email | tel | url | url-list
	 *   - required:     bool
	 *   - words_max:    soft hint for textareas (rendered as `<small>`, not enforced server-side beyond sanitization)
	 *   - help:         help-text under the field
	 *   - schema_field: matching key in structured_payload (see field_map())
	 *   - structured_type: list | string | int | url-list  (controls Stage 3 parse hint)
	 */
	public static function survey_questions() {
		return array(
			array(
				'key'              => 'full_name',
				'label'            => __( 'Full name', 'bw-ai-schema-pro' ),
				'type'             => 'text',
				'required'         => true,
				'help'             => __( 'Your full name as you want it shown on the website.', 'bw-ai-schema-pro' ),
				'schema_field'     => 'name',
				'structured_type'  => 'string',
			),
			array(
				'key'              => 'job_title',
				'label'            => __( 'Job title', 'bw-ai-schema-pro' ),
				'type'             => 'text',
				'required'         => true,
				'help'             => __( 'Your role or title.', 'bw-ai-schema-pro' ),
				'schema_field'     => 'jobTitle',
				'structured_type'  => 'string',
			),
			array(
				'key'              => 'short_bio',
				'label'            => __( 'Short bio', 'bw-ai-schema-pro' ),
				'type'             => 'textarea',
				'required'         => true,
				'words_max'        => 50,
				'help'             => __( 'About 50 words. This is what shows up on the website and in search results.', 'bw-ai-schema-pro' ),
				'schema_field'     => 'description',
				'structured_type'  => 'string',
			),
			array(
				'key'              => 'long_bio',
				'label'            => __( 'Long bio', 'bw-ai-schema-pro' ),
				'type'             => 'textarea',
				'required'         => false,
				'words_max'        => 200,
				'help'             => __( 'Optional. About 200 words — used by AI systems to better understand who you are.', 'bw-ai-schema-pro' ),
				'schema_field'     => 'longDescription',
				'structured_type'  => 'string',
			),
			array(
				'key'              => 'expertise',
				'label'            => __( 'Areas of expertise', 'bw-ai-schema-pro' ),
				'type'             => 'textarea',
				'required'         => false,
				'help'             => __( 'Comma-separated list of topics you know about. Prose is OK — a moderator will tidy it.', 'bw-ai-schema-pro' ),
				'schema_field'     => 'knowsAbout',
				'structured_type'  => 'list',
			),
			array(
				'key'              => 'credentials',
				'label'            => __( 'Credentials and certifications', 'bw-ai-schema-pro' ),
				'type'             => 'textarea',
				'required'         => false,
				'help'             => __( 'Degrees, licenses, professional certifications.', 'bw-ai-schema-pro' ),
				'schema_field'     => 'hasCredential',
				'structured_type'  => 'list',
			),
			array(
				'key'              => 'awards',
				'label'            => __( 'Awards', 'bw-ai-schema-pro' ),
				'type'             => 'textarea',
				'required'         => false,
				'help'             => __( 'Any notable awards or recognitions.', 'bw-ai-schema-pro' ),
				'schema_field'     => 'award',
				'structured_type'  => 'list',
			),
			array(
				'key'              => 'education',
				'label'            => __( 'Education', 'bw-ai-schema-pro' ),
				'type'             => 'textarea',
				'required'         => false,
				'help'             => __( 'Where you studied (one school per line is easiest).', 'bw-ai-schema-pro' ),
				'schema_field'     => 'alumniOf',
				'structured_type'  => 'list',
			),
			array(
				'key'              => 'years_experience',
				'label'            => __( 'Years of experience', 'bw-ai-schema-pro' ),
				'type'             => 'number',
				'required'         => false,
				'help'             => __( 'Whole number of years in your field.', 'bw-ai-schema-pro' ),
				'schema_field'     => 'yearsOfExperience',
				'structured_type'  => 'int',
			),
			array(
				'key'              => 'email',
				'label'            => __( 'Public email', 'bw-ai-schema-pro' ),
				'type'             => 'email',
				'required'         => false,
				'help'             => __( "The contact email you want shown publicly (if any). Don't use a personal address.", 'bw-ai-schema-pro' ),
				'schema_field'     => 'email',
				'structured_type'  => 'string',
			),
			array(
				'key'              => 'telephone',
				'label'            => __( 'Public phone', 'bw-ai-schema-pro' ),
				'type'             => 'tel',
				'required'         => false,
				'help'             => __( 'A public-facing phone number, if any.', 'bw-ai-schema-pro' ),
				'schema_field'     => 'telephone',
				'structured_type'  => 'string',
			),
			array(
				'key'              => 'social_profiles',
				'label'            => __( 'Social profile URLs', 'bw-ai-schema-pro' ),
				'type'             => 'url-list',
				'required'         => false,
				'help'             => __( 'LinkedIn, X/Twitter, Mastodon, Github, etc. One per line.', 'bw-ai-schema-pro' ),
				'schema_field'     => 'sameAs',
				'structured_type'  => 'url-list',
			),
			array(
				'key'              => 'department',
				'label'            => __( 'Department', 'bw-ai-schema-pro' ),
				'type'             => 'text',
				'required'         => false,
				'help'             => __( 'Optional — your department within the organization.', 'bw-ai-schema-pro' ),
				'schema_field'     => 'affiliation',
				'structured_type'  => 'string',
			),
		);
	}

	/**
	 * Map a structured-payload key → live post_meta key written on publish.
	 *
	 * Keys here MUST stay aligned with the bio_fields list in
	 * class-bw-schema-person.php and the Person renderer's post_meta reads.
	 * Changes here are user-visible — verify with the rendered schema.
	 */
	public static function field_map() {
		return array(
			// Schema-payload key   =>   post_meta key the Person renderer reads
			'name'              => '_bw_schema_person_name',
			'jobTitle'          => '_bw_schema_person_job_title',
			'description'       => '_bw_schema_person_bio',
			'longDescription'   => '_bw_schema_person_bio_long',
			'knowsAbout'        => '_bw_schema_person_knows_about',
			'hasCredential'     => '_bw_schema_person_credentials',
			'award'             => '_bw_schema_person_awards',
			'alumniOf'          => '_bw_schema_person_alumni_of',
			'yearsOfExperience' => '_bw_schema_person_years_experience',
			'email'             => '_bw_schema_person_email',
			'telephone'         => '_bw_schema_person_telephone',
			'sameAs'            => '_bw_schema_person_same_as',
			'affiliation'       => '_bw_schema_person_department',
		);
	}

	/**
	 * Default intro HTML shown above the public form. Editable in settings.
	 */
	public static function default_intro_html() {
		return wp_kses_post(
			'<p>' . esc_html__( "Thanks for taking a few minutes to help us put together your team profile. The information you share here will be reviewed by a moderator before anything appears on the website.", 'bw-ai-schema-pro' ) . '</p>'
		);
	}
}
