//! Targeted publication sync (PwC Albania listings → S3).

mod pwc_al;
mod s3;

pub use pwc_al::{
    assert_pwc_host, classify_asset, normalize_publication_href, parse_publication_links, AssetKind,
    PublicationLink, PWC_AL_PUBLICATIONS_URL,
};
pub use s3::{PublicationS3Config, PublicationS3Service};

use anyhow::{anyhow, Result};
use serde::Serialize;
use sha2::{Digest, Sha256};
use std::collections::HashMap;
use std::time::Duration;

const PDF_CT: &str = "application/pdf";
const HTML_CT: &str = "text/html; charset=utf-8";

/// Options for `sync_pwc_al_publications`.
#[derive(Debug, Clone, Default)]
pub struct PwcAlSyncOptions {
    pub dry_run: bool,
    /// When true, download and upload `.html` publication pages in addition to PDFs.
    pub include_html: bool,
}

#[derive(Debug, Serialize, Clone)]
pub struct PwcAlSyncItem {
    pub title: String,
    pub source_url: String,
    pub content_type: String,
    pub kind: String,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub s3_key: Option<String>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub error: Option<String>,
    #[serde(default)]
    pub skipped_existing: bool,
}

#[derive(Debug, Serialize)]
pub struct PwcAlSyncResult {
    pub links_total: usize,
    pub pdfs_eligible: usize,
    pub html_eligible: usize,
    pub uploaded: usize,
    pub skipped_existing: usize,
    pub errors: usize,
    pub items: Vec<PwcAlSyncItem>,
}

fn short_url_hash(url: &str) -> String {
    let mut h = Sha256::new();
    h.update(url.as_bytes());
    format!("{:x}", h.finalize())[..12].to_string()
}

fn disambiguated_filename(base: &str, url: &str) -> String {
    let hash = short_url_hash(url);
    if let Some(dot) = base.rfind('.') {
        let (stem, ext) = base.split_at(dot);
        format!("{}_{}{}", stem, hash, ext)
    } else {
        format!("{}_{}", base, hash)
    }
}

/// First file uses `filename`; further collisions use stem + 12-char hash before extension.
fn build_s3_key(
    prefix: &str,
    subdir: &str,
    filename: &str,
    url: &str,
    used_count: &mut HashMap<String, u32>,
) -> String {
    let c = used_count.entry(filename.to_string()).or_insert(0);
    let fname = if *c == 0 {
        *c += 1;
        filename.to_string()
    } else {
        *c += 1;
        disambiguated_filename(filename, url)
    };
    format!("{}{}/{}", prefix, subdir, fname)
}

fn classify_path(path: &str, include_html: bool) -> Option<(&'static str, &'static str)> {
    match classify_asset(path) {
        Some(AssetKind::Pdf) => Some(("pdf", PDF_CT)),
        Some(AssetKind::Html) if include_html => Some(("html", HTML_CT)),
        _ => None,
    }
}

fn count_eligible(links: &[PublicationLink], include_html: bool) -> (usize, usize) {
    let mut pdf = 0usize;
    let mut html = 0usize;
    for link in links {
        let Ok(u) = url::Url::parse(&link.url) else {
            continue;
        };
        match classify_asset(u.path()) {
            Some(AssetKind::Pdf) => pdf += 1,
            Some(AssetKind::Html) if include_html => html += 1,
            _ => {}
        }
    }
    (pdf, html)
}

