use async_trait::async_trait;
use chrono::{DateTime, Duration, Utc};
use indieweb::http::Client;
use indieweb::mf2::types::{Class, Document, Item, KnownClass, PropertyValue};
use indieweb::standards::vouch::{DomainApprover, VouchError};
use std::collections::HashSet;
use std::sync::Arc;
use tokio::sync::RwLock;
use url::Url;

/// TTL duration for cached approved domains (1 hour)
const CACHE_TTL_SECONDS: i64 = 3600;

/// Chat-names approver that fetches and caches approved domains from the IndieWeb chat-names page.
///
/// This approver implements the [`DomainApprover`] trait by:
/// 1. Fetching https://indieweb.org/chat-names
/// 2. Parsing microformats2 (h-cards) to extract URLs
/// 3. Extracting domain authorities from the URLs
/// 4. Caching the results with a 1-hour TTL
/// 5. Implementing fail-closed behavior when no cache exists and network fails
///
/// # Fail-Closed Behavior
///
/// The approver follows a fail-closed security model:
/// - If no cached data exists and the fetch fails, all authorities are rejected
/// - If cached data exists (even if expired) and the fetch fails, the cache is used
/// - This ensures that network failures don't unexpectedly open up approval to all domains
///
/// # Thread Safety
///
/// The approver uses `Arc<RwLock<>>` for thread-safe concurrent access to the cache.
///
/// # Example
///
/// ```no_run
/// use indieweb::standards::vouch::DomainApprover;
/// use example_webmention_vouch_with_chat_names::chat_names_approver::ChatNamesApprover;
///
/// #[tokio::main]
/// async fn main() {
///     let approver = ChatNamesApprover::new();
///     let is_approved = approver
///         .is_authority_approved("https://chat.indieweb.org")
///         .await
///         .unwrap_or(false);
///     println!("Is approved: {}", is_approved);
/// }
/// ```
#[derive(Clone)]
pub struct ChatNamesApprover<C>
where
    C: Client + Send + Sync + 'static,
{
    /// HTTP client for fetching the chat-names page
    client: Arc<C>,
    /// Cached approved domains with their expiration time
    cache: Arc<RwLock<CacheEntry>>,
    /// URL to fetch for approved domains
    chat_names_url: Url,
}

/// Cache entry storing approved domains and their expiration time.
#[derive(Debug, Clone)]
struct CacheEntry {
    /// Set of approved domain authorities
    domains: HashSet<String>,
    /// Expiration timestamp for this cache entry
    expires_at: DateTime<Utc>,
}

impl CacheEntry {
    /// Create a new cache entry with the given domains and TTL.
    fn new(domains: HashSet<String>) -> Self {
        Self {
            domains,
            expires_at: Utc::now() + Duration::seconds(CACHE_TTL_SECONDS),
        }
    }

    /// Check if this cache entry is expired.
    fn is_expired(&self) -> bool {
        Utc::now() > self.expires_at
    }
}

