// Redirect proxy for hentai.tv.
//
// hentai.tv embeds every episode through nhplayer.com (`https://nhplayer.com/v/{embedId}/`) and
// the real MP4 lives on the same Cloudflare-fronted R2 bucket (`r2.1hanime.com`) as
// `animeidhentai`, behind a signed `?verify=<ts>-<sig>` token produced by an obfuscated browser
// challenge (`player.php` -> `player-core-v2.php` -> `get-video-url-v2.php`): a proof-of-work over
// five DOM-embedded parts plus a fixed-but-valid fingerprint, replicated server-side below.
//
// The signed URL additionally requires a *browser* TLS fingerprint (JA3) to clear Cloudflare.
// curl_cffi/AVFoundation/Safari and yt-dlp `--impersonate` pass; our Rust HTTP stack (wreq) is
// JA3-blocked on every emulation profile, so we cannot stream the bytes ourselves. Instead this is
// a redirect proxy (same pattern as `jable`/`animeidhentai`): HEAD returns 200 so probes and
// health-checks pass, and GET 302-redirects to the freshly-resolved signed URL, which the Hot Tub
// client fetches directly with its own (CF-accepted) TLS stack.
//
// Proxy URL shape: `/proxy/hentaitv/{embedId}.mp4` (the trailing `.mp4` is cosmetic so downstream
// media detection keys off the extension; it is stripped before resolving).

use ntex::http::Method;
use ntex::http::header::{ACCEPT_RANGES, CONTENT_TYPE, LOCATION};
use ntex::web::{self, HttpRequest};
use once_cell::sync::Lazy;
use regex::Regex;
use sha2::{Digest, Sha256};
use std::collections::HashMap;
use std::sync::Mutex;
use std::time::{Duration, Instant};

use base64::{Engine as _, engine::general_purpose::STANDARD};
use percent_encoding::{AsciiSet, NON_ALPHANUMERIC};

use crate::util::requester::Requester;

// Mirror of JavaScript encodeURIComponent: keep the unreserved marks `-_.!~*'()` unescaped.
const COMPONENT: &AsciiSet = &NON_ALPHANUMERIC
    .remove(b'-')
    .remove(b'_')
    .remove(b'.')
    .remove(b'!')
    .remove(b'~')
    .remove(b'*')
    .remove(b'\'')
    .remove(b'(')
    .remove(b')');

const NH_BASE: &str = "https://nhplayer.com";
const SITE_REFERER: &str = "https://hentai.tv/";
// Static but plausible desktop-Firefox fingerprint. nhplayer's server only sanity-checks the
// shape plus the proof-of-work / detection flags, so a fixed payload validates reliably.
const FINGERPRINT_JSON: &str = r#"{"t":1500,"mm":[[120,210,260],[180,260,640]],"tm":[],"cl":[[190,300,1180]],"kp":[],"sc":[],"i":1,"mc":2,"tc":0,"cc":1,"kc":0,"b":{"w":"ANGLE (Intel, Intel(R) UHD Graphics, OpenGL 4.6)","v":"Google Inc. (Intel)","sw":1920,"sh":1080,"aw":1920,"ah":1040,"cd":24,"pd":24,"tz":-60,"hc":8,"dm":8,"pl":"Win32","lang":"en-US","langs":"en-US,en","dpr":1,"ww":1280,"wh":720,"touch":false,"pdf":true,"fonts":0}}"#;

