<?php
/**
 * Organization-node builder (single source of truth, since 2.3.1).
 *
 * Replaces the three divergent builders that lived in
 *   - class-bw-schema-renderer.php (homepage Organization path)
 *   - class-bw-schema-renderer.php (fallback WebSite publisher inline)
 *   - class-bw-schema-organization.php (per-post Organization, different field names)
 *
 * Read order for any field:
 *   1. `bw_schema_organization` (array option — the wizard's main blob)
 *   2. Top-level options seeded by the wizard / settings page
 *      (e.g. `bw_schema_org_description`, `bw_schema_founding_date`)
 *   3. Computed values (employee[], founder[] from the team CPT)
 *
 * Everything is keyed by a stable `@id = home_url('/#organization')` so the
 * homepage can emit a connected `@graph` referencing the org from WebSite.publisher,
 * WebPage.about/isPartOf, and Person.worksFor.
 *
 * @package BW_AI_Schema_Pro
 * @since 2.3.1
 */

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

class BW_Schema_Org_Builder {

	/**
	 * Stable @id used to reference the Organization from elsewhere in the graph.
	 */
	public static function id() {
		return home_url( '/' ) . '#organization';
	}

	/**
	 * Same @id pattern but as a tiny reference node — drop this into
	 * Person.worksFor, WebSite.publisher, WebPage.about, etc.
	 */
	public static function reference() {
		return array( '@id' => self::id() );
	}

	/**
	 * Stable @id for the logo ImageObject, so Organization.image and
	 * Organization.logo can both point at the same node.
	 */
	public static function logo_id() {
		return home_url( '/' ) . '#logo';
	}