/// Fetch listings page, optionally download PDFs (and HTML if `include_html`) and upload to S3.
pub async fn sync_pwc_al_publications(
    http: &reqwest::Client,
    s3: Option<&PublicationS3Service>,
    opts: &PwcAlSyncOptions,
) -> Result<PwcAlSyncResult> {
    if !opts.dry_run && s3.is_none() {
        return Err(anyhow!(
            "S3 not configured (set S3_PUBLICATIONS_BUCKET) for non-dry-run sync"
        ));
    }

    let resp = http
        .get(PWC_AL_PUBLICATIONS_URL)
        .header(
            reqwest::header::USER_AGENT,
            "Mozilla/5.0 (compatible; DexterSentinel/1.0)",
        )
        .send()
        .await
        .map_err(|e| anyhow!("fetch publications page: {}", e))?;
    if !resp.status().is_success() {
        return Err(anyhow!("publications page HTTP {}", resp.status()));
    }
    let html = resp
        .text()
        .await
        .map_err(|e| anyhow!("read body: {}", e))?;

    let links = parse_publication_links(&html);
    let links_total = links.len();
    let (pdfs_eligible, html_eligible) = count_eligible(&links, opts.include_html);

    if opts.dry_run {
        let mut items = Vec::with_capacity(links.len());
        for link in &links {
            let parsed = url::Url::parse(&link.url);
            let (kind, ct) = parsed
                .as_ref()
                .ok()
                .and_then(|u| classify_path(u.path(), opts.include_html))
                .map(|(k, c)| (k.to_string(), c.to_string()))
                .unwrap_or_else(|| ("other".to_string(), String::new()));
            items.push(PwcAlSyncItem {
                title: link.title.clone(),
                source_url: link.url.clone(),
                content_type: ct,
                kind,
                s3_key: None,
                error: None,
                skipped_existing: false,
            });
        }
        return Ok(PwcAlSyncResult {
            links_total,
            pdfs_eligible,
            html_eligible,
            uploaded: 0,
            skipped_existing: 0,
            errors: 0,
            items,
        });
    }

    let svc = s3.expect("checked");
    let prefix = svc.prefix().to_string();

    let mut items: Vec<PwcAlSyncItem> = Vec::new();
    let mut uploaded = 0usize;
    let mut skipped_existing = 0usize;
    let mut errors = 0usize;
    let mut used_names_pdf: HashMap<String, u32> = HashMap::new();
    let mut used_names_html: HashMap<String, u32> = HashMap::new();

    for link in &links {
        let Ok(u) = url::Url::parse(&link.url) else {
            errors += 1;
            items.push(PwcAlSyncItem {
                title: link.title.clone(),
                source_url: link.url.clone(),
                content_type: String::new(),
                kind: "error".to_string(),
                s3_key: None,
                error: Some("invalid URL".to_string()),
                skipped_existing: false,
            });
            continue;
        };

        let Some((kind, content_type)) = classify_path(u.path(), opts.include_html) else {
            continue;
        };

        assert_pwc_host(&link.url)?;

        let filename = match pwc_al::s3_filename_from_url(&link.url) {
            Ok(f) => f,
            Err(e) => {
                errors += 1;
                items.push(PwcAlSyncItem {
                    title: link.title.clone(),
                    source_url: link.url.clone(),
                    content_type: content_type.to_string(),
                    kind: kind.to_string(),
                    s3_key: None,
                    error: Some(e.to_string()),
                    skipped_existing: false,
                });
                continue;
            }
        };

        let key = if kind == "pdf" {
            build_s3_key(&prefix, kind, &filename, &link.url, &mut used_names_pdf)
        } else {
            build_s3_key(&prefix, kind, &filename, &link.url, &mut used_names_html)
        };

        if svc.config().skip_existing && svc.object_exists(&key).await? {
            skipped_existing += 1;
            items.push(PwcAlSyncItem {
                title: link.title.clone(),
                source_url: link.url.clone(),
                content_type: content_type.to_string(),
                kind: kind.to_string(),
                s3_key: Some(key),
                error: None,
                skipped_existing: true,
            });
            tokio::time::sleep(Duration::from_millis(200)).await;
            continue;
        }

        let doc_resp = http
            .get(&link.url)
            .header(
                reqwest::header::USER_AGENT,
                "Mozilla/5.0 (compatible; DexterSentinel/1.0)",
            )
            .send()
            .await;

        let doc_resp = match doc_resp {
            Ok(r) => r,
            Err(e) => {
                errors += 1;
                items.push(PwcAlSyncItem {
                    title: link.title.clone(),
                    source_url: link.url.clone(),
                    content_type: content_type.to_string(),
                    kind: kind.to_string(),
                    s3_key: Some(key),
                    error: Some(format!("download: {}", e)),
                    skipped_existing: false,
                });
                continue;
            }
        };

        if !doc_resp.status().is_success() {
            errors += 1;
            items.push(PwcAlSyncItem {
                title: link.title.clone(),
                source_url: link.url.clone(),
                content_type: content_type.to_string(),
                kind: kind.to_string(),
                s3_key: Some(key),
                error: Some(format!("document HTTP {}", doc_resp.status())),
                skipped_existing: false,
            });
            continue;
        }

        let bytes = match doc_resp.bytes().await {
            Ok(b) => b.to_vec(),
            Err(e) => {
                errors += 1;
                items.push(PwcAlSyncItem {
                    title: link.title.clone(),
                    source_url: link.url.clone(),
                    content_type: content_type.to_string(),
                    kind: kind.to_string(),
                    s3_key: Some(key),
                    error: Some(format!("read document: {}", e)),
                    skipped_existing: false,
                });
                continue;
            }
        };

        match svc.put_bytes(&key, bytes, content_type).await {
            Err(e) => {
                errors += 1;
                items.push(PwcAlSyncItem {
                    title: link.title.clone(),
                    source_url: link.url.clone(),
                    content_type: content_type.to_string(),
                    kind: kind.to_string(),
                    s3_key: Some(key),
                    error: Some(e.to_string()),
                    skipped_existing: false,
                });
            }
            Ok(()) => {
                uploaded += 1;
                items.push(PwcAlSyncItem {
                    title: link.title.clone(),
                    source_url: link.url.clone(),
                    content_type: content_type.to_string(),
                    kind: kind.to_string(),
                    s3_key: Some(key),
                    error: None,
                    skipped_existing: false,
                });
            }
        }

        tokio::time::sleep(Duration::from_millis(200)).await;
    }

    Ok(PwcAlSyncResult {
        links_total,
        pdfs_eligible,
        html_eligible,
        uploaded,
        skipped_existing,
        errors,
        items,
    })
}