static DATA_ID_RE: Lazy<Regex> =
    Lazy::new(|| Regex::new(r#"data-id="(/player\.php\?[^"]+)""#).unwrap());
static PV_RE: Lazy<Regex> = Lazy::new(|| {
    Regex::new(r#"_pV=\{vid:"([^"]+)",ct:"([^"]+)",pid:"([^"]+)",st:"([^"]+)"\}"#).unwrap()
});
static CORE_RE: Lazy<Regex> =
    Lazy::new(|| Regex::new(r#"player-core-v2\.php\?t=([A-Za-z0-9+/=]+)"#).unwrap());
static SC_RE: Lazy<Regex> = Lazy::new(|| Regex::new(r#"'([0-9a-f]+\.[0-9a-f]+)'"#).unwrap());
static RID_RE: Lazy<Regex> = Lazy::new(|| Regex::new(r#"id:'([0-9a-f]{16})'"#).unwrap());
static ELEM_ID_RE: Lazy<Regex> =
    Lazy::new(|| Regex::new(r#"getElementById\('([^']+)'\)"#).unwrap());
static ATTR_RE: Lazy<Regex> = Lazy::new(|| Regex::new(r#"getAttribute\('([^']+)'\)"#).unwrap());
static URL_FIELD_RE: Lazy<Regex> = Lazy::new(|| Regex::new(r#""url"\s*:\s*"([^"]+)""#).unwrap());

// embedId -> (signed media url, fetched-at). The verify token is short lived, so we keep it brief.
static URL_CACHE: Lazy<Mutex<HashMap<String, (String, Instant)>>> =
    Lazy::new(|| Mutex::new(HashMap::new()));
const CACHE_TTL: Duration = Duration::from_secs(150);

fn cache_get(embed_id: &str) -> Option<String> {
    let cache = URL_CACHE.lock().ok()?;
    let (url, at) = cache.get(embed_id)?;
    if at.elapsed() < CACHE_TTL {
        Some(url.clone())
    } else {
        None
    }
}

fn cache_put(embed_id: &str, url: &str) {
    if let Ok(mut cache) = URL_CACHE.lock() {
        cache.insert(embed_id.to_string(), (url.to_string(), Instant::now()));
    }
}

/// Read a challenge part from `player.php` by element id, tolerating either attribute order.
fn read_attr(html: &str, id: &str, attr: &str) -> Option<String> {
    let id_q = regex::escape(id);
    let attr_q = regex::escape(attr);
    let forward = Regex::new(&format!(r#"id="{id_q}"[^>]*?{attr_q}="([^"]*)""#)).ok()?;
    if let Some(c) = forward.captures(html) {
        return Some(c[1].to_string());
    }
    let backward = Regex::new(&format!(r#"{attr_q}="([^"]*)"[^>]*?id="{id_q}""#)).ok()?;
    backward.captures(html).map(|c| c[1].to_string())
}

fn read_input_value(html: &str, id: &str) -> Option<String> {
    read_attr(html, id, "value")
}

fn read_template_text(html: &str, id: &str) -> Option<String> {
    let id_q = regex::escape(id);
    let re = Regex::new(&format!(r#"<template id="{id_q}"><p>([^<]*)</p>"#)).ok()?;
    re.captures(html).map(|c| c[1].to_string())
}

fn solve_pow(challenge: &str) -> String {
    let mut n: u64 = 0;
    loop {
        let mut hasher = Sha256::new();
        hasher.update(challenge.as_bytes());
        hasher.update(format!("{n:x}").as_bytes());
        if hasher.finalize()[0] == 0 {
            return format!("{n:x}");
        }
        n += 1;
        if n > 50_000_000 {
            // statistically unreachable (1/256 success per try); bail rather than spin forever.
            return format!("{n:x}");
        }
    }
}

async fn fetch_text(requester: &mut Requester, url: &str, referer: &str) -> Option<String> {
    let resp = requester
        .get_raw_with_headers(url, vec![("Referer".to_string(), referer.to_string())])
        .await
        .ok()?;
    resp.text().await.ok()
}

/// Replicate nhplayer's browser challenge and return the signed CDN MP4 URL for an embed id.
async fn resolve_signed_url(requester: &mut Requester, embed_id: &str) -> Option<String> {
    // 1. Embed page -> player.php path (carries the base64 media token).
    let embed_url = format!("{NH_BASE}/v/{embed_id}/");
    let embed_html = fetch_text(requester, &embed_url, SITE_REFERER).await?;
    let player_path = DATA_ID_RE
        .captures(&embed_html)?
        .get(1)?
        .as_str()
        .replace("&amp;", "&");

    // 2. player.php -> _pV inputs, the player-core token, and the DOM challenge parts.
    let player_url = format!("{NH_BASE}{player_path}");
    let player_html = fetch_text(requester, &player_url, &embed_url).await?;
    let pv = PV_RE.captures(&player_html)?;
    let vid = pv.get(1)?.as_str().to_string();
    let ct = pv.get(2)?.as_str().to_string();
    let pid = pv.get(3)?.as_str().to_string();
    let st = pv.get(4)?.as_str().to_string();
    let core_token = CORE_RE.captures(&player_html)?.get(1)?.as_str().to_string();

    // 3. player-core-v2.php -> server-challenge token, request id, and (randomized) element refs.
    let core_url = format!("{NH_BASE}/player-core-v2.php?t={core_token}");
    let core_js = fetch_text(requester, &core_url, &player_url).await?;
    let sc = SC_RE.captures(&core_js)?.get(1)?.as_str().to_string();
    let rid = RID_RE.captures(&core_js)?.get(1)?.as_str().to_string();
    let ids: Vec<String> = ELEM_ID_RE
        .captures_iter(&core_js)
        .take(5)
        .filter_map(|c| c.get(1).map(|m| m.as_str().to_string()))
        .collect();
    let attrs: Vec<String> = ATTR_RE
        .captures_iter(&core_js)
        .take(3)
        .filter_map(|c| c.get(1).map(|m| m.as_str().to_string()))
        .collect();
    if ids.len() < 5 || attrs.len() < 3 {
        return None;
    }

    crate::flow_debug!(
        "hentaitv resolve embed={} player_html={}B core_js={}B sc={} rid={} ids={} attrs={}",
        embed_id,
        player_html.len(),
        core_js.len(),
        sc,
        rid,
        ids.len(),
        attrs.len()
    );

    // 4. Read the five challenge parts out of player.php using the discovered ids/attrs.
    let p1 = read_attr(&player_html, &ids[0], &attrs[0])?;
    let p2 = read_input_value(&player_html, &ids[1])?;
    let p3 = read_attr(&player_html, &ids[2], &attrs[1])?;
    let p4 = read_template_text(&player_html, &ids[3])?;
    let ts = read_attr(&player_html, &ids[4], &attrs[2])?;

    // 5. Proof of work over the concatenated parts (first SHA-256 byte must be zero).
    let pow = solve_pow(&format!("{p1}{p2}{p3}{p4}{ts}"));
    let fp = STANDARD.encode(FINGERPRINT_JSON.as_bytes());

    // The server enforces a minimum dwell time (>= 700ms) between the challenge being issued and
    // the URL request; the browser sleeps for the same reason. Without this the endpoint rejects
    // the request and returns no url.
    tokio::time::sleep(Duration::from_millis(900)).await;

    // 6. Ask the server to mint the signed URL. Encode like the browser's encodeURIComponent
    // (unreserved marks left intact) so the server validates exactly as it would in-browser.
    let enc = |s: &str| percent_encoding::utf8_percent_encode(s, COMPONENT).to_string();
    let query = format!(
        "vid={}&c={}&p1={}&p2={}&p3={}&p4={}&t={}&sc={}&rid={}&fp={}&df=&pow={}&pid={}&st={}",
        enc(&vid),
        enc(&ct),
        enc(&p1),
        enc(&p2),
        enc(&p3),
        enc(&p4),
        enc(&ts),
        enc(&sc),
        enc(&rid),
        enc(&fp),
        enc(&pow),
        enc(&pid),
        enc(&st),
    );
    let final_url = format!("{NH_BASE}/get-video-url-v2.php?{query}");
    let resp = requester
        .get_raw_with_headers(
            &final_url,
            vec![
                ("Referer".to_string(), player_url.clone()),
                ("X-Requested-With".to_string(), "XMLHttpRequest".to_string()),
            ],
        )
        .await
        .ok()?;
    let status = resp.status();
    #[cfg(not(feature = "debug"))]
    let _ = &status;
    let body = resp.text().await.ok()?;
    crate::flow_debug!(
        "hentaitv resolve embed={} get-video-url status={} body={}",
        embed_id,
        status.as_u16(),
        crate::util::flow_debug::preview(&body, 200)
    );
    let captures = URL_FIELD_RE.captures(&body)?;
    let url = captures.get(1)?.as_str().replace("\\/", "/");
    if url.starts_with("http") {
        Some(url)
    } else {
        None
    }
}

async fn resolve_cached(requester: &mut Requester, embed_id: &str, force: bool) -> Option<String> {
    if !force {
        if let Some(url) = cache_get(embed_id) {
            return Some(url);
        }
    }
    let url = resolve_signed_url(requester, embed_id).await?;
    cache_put(embed_id, &url);
    Some(url)
}

fn embed_id_from_endpoint(endpoint: &str) -> String {
    endpoint
        .trim_matches('/')
        .trim_end_matches(".mp4")
        .to_string()
}

pub async fn serve_media(
    req: HttpRequest,
    requester: web::types::State<Requester>,
) -> Result<impl web::Responder, web::Error> {
    let endpoint = req.match_info().query("endpoint").to_string();
    let embed_id = embed_id_from_endpoint(&endpoint);
    if embed_id.is_empty() {
        return Ok(web::HttpResponse::BadRequest().finish());
    }

    // HEAD: answer probes/health-checks directly. We can't fetch the CF-guarded CDN from this TLS
    // stack, but the resource is a direct MP4, so advertise it as one without touching it.
    if req.method() == Method::HEAD {
        return Ok(web::HttpResponse::Ok()
            .header(CONTENT_TYPE, "video/mp4")
            .header(ACCEPT_RANGES, "bytes")
            .finish());
    }

    // GET/POST: resolve the signed CDN URL and 302 to it so the client fetches it directly.
    let mut requester = requester.get_ref().clone();
    match resolve_cached(&mut requester, &embed_id, false).await {
        Some(signed_url) => Ok(web::HttpResponse::Found()
            .header(LOCATION, signed_url)
            .finish()),
        None => Ok(web::HttpResponse::BadGateway().finish()),
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn strips_mp4_suffix_and_slashes() {
        assert_eq!(embed_id_from_endpoint("M2WMSkkRmf5wJvq.mp4"), "M2WMSkkRmf5wJvq");
        assert_eq!(embed_id_from_endpoint("/M2WMSkkRmf5wJvq.mp4"), "M2WMSkkRmf5wJvq");
        assert_eq!(embed_id_from_endpoint("M2WMSkkRmf5wJvq"), "M2WMSkkRmf5wJvq");
    }

    #[test]
    fn pow_first_byte_is_zero() {
        let answer = solve_pow("abcd1234");
        let mut hasher = Sha256::new();
        hasher.update("abcd1234".as_bytes());
        hasher.update(answer.as_bytes());
        assert_eq!(hasher.finalize()[0], 0);
    }

    #[test]
    fn reads_challenge_parts_in_either_order() {
        let html = r#"<span id="xb01505" data-b74a="c13d97ee"></span><input type="hidden" id="xfc1ef6" value="fb3c856e"><template id="x60ad50"><p>81b9beee</p></template>"#;
        assert_eq!(read_attr(html, "xb01505", "data-b74a").as_deref(), Some("c13d97ee"));
        assert_eq!(read_input_value(html, "xfc1ef6").as_deref(), Some("fb3c856e"));
        assert_eq!(read_template_text(html, "x60ad50").as_deref(), Some("81b9beee"));
    }
}