	/**
	 * Build a complete Organization (or subtype) node.
	 *
	 * The returned array does NOT include `@context` — callers wrapping it in
	 * an `@graph` should hold a single context at the top level.
	 *
	 * @param array $args Optional. Keys:
	 *   - include_context (bool, default false) — add @context.
	 *   - include_employees (bool, default true) — include employee[] array.
	 *   - include_founders (bool, default true) — include founder[] from team flags.
	 * @return array
	 */
	public static function build( array $args = array() ) {
		$args = array_merge( array(
			'include_context'  => false,
			'include_employees' => true,
			'include_founders'  => true,
		), $args );

		$org_data = BW_Schema_Core::get_organization_schema();
		if ( ! is_array( $org_data ) ) {
			$org_data = array();
		}

		$type = self::resolve_type();
		$name = self::pull( $org_data, 'name', get_bloginfo( 'name' ) );

		$node = array(
			'@type' => $type,
			'@id'   => self::id(),
			'name'  => $name,
			'url'   => home_url( '/' ),
		);
		if ( $args['include_context'] ) {
			$node = array_merge( array( '@context' => 'https://schema.org' ), $node );
		}

		// alternateName / legalName / slogan / description ---------------------
		$alt = trim( (string) self::pull( $org_data, 'alternateName', '' ) );
		if ( '' !== $alt ) {
			$node['alternateName'] = $alt;
		}
		$legal = trim( (string) self::pull( $org_data, 'legalName', '' ) );
		if ( '' !== $legal ) {
			$node['legalName'] = $legal;
		}
		$slogan = trim( (string) self::pull( $org_data, 'slogan', '' ) );
		if ( '' !== $slogan ) {
			$node['slogan'] = $slogan;
		}
		$description = trim( (string) get_option( 'bw_schema_org_description', '' ) );
		if ( '' !== $description ) {
			$node['description'] = $description;
		}

		// Identifiers ---------------------------------------------------------
		// Schema.org primary: taxID, vatID, duns, leiCode, naics, iso6523Code.
		foreach ( array( 'taxID', 'vatID', 'duns', 'leiCode', 'naics', 'iso6523Code' ) as $key ) {
			$value = trim( (string) self::pull( $org_data, $key, '' ) );
			if ( '' !== $value ) {
				$node[ $key ] = $value;
			}
		}

		// Logo + image (Organization.image = same ImageObject as logo) --------
		$logo_url = self::pull( $org_data, 'logo', '' );
		if ( ! empty( $logo_url ) ) {
			$logo_node = array(
				'@type' => 'ImageObject',
				'@id'   => self::logo_id(),
				'url'   => $logo_url,
			);
			// Pull dimensions from the attachment if we can resolve it.
			$attach = attachment_url_to_postid( (string) $logo_url );
			if ( $attach ) {
				$meta = wp_get_attachment_metadata( $attach );
				if ( ! empty( $meta['width'] ) ) {
					$logo_node['width'] = (int) $meta['width'];
				}
				if ( ! empty( $meta['height'] ) ) {
					$logo_node['height'] = (int) $meta['height'];
				}
				$caption = get_post_field( 'post_excerpt', $attach );
				if ( '' === trim( (string) $caption ) ) {
					$caption = $name;
				}
				$logo_node['caption'] = $caption;
			} else {
				$logo_node['caption'] = $name;
			}
			$node['logo']  = $logo_node;
			$node['image'] = array( '@id' => self::logo_id() );
		}

		// Contact (top-level + contactPoint[]) --------------------------------
		$tel = self::pull( $org_data, 'telephone', '' );
		if ( ! empty( $tel ) ) {
			$node['telephone'] = $tel;
		}
		$email = self::pull( $org_data, 'email', '' );
		if ( ! empty( $email ) ) {
			$node['email'] = $email;
		}
		$contact_points = get_option( 'bw_schema_contact_points', array() );
		if ( ! empty( $contact_points ) && is_array( $contact_points ) ) {
			$cps = array();
			foreach ( $contact_points as $cp ) {
				if ( empty( $cp['telephone'] ) && empty( $cp['email'] ) ) {
					continue;
				}
				$entry = array( '@type' => 'ContactPoint' );
				if ( ! empty( $cp['type'] ) ) {
					$entry['contactType'] = $cp['type'];
				}
				if ( ! empty( $cp['telephone'] ) ) {
					$entry['telephone'] = $cp['telephone'];
				}
				if ( ! empty( $cp['email'] ) ) {
					$entry['email'] = $cp['email'];
				}
				if ( ! empty( $cp['languages'] ) ) {
					$entry['availableLanguage'] = $cp['languages'];
				}
				$cps[] = $entry;
			}
			if ( ! empty( $cps ) ) {
				$node['contactPoint'] = $cps;
			}
		}

		// Address -------------------------------------------------------------
		$address = get_option( 'bw_schema_address', array() );
		if ( ! empty( $address ) && is_array( $address ) && ! empty( $address['street'] ) ) {
			$postal = array( '@type' => 'PostalAddress' );
			$map    = array(
				'street'  => 'streetAddress',
				'city'    => 'addressLocality',
				'state'   => 'addressRegion',
				'postal'  => 'postalCode',
				'country' => 'addressCountry',
			);
			foreach ( $map as $src => $dst ) {
				if ( ! empty( $address[ $src ] ) ) {
					$postal[ $dst ] = $address[ $src ];
				}
			}
			$node['address'] = $postal;
		}

		// sameAs --------------------------------------------------------------
		$same_as = self::pull( $org_data, 'sameAs', array() );
		if ( ! empty( $same_as ) && is_array( $same_as ) ) {
			$node['sameAs'] = array_values( array_filter( $same_as ) );
		}

		// Founding date -------------------------------------------------------
		$founding_date = get_option( 'bw_schema_founding_date', '' );
		if ( empty( $founding_date ) ) {
			$founding_date = get_option( 'bw_schema_org_founding_date', '' );
		}
		if ( ! empty( $founding_date ) ) {
			$node['foundingDate'] = $founding_date;
		}

		// Founding location ---------------------------------------------------
		$founding_location = trim( (string) self::pull( $org_data, 'foundingLocation', '' ) );
		if ( '' !== $founding_location ) {
			$node['foundingLocation'] = array(
				'@type' => 'Place',
				'name'  => $founding_location,
			);
		}

		// numberOfEmployees as QuantitativeValue -------------------------------
		$emp_count = get_option( 'bw_schema_employee_count', 0 );
		if ( ! empty( $emp_count ) && (int) $emp_count > 0 ) {
			$node['numberOfEmployees'] = array(
				'@type' => 'QuantitativeValue',
				'value' => (int) $emp_count,
			);
		}

		// areaServed — for ALL org types (was previously only on Government).
		// Since 2.5.0 this routes through the shared service-area builder, which
		// emits City / State / Country / AdministrativeArea / GeoCircle nodes.
		// BW_Schema_Location::get_service_areas() falls back to the legacy
		// comma-separated strings when no structured areas are configured, so
		// existing sites are unaffected.
		if ( class_exists( 'BW_Schema_Location' ) ) {
			$areas = BW_Schema_Location::build_area_served( BW_Schema_Location::get_service_areas() );
			if ( ! empty( $areas ) ) {
				$node['areaServed'] = $areas;
			}
		} else {
			$area = self::pull( $org_data, 'areaServed', array() );
			if ( ! empty( $area ) && is_array( $area ) ) {
				$cleaned = array_values( array_filter( array_map( 'trim', $area ) ) );
				if ( ! empty( $cleaned ) ) {
					$node['areaServed'] = $cleaned;
				}
			}
		}

		// subOrganization[] — branches, by @id reference (since 2.5.0). The full
		// LocalBusiness nodes are emitted on the homepage; interior pages carry
		// only these refs. parentOrganization on each branch is the inverse.
		// NOT `department` (that's for departments inside one store) and NOT
		// `branchOf` (superseded by parentOrganization).
		if ( class_exists( 'BW_Schema_Location' ) ) {
			$branches = BW_Schema_Location::collect_location_references();
			if ( ! empty( $branches ) ) {
				$node['subOrganization'] = $branches;
			}
		}

		// makesOffer ----------------------------------------------------------
		$offers = self::pull( $org_data, 'makesOffer', array() );
		if ( ! empty( $offers ) && is_array( $offers ) ) {
			$out = array();
			foreach ( $offers as $offer ) {
				if ( is_string( $offer ) ) {
					$offer = trim( $offer );
					if ( '' === $offer ) {
						continue;
					}
					$out[] = array(
						'@type'       => 'Offer',
						'itemOffered' => array( '@type' => 'Service', 'name' => $offer ),
					);
				} elseif ( is_array( $offer ) ) {
					$out[] = $offer;
				}
			}
			if ( ! empty( $out ) ) {
				$node['makesOffer'] = $out;
			}
		}

		// Room offers (since 2.4.0) — appended to any option-driven makesOffer
		// above, never replacing it. The property makes the offers; each points
		// at its room via itemOffered. This is where room rates live: `offers`
		// is not valid on an Accommodation, and a top-level Offer node would be
		// an unreferenced second root of the graph.
		if ( class_exists( 'BW_Schema_Room' ) ) {
			$room_offers = BW_Schema_Room::collect_offers();
			if ( ! empty( $room_offers ) ) {
				$node['makesOffer'] = array_merge(
					isset( $node['makesOffer'] ) ? $node['makesOffer'] : array(),
					$room_offers
				);
			}
		}

		// Awards / Credentials -------------------------------------------------
		$awards = get_option( 'bw_schema_awards', array() );
		if ( ! empty( $awards ) && is_array( $awards ) ) {
			$awards = array_values( array_filter( $awards ) );
			if ( ! empty( $awards ) ) {
				$node['award'] = $awards;
			}
		}
		$creds = get_option( 'bw_schema_certifications', array() );
		if ( ! empty( $creds ) && is_array( $creds ) ) {
			$creds = array_values( array_filter( $creds ) );
			if ( ! empty( $creds ) ) {
				$node['hasCredential'] = $creds;
			}
		}

		// Local-business extras (subtype hierarchy includes LocalBusiness +
		// EmploymentAgency, MedicalBusiness, etc.) ---------------------------
		if ( self::is_local_business_type( $type ) ) {
			// geo
			$geo = self::pull( $org_data, 'geo', array() );
			if ( is_array( $geo ) && ( ! empty( $geo['latitude'] ) || ! empty( $geo['longitude'] ) ) ) {
				$node['geo'] = array(
					'@type'     => 'GeoCoordinates',
					'latitude'  => isset( $geo['latitude'] ) ? (float) $geo['latitude'] : 0,
					'longitude' => isset( $geo['longitude'] ) ? (float) $geo['longitude'] : 0,
				);
			}
			// openingHoursSpecification
			$hours = self::pull( $org_data, 'openingHours', array() );
			if ( is_array( $hours ) && ! empty( $hours ) ) {
				$specs = array();
				foreach ( $hours as $row ) {
					if ( empty( $row['days'] ) || empty( $row['opens'] ) || empty( $row['closes'] ) ) {
						continue;
					}
					$specs[] = array(
						'@type'      => 'OpeningHoursSpecification',
						'dayOfWeek'  => $row['days'],
						'opens'      => $row['opens'],
						'closes'     => $row['closes'],
					);
				}
				if ( ! empty( $specs ) ) {
					$node['openingHoursSpecification'] = $specs;
				}
			}
			// priceRange
			$price = get_option( 'bw_schema_price_range', '' );
			if ( ! empty( $price ) ) {
				$node['priceRange'] = $price;
			}
		}

		// Rooms from the accommodations CPT (since 2.4.0) ---------------------
		// containsPlace is a Place property, so it only makes sense on a type
		// that is also a Place — i.e. LocalBusiness and its subtypes. A plain
		// Organization is not a Place, so we don't emit it there.
		if ( self::is_local_business_type( $type ) && class_exists( 'BW_Schema_Room' ) ) {
			$rooms = BW_Schema_Room::collect_accommodations();
			if ( ! empty( $rooms ) ) {
				$node['containsPlace'] = $rooms;
			}
		}

		// Founder / employees from team CPT -----------------------------------
		if ( $args['include_founders'] ) {
			$founders = self::collect_founders();
			if ( ! empty( $founders ) ) {
				$node['founder'] = count( $founders ) === 1 ? $founders[0] : $founders;
			}
		}
		if ( $args['include_employees'] ) {
			$employees = self::collect_employees();
			if ( ! empty( $employees ) ) {
				$node['employee'] = $employees;
			}
		}

		return apply_filters( 'bw_schema_org_builder_node', $node, $args );
	}