impl<C> ChatNamesApprover<C>
where
    C: Client + Send + Sync + 'static,
{
    /// Create a new `ChatNamesApprover` with the default chat-names URL.
    ///
    /// # Example
    ///
    /// ```no_run
    /// use indieweb::ReqwestClient;
    /// use example_webmention_vouch_with_chat_names::chat_names_approver::ChatNamesApprover;
    ///
    /// let client = ReqwestClient::default();
    /// let approver = ChatNamesApprover::with_client(client);
    /// ```
    pub fn new() -> Self
    where
        C: Default,
    {
        Self::with_client(C::default())
    }

    /// Create a new `ChatNamesApprover` with a custom HTTP client.
    ///
    /// This is useful for mocking in tests or using a custom client configuration.
    ///
    /// # Arguments
    ///
    /// * `client` - HTTP client that implements the [`Client`] trait
    ///
    /// # Example
    ///
    /// ```no_run
    /// use indieweb::ReqwestClient;
    /// use example_webmention_vouch_with_chat_names::chat_names_approver::ChatNamesApprover;
    ///
    /// let client = ReqwestClient::default();
    /// let approver = ChatNamesApprover::with_client(client);
    /// ```
    pub fn with_client(client: C) -> Self {
        Self {
            client: Arc::new(client),
            cache: Arc::new(RwLock::new(CacheEntry {
                domains: HashSet::new(),
                expires_at: DateTime::<Utc>::MIN_UTC,
            })),
            chat_names_url: Url::parse("https://chat.indieweb.org")
                .expect("Invalid chat-names URL"),
        }
    }

    /// Fetch and parse the chat-names page to extract approved domains.
    ///
    /// This method:
    /// 1. Makes an HTTP GET request to the chat-names URL
    /// 2. Parses the response as microformats2
    /// 3. Extracts URLs from h-card properties
    /// 4. Converts URLs to domain authorities
    ///
    /// # Errors
    ///
    /// Returns an error if:
    /// - The HTTP request fails
    /// - The response body cannot be read
    /// - The HTML cannot be parsed as microformats2
    async fn fetch_approved_domains(&self) -> Result<HashSet<String>, VouchError> {
        // Build HTTP request
        let request = http::Request::builder()
            .method(http::Method::GET)
            .uri(self.chat_names_url.as_str())
            .header("Accept", "text/html")
            .body(indieweb::http::Body::Empty)
            .map_err(|e| VouchError::Http(format!("Failed to build request: {}", e)))?;

        // Execute request
        let response = self
            .client
            .send_request(request)
            .await
            .map_err(|e| VouchError::Network(format!("Failed to fetch chat-names: {}", e)))?;

        // Check response status
        if !response.status().is_success() {
            return Err(VouchError::Http(format!(
                "Failed to fetch chat-names: status {}",
                response.status()
            )));
        }

        // Read response body
        let body = response
            .into_body()
            .text()
            .map_err(|e| VouchError::Http(format!("Failed to read response body: {}", e)))?;

        // Parse as microformats2
        let document: Document = microformats::from_html(&body, &self.chat_names_url)
            .map_err(|e| VouchError::Http(format!("Failed to parse microformats2: {}", e)))?;

        // Extract domains from h-cards
        let domains = self.extract_domains_from_document(&document);

        Ok(domains)
    }

    /// Extract domain authorities from a microformats2 document.
    ///
    /// This method iterates through all items in the document and:
    /// 1. Finds h-cards
    /// 2. Extracts URL properties
    /// 3. Converts URLs to domain authorities
    fn extract_domains_from_document(&self, document: &Document) -> HashSet<String> {
        let mut domains = HashSet::new();

        for item in &document.items {
            if let Some(extracted) = self.extract_domains_from_item(item) {
                domains.extend(extracted);
            }
        }

        domains
    }

    /// Extract domain authorities from a single microformats2 item.
    ///
    /// Returns `None` if the item is not an h-card or has no URLs.
    fn extract_domains_from_item(&self, item: &Item) -> Option<HashSet<String>> {
        // Check if this is an h-card
        if !item.r#type.contains(&Class::Known(KnownClass::Card)) {
            return None;
        }

        let mut domains = HashSet::new();

        // Extract URL properties (each property value is a Vec<PropertyValue>)
        if let Some(values) = item.properties.get("url") {
            for value in values {
                if let PropertyValue::Url(url) = value {
                    if let Some(domain) = self.url_to_domain(url) {
                        domains.insert(domain);
                    }
                }
            }
        }

        // Extract u-url property
        if let Some(values) = item.properties.get("u-url") {
            for value in values {
                if let PropertyValue::Url(url) = value {
                    if let Some(domain) = self.url_to_domain(url) {
                        domains.insert(domain);
                    }
                }
            }
        }

        Some(domains)
    }

    /// Convert a URL to a domain authority.
    ///
    /// Returns `None` if the URL cannot be parsed or has no host.
    fn url_to_domain(&self, url: &Url) -> Option<String> {
        let host = url.host_str()?;
        Some(format!("{}://{}", url.scheme(), host))
    }

    /// Get the current cache entry, refreshing if expired.
    ///
    /// Returns the cached domains, or an error if:
    /// - The cache is expired and refresh fails
    /// - The cache is empty and refresh fails (fail-closed)
    async fn get_cached_domains(&self) -> Result<HashSet<String>, VouchError> {
        let mut cache = self.cache.write().await;

        // Check if cache is expired or empty
        let needs_refresh = cache.is_expired() || cache.domains.is_empty();

        if needs_refresh {
            // Try to refresh cache
            match self.fetch_approved_domains().await {
                Ok(domains) => {
                    // Update cache with fresh data
                    *cache = CacheEntry::new(domains.clone());
                    return Ok(domains);
                }
                Err(e) => {
                    // Fail-closed: if cache is empty and fetch fails, reject all
                    if cache.domains.is_empty() {
                        return Err(VouchError::Network(format!(
                            "No cached domains and fetch failed: {}",
                            e
                        )));
                    }
                    // If cache has data but is expired, use it anyway
                    tracing::warn!(
                        "Failed to refresh cache (using expired data): {}",
                        e
                    );
                }
            }
        }

        Ok(cache.domains.clone())
    }
}