	/**
	 * Resolve the Organization @type. Falls back to 'Organization' if nothing is set.
	 */
	public static function resolve_type() {
		$type = trim( (string) get_option( 'bw_schema_schema_org_type', '' ) );
		if ( '' === $type ) {
			$type = trim( (string) get_option( 'bw_schema_business_type', '' ) );
		}
		if ( '' === $type ) {
			$type = 'Organization';
		}
		return $type;
	}

	/**
	 * Whether the resolved @type is a LocalBusiness (or subtype thereof).
	 * Used to decide whether to emit geo / openingHours / priceRange.
	 *
	 * (Conservative list — Schema.org's full LocalBusiness subtype hierarchy
	 * has hundreds of entries. We accept anything explicitly marked
	 * LocalBusiness, EmploymentAgency, or one of a few common biz types.)
	 */
	private static function is_local_business_type( $type ) {
		$local_business_types = apply_filters( 'bw_schema_local_business_types', array(
			'LocalBusiness', 'EmploymentAgency', 'ProfessionalService', 'ConsultingBusiness',
			'AccountingService', 'AutoDealer', 'AutoRepair', 'BeautySalon', 'Bakery',
			'Brewery', 'CafeOrCoffeeShop', 'ChildCare', 'Dentist', 'DryCleaningOrLaundry',
			'ElectricalContractor', 'EmploymentAgency', 'EntertainmentBusiness',
			'EventVenue', 'FinancialService', 'FoodEstablishment', 'GeneralContractor',
			'HVACBusiness', 'HealthAndBeautyBusiness', 'HomeAndConstructionBusiness',
			'HousePainter', 'InsuranceAgency', 'LegalService', 'Library', 'LodgingBusiness',
			// Lodging subtypes (added 2.4.0 with the Accommodations module). Without
			// these, a site whose org type is Resort/Hotel silently lost geo,
			// openingHoursSpecification and priceRange — the resolved type never
			// matched, even though 'LodgingBusiness' itself was listed.
			'Resort', 'Hotel', 'BedAndBreakfast', 'Motel', 'Hostel', 'Campground',
			'VacationRental',
			// Types the plugin's own pickers offer but this list never covered
			// (added 2.6.0). `Attorney` is in the Organization tab's type
			// dropdown, so every law-firm site was silently losing geo,
			// openingHoursSpecification and priceRange. The rest are offered by
			// the Locations module's business-type picker.
			'Attorney', 'Warehouse', 'HardwareStore', 'AutoPartsStore',
			'MedicalBusiness', 'MedicalClinic', 'MovingCompany', 'NotaryPublic',
			'Notary', 'Optician', 'PetStore', 'Physician', 'Plumber', 'PoliceStation',
			'PostOffice', 'Restaurant', 'RealEstateAgent', 'RoofingContractor', 'School',
			'SelfStorage', 'ShoppingCenter', 'SportsActivityLocation', 'Store',
			'TattooParlor', 'TaxiService', 'TelevisionStation', 'TouristInformationCenter',
			'TravelAgency', 'Winery', 'WholesaleStore',
		) );
		return in_array( $type, $local_business_types, true );
	}