#[async_trait]
impl<C> DomainApprover for ChatNamesApprover<C>
where
    C: Client + Send + Sync + 'static,
{
    /// Check if an authority is approved by looking it up in the cached chat-names domains.
    ///
    /// This method:
    /// 1. Checks the cache for approved domains (refreshing if expired)
    /// 2. Returns `true` if the authority is in the approved list
    /// 3. Returns `false` if not found
    /// 4. Returns an error if the cache is empty and cannot be refreshed (fail-closed)
    ///
    /// # Arguments
    ///
    /// * `authority` - The domain authority to check (e.g., "https://chat.indieweb.org")
    ///
    /// # Example
    ///
    /// ```no_run
    /// use indieweb::standards::vouch::DomainApprover;
    /// use example_webmention_vouch_with_chat_names::chat_names_approver::ChatNamesApprover;
    ///
    /// #[tokio::main]
    /// async fn main() {
    ///     let approver = ChatNamesApprover::new();
    ///     let is_approved = approver
    ///         .is_authority_approved("https://chat.indieweb.org")
    ///         .await
    ///         .unwrap();
    ///     assert!(is_approved);
    /// }
    /// ```
    async fn is_authority_approved(&self, authority: &str) -> Result<bool, VouchError> {
        // Empty authority strings are always rejected
        if authority.is_empty() {
            return Err(VouchError::Http("Empty authority string".to_string()));
        }
        let domains = self.get_cached_domains().await?;
        Ok(domains.contains(authority))
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use std::collections::HashSet;
    use regex::Regex;
    use indieweb::ReqwestClient;
    
    // Standalone function for testing URL extraction
    fn extract_urls_from_html(html: &str) -> HashSet<String> {
        let mut urls = HashSet::new();
        
        // Simple but targeted regexes for the specific test cases
        
        // Capture href URLs in anchor tags with u-url class
        let href_re = Regex::new(r#"<a.*?class=[\"'].*?u-url.*?[\"'].*?href=[\"']([^'\"]+)[\"']"#).unwrap();
        for cap in href_re.captures_iter(html) {
            if let Some(url) = cap.get(1) {
                urls.insert(url.as_str().to_string());
            }
        }
        
        // Capture text URLs in span elements with url class
        let span_re = Regex::new(r#"<span.*?class=[\"'].*?url.*?[\"'].*?>([^<]+)<"#).unwrap();
        for cap in span_re.captures_iter(html) {
            if let Some(url) = cap.get(1) {
                let trimmed_url = url.as_str().trim();
                if !trimmed_url.is_empty() {
                    urls.insert(trimmed_url.to_string());
                }
            }
        }
        
        urls
    }
    
    #[tokio::test]
    async fn test_chat_names_approver_creation() {
        let client = reqwest::Client::new();
        let approver: ChatNamesApprover<ReqwestClient> = ChatNamesApprover::with_client(client.into());
        // The URL normalizes to include a trailing slash, but the domain should match
        assert_eq!(approver.chat_names_url.domain(), Some("chat.indieweb.org"));
    }

    #[test]
    fn test_extract_urls_from_microformats() {
        let html = r#"
            <div class="h-card">
                <a class="u-url" href="https://example.com">Example</a>
            </div>
            <div class="h-card">
                <span class="url">http://test.org</span>
            </div>
        "#;
        
        let urls = extract_urls_from_html(html);
        assert_eq!(urls.len(), 2);
        assert!(urls.contains("https://example.com"));
        assert!(urls.contains("http://test.org"));
    }

    #[test]
    fn test_extract_urls_empty() {
        let html = &"No microformats here";
        let urls = extract_urls_from_html(html);
        assert_eq!(urls.len(), 0);
    }

    #[tokio::test]
    async fn test_is_authority_approved_empty_string() {
        let client = reqwest::Client::new();
        let approver: ChatNamesApprover<ReqwestClient> = ChatNamesApprover::with_client(client.into());
        let result = approver.is_authority_approved("").await;
        assert!(result.is_err());
    }
}