	/**
	 * Pull a value from $org_data with a top-level option fallback.
	 */
	private static function pull( $org_data, $key, $default ) {
		if ( is_array( $org_data ) && isset( $org_data[ $key ] ) && '' !== $org_data[ $key ] ) {
			return $org_data[ $key ];
		}
		return $default;
	}

	/* ---------------- Team-CPT-derived nodes ---------------- */

	/**
	 * Founders: team CPT entries flagged is_leader, with the exclude-from-schema
	 * flag respected.
	 */
	public static function collect_founders() {
		$team_post_type = class_exists( 'BW_Schema_Team_Member' )
			? BW_Schema_Team_Member::get_team_post_type()
			: get_option( 'bw_schema_team_post_type', '' );
		if ( ! $team_post_type ) {
			return array();
		}

		$ids = get_posts( array(
			'post_type'      => $team_post_type,
			'posts_per_page' => -1,
			'post_status'    => 'publish',
			'fields'         => 'ids',
			'meta_query'     => array(
				array(
					'key'     => '_bw_schema_is_leader',
					'value'   => '1',
					'compare' => '=',
				),
			),
		) );

		$out = array();
		foreach ( $ids as $pid ) {
			if ( self::is_excluded( $pid ) ) {
				continue;
			}
			$out[] = self::person_node_for_member( $pid );
		}
		return $out;
	}

	/**
	 * Employees: every published team CPT entry, sans excluded placeholders.
	 */
	public static function collect_employees() {
		$team_post_type = class_exists( 'BW_Schema_Team_Member' )
			? BW_Schema_Team_Member::get_team_post_type()
			: get_option( 'bw_schema_team_post_type', '' );
		if ( ! $team_post_type ) {
			return array();
		}

		$ids = get_posts( array(
			'post_type'      => $team_post_type,
			'posts_per_page' => -1,
			'post_status'    => 'publish',
			'orderby'        => 'menu_order title',
			'order'          => 'ASC',
			'fields'         => 'ids',
		) );

		$out = array();
		foreach ( $ids as $pid ) {
			if ( self::is_excluded( $pid ) ) {
				continue;
			}
			$out[] = self::person_node_for_member( $pid );
		}
		return $out;
	}

	/**
	 * Per-member exclude flag. Set via the team CPT metabox; honoured everywhere
	 * the org builder collects Person nodes. Lets sites flag placeholder rows
	 * like "Is It You? — Join Our Team!" so they don't get rendered as real
	 * Person schema.
	 */
	public static function is_excluded( $member_id ) {
		return '1' === (string) get_post_meta( (int) $member_id, '_bw_schema_exclude_from_schema', true );
	}

	/**
	 * Person node for a team CPT entry — used inside Organization.employee[]
	 * and Organization.founder[]. Stable @id so the standalone Person page can
	 * be referenced from elsewhere in the graph.
	 */
	private static function person_node_for_member( $member_id ) {
		$member  = get_post( $member_id );
		$node = array(
			'@type' => 'Person',
			'@id'   => get_permalink( $member ) . '#person',
			'name'  => $member->post_title,
			'url'   => get_permalink( $member ),
		);

		// Job title.
		$job = '';
		if ( class_exists( 'BW_Schema_Team_Member' ) ) {
			$detected = BW_Schema_Team_Member::detect_job_title( $member_id );
			$job      = is_array( $detected ) ? ( $detected['value'] ?? '' ) : $detected;
		}
		if ( '' !== $job ) {
			$node['jobTitle'] = $job;
		}

		// Image.
		$image = get_the_post_thumbnail_url( $member, 'thumbnail' );
		if ( ! empty( $image ) ) {
			$node['image'] = $image;
		}

		// worksFor — by @id reference, so the graph stays connected.
		$node['worksFor'] = self::reference();

		return $node;
	}
}
