use reqwest::{
    Client as HttpClient,
    header::{ACCEPT, ACCEPT_LANGUAGE, HeaderMap, HeaderValue},
};
use serde_json::{Value, json};
use std::sync::Arc;
use tokio::sync::RwLock;

use crate::auth::{Auth, SessionData};
use crate::endpoints::{self, graphql};
use crate::error::{Error, Result};
use crate::types::*;

const DEFAULT_USER_AGENT: &str = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36";
const IG_BASE: &str = "https://www.instagram.com";

pub struct Instagram {
    http: HttpClient,
    auth: Auth,
    csrf: Arc<RwLock<Option<String>>>,
}

pub struct InstagramBuilder {
    auth: Auth,
    user_agent: Option<String>,
}

impl Instagram {
    pub fn builder() -> InstagramBuilder {
        InstagramBuilder {
            auth: Auth::None,
            user_agent: None,
        }
    }

    fn headers() -> HeaderMap {
        let mut h = HeaderMap::new();
        h.insert(ACCEPT, HeaderValue::from_static("*/*"));
        h.insert(ACCEPT_LANGUAGE, HeaderValue::from_static("en-US,en;q=0.9"));
        h.insert("x-ig-app-id", HeaderValue::from_static(endpoints::APP_ID));
        h.insert(
            "x-requested-with",
            HeaderValue::from_static("XMLHttpRequest"),
        );
        h
    }

    async fn fetch_csrf(&self) -> Result<String> {
        {
            let cached = self.csrf.read().await;
            if let Some(ref token) = *cached {
                return Ok(token.clone());
            }
        }

        let resp = self
            .http
            .get(IG_BASE)
            .header("cookie", "ig_cb=1")
            .send()
            .await?;

        let body = resp.text().await?;

        let token = body
            .find("\"csrf_token\":\"")
            .and_then(|start| {
                let rest = &body[start + 14..];
                rest.find('"').map(|end| rest[..end].to_string())
            })
            .ok_or_else(|| Error::Api("csrf token not found".into()))?;

        {
            let mut cached = self.csrf.write().await;
            *cached = Some(token.clone());
        }

        Ok(token)
    }

    pub async fn login(&self) -> Result<()> {
        let Auth::Credentials {
            ref id,
            ref password,
        } = self.auth
        else {
            return Err(Error::InvalidCredentials);
        };

        let csrf = self.fetch_csrf().await?;

        let body = format!(
            "username={}&enc_password=#PWD_INSTAGRAM_BROWSER:0:{}:{}&queryParams={{}}&optIntoOneTap=false",
            urlencoding::encode(id),
            chrono::Utc::now().timestamp(),
            urlencoding::encode(password)
        );

        let resp = self
            .http
            .post(endpoints::rest::LOGIN)
            .header("content-type", "application/x-www-form-urlencoded")
            .header("x-csrftoken", &csrf)
            .body(body)
            .send()
            .await?;

        if resp.status() == 400 {
            return Err(Error::InvalidCredentials);
        }
        println!("{:?}", resp.status());

        let json: Value = resp.json().await?;
        println!("{:?}", &json);

        if json.get("authenticated").and_then(|v| v.as_bool()) != Some(true) {
            let msg = json
                .get("message")
                .and_then(|m| m.as_str())
                .unwrap_or("login failed");
            return Err(Error::Api(msg.into()));
        }

        Ok(())
    }

    async fn graphql_query(&self, doc_id: &str, variables: Value) -> Result<Value> {
        let csrf = self.fetch_csrf().await?;

        let body = format!(
            "variables={}&doc_id={}",
            urlencoding::encode(&variables.to_string()),
            doc_id
        );

        let resp = self
            .http
            .post(graphql::BASE)
            .header("content-type", "application/x-www-form-urlencoded")
            .header("x-csrftoken", &csrf)
            .body(body)
            .send()
            .await?;

        if resp.status() == 429 {
            let retry = resp
                .headers()
                .get("retry-after")
                .and_then(|v| v.to_str().ok())
                .and_then(|s| s.parse().ok());
            return Err(Error::RateLimited(retry));
        }

        if resp.status() == 401 || resp.status() == 403 {
            return Err(Error::AuthRequired);
        }

        let json: Value = resp.json().await?;
        Ok(json)
    }

    async fn get_json(&self, url: &str) -> Result<Value> {
        let resp = self.http.get(url).send().await?;

        if resp.status() == 429 {
            return Err(Error::RateLimited(None));
        }

        if resp.status() == 404 {
            return Err(Error::Api("not found".into()));
        }

        let json: Value = resp.json().await?;
        Ok(json)
    }

    pub async fn user(&self, username: &str) -> Result<User> {
        let url = endpoints::profile_url(username);
        let json = self.get_json(&url).await?;

        let user = json
            .get("data")
            .and_then(|d| d.get("user"))
            .ok_or_else(|| Error::UserNotFound(username.into()))?;

        Ok(User {
            id: extract_str(user, "id"),
            username: extract_str(user, "username"),
            full_name: user
                .get("full_name")
                .and_then(|v| v.as_str())
                .map(Into::into),
            biography: user
                .get("biography")
                .and_then(|v| v.as_str())
                .map(Into::into),
            profile_pic_url: user
                .get("profile_pic_url")
                .and_then(|v| v.as_str())
                .map(Into::into),
            is_private: user
                .get("is_private")
                .and_then(|v| v.as_bool())
                .unwrap_or(false),
            is_verified: user
                .get("is_verified")
                .and_then(|v| v.as_bool())
                .unwrap_or(false),
            follower_count: extract_count(user, "edge_followed_by"),
            following_count: extract_count(user, "edge_follow"),
            media_count: extract_count(user, "edge_owner_to_timeline_media"),
            external_url: user
                .get("external_url")
                .and_then(|v| v.as_str())
                .map(Into::into),
            business_email: user
                .get("business_email")
                .and_then(|v| v.as_str())
                .map(Into::into),
            business_phone: user
                .get("business_phone_number")
                .and_then(|v| v.as_str())
                .map(Into::into),
            category: user
                .get("category_name")
                .and_then(|v| v.as_str())
                .map(Into::into),
        })
    }

    pub async fn user_by_id(&self, user_id: &str) -> Result<User> {
        let url = endpoints::user_info_url(user_id);
        let json = self.get_json(&url).await?;

        let user = json
            .get("user")
            .ok_or_else(|| Error::UserNotFound(user_id.into()))?;

        Ok(User {
            id: extract_str(user, "pk"),
            username: extract_str(user, "username"),
            full_name: user
                .get("full_name")
                .and_then(|v| v.as_str())
                .map(Into::into),
            biography: user
                .get("biography")
                .and_then(|v| v.as_str())
                .map(Into::into),
            profile_pic_url: user
                .get("profile_pic_url")
                .and_then(|v| v.as_str())
                .map(Into::into),
            is_private: user
                .get("is_private")
                .and_then(|v| v.as_bool())
                .unwrap_or(false),
            is_verified: user
                .get("is_verified")
                .and_then(|v| v.as_bool())
                .unwrap_or(false),
            follower_count: user
                .get("follower_count")
                .and_then(|v| v.as_u64())
                .unwrap_or(0),
            following_count: user
                .get("following_count")
                .and_then(|v| v.as_u64())
                .unwrap_or(0),
            media_count: user
                .get("media_count")
                .and_then(|v| v.as_u64())
                .unwrap_or(0),
            external_url: user
                .get("external_url")
                .and_then(|v| v.as_str())
                .map(Into::into),
            business_email: None,
            business_phone: None,
            category: user
                .get("category")
                .and_then(|v| v.as_str())
                .map(Into::into),
        })
    }

    pub async fn media(&self, shortcode: &str) -> Result<Media> {
        let vars = json!({
            "shortcode": shortcode,
            "fetch_tagged_user_count": null,
            "hoisted_comment_id": null,
            "hoisted_reply_id": null
        });

        let json = self
            .graphql_query(graphql::doc_id::POST_DETAILS, vars)
            .await?;

        let media = json
            .pointer("/data/xdt_shortcode_media")
            .ok_or_else(|| Error::MediaNotFound(shortcode.into()))?;

        parse_media(media)
    }

    pub async fn reel(&self, shortcode: &str) -> Result<Reel> {
        let vars = json!({
            "shortcode": shortcode,
            "fetch_tagged_user_count": null,
            "hoisted_comment_id": null,
            "hoisted_reply_id": null
        });
        let json = self
            .graphql_query(graphql::doc_id::POST_DETAILS, vars)
            .await?;

        let item = json
            .pointer("/data/xdt_shortcode_media")
            .ok_or_else(|| Error::MediaNotFound(shortcode.into()))?;

        Ok(Reel {
            id: extract_str(item, "id"),
            shortcode: extract_str(item, "shortcode"),
            caption: item
                .pointer("/edge_media_to_caption/edges/0/node/text")
                .and_then(|v| v.as_str())
                .map(Into::into),
            timestamp: item
                .get("taken_at_timestamp")
                .and_then(|v| v.as_i64())
                .unwrap_or(0),
            like_count: extract_count(item, "edge_media_preview_like"),
            comment_count: extract_count(item, "edge_media_to_parent_comment"),
            play_count: item
                .get("video_play_count")
                .and_then(|v| v.as_u64())
                .unwrap_or(0),
            view_count: item
                .get("video_view_count")
                .and_then(|v| v.as_u64())
                .unwrap_or(0),
            video_url: extract_str(item, "video_url"),
            thumbnail_url: extract_str(item, "display_url"),
            duration: item
                .get("video_duration")
                .and_then(|v| v.as_f64())
                .unwrap_or(0.0),
            owner: parse_media_owner(item.get("owner").unwrap_or(&Value::Null)),
            audio: item.get("clips_music_attribution_info").map(|a| ReelAudio {
                id: extract_str(a, "audio_id"),
                title: extract_str(a, "song_name"),
                artist: a
                    .get("artist_name")
                    .and_then(|v| v.as_str())
                    .map(Into::into),
            }),
        })
    }

    pub async fn user_posts(
        &self,
        user_id: &str,
        count: u32,
        cursor: Option<&str>,
    ) -> Result<Pagination<Media>> {
        let vars = json!({
            "id": user_id,
            "first": count,
            "after": cursor
        });

        let json = self
            .graphql_query(graphql::doc_id::PROFILE_POSTS, vars)
            .await?;

        let timeline = json
            .pointer("/data/xdt_api__v1__feed__user_timeline_graphql_connection")
            .ok_or_else(|| Error::UserNotFound(user_id.into()))?;

        let edges = timeline.get("edges").and_then(|e| e.as_array());
        let page_info = timeline.get("page_info");

        let items = edges
            .map(|arr| {
                arr.iter()
                    .filter_map(|e| e.get("node"))
                    .filter_map(|n| parse_media(n).ok())
                    .collect()
            })
            .unwrap_or_default();

        Ok(Pagination {
            items,
            has_next: page_info
                .and_then(|p| p.get("has_next_page"))
                .and_then(|v| v.as_bool())
                .unwrap_or(false),
            cursor: page_info
                .and_then(|p| p.get("end_cursor"))
                .and_then(|v| v.as_str())
                .map(Into::into),
        })
    }

    pub async fn comments(
        &self,
        shortcode: &str,
        count: u32,
        cursor: Option<&str>,
    ) -> Result<Pagination<Comment>> {
        let vars = json!({
            "shortcode": shortcode,
            "fetch_tagged_user_count": null,
            "hoisted_comment_id": null,
            "hoisted_reply_id": null,
            "first": count,
            "after": cursor
        });

        let json = self
            .graphql_query(graphql::doc_id::POST_DETAILS, vars)
            .await?;

        let comments = json
            .pointer("/data/xdt_shortcode_media/edge_media_to_parent_comment")
            .ok_or_else(|| Error::MediaNotFound(shortcode.into()))?;

        let edges = comments.get("edges").and_then(|e| e.as_array());
        let page_info = comments.get("page_info");

        let items = edges
            .map(|arr| {
                arr.iter()
                    .filter_map(|e| parse_comment(e.get("node")?))
                    .collect()
            })
            .unwrap_or_default();

        Ok(Pagination {
            items,
            has_next: page_info
                .and_then(|p| p.get("has_next_page"))
                .and_then(|v| v.as_bool())
                .unwrap_or(false),
            cursor: page_info
                .and_then(|p| p.get("end_cursor"))
                .and_then(|v| v.as_str())
                .map(Into::into),
        })
    }

    pub async fn likers(
        &self,
        shortcode: &str,
        count: u32,
        cursor: Option<&str>,
    ) -> Result<Pagination<Like>> {
        let vars = json!({
            "shortcode": shortcode,
            "first": count,
            "after": cursor
        });

        let url = format!(
            "{}?query_hash={}&variables={}",
            graphql::BASE,
            graphql::query_hash::LIKERS,
            urlencoding::encode(&vars.to_string())
        );

        let json = self.get_json(&url).await?;

        let likers = json
            .pointer("/data/shortcode_media/edge_liked_by")
            .ok_or_else(|| Error::MediaNotFound(shortcode.into()))?;

        let edges = likers.get("edges").and_then(|e| e.as_array());
        let page_info = likers.get("page_info");

        let items = edges
            .map(|arr| {
                arr.iter()
                    .filter_map(|e| e.get("node"))
                    .map(|n| Like {
                        id: extract_str(n, "id"),
                        username: extract_str(n, "username"),
                        full_name: n.get("full_name").and_then(|v| v.as_str()).map(Into::into),
                        profile_pic_url: n
                            .get("profile_pic_url")
                            .and_then(|v| v.as_str())
                            .map(Into::into),
                        is_verified: n
                            .get("is_verified")
                            .and_then(|v| v.as_bool())
                            .unwrap_or(false),
                    })
                    .collect()
            })
            .unwrap_or_default();

        Ok(Pagination {
            items,
            has_next: page_info
                .and_then(|p| p.get("has_next_page"))
                .and_then(|v| v.as_bool())
                .unwrap_or(false),
            cursor: page_info
                .and_then(|p| p.get("end_cursor"))
                .and_then(|v| v.as_str())
                .map(Into::into),
        })
    }

    pub async fn followers(
        &self,
        user_id: &str,
        count: u32,
        cursor: Option<&str>,
    ) -> Result<Pagination<User>> {
        self.fetch_connections(
            user_id,
            count,
            cursor,
            graphql::query_hash::FOLLOWERS,
            "edge_followed_by",
        )
        .await
    }

    pub async fn following(
        &self,
        user_id: &str,
        count: u32,
        cursor: Option<&str>,
    ) -> Result<Pagination<User>> {
        self.fetch_connections(
            user_id,
            count,
            cursor,
            graphql::query_hash::FOLLOWING,
            "edge_follow",
        )
        .await
    }

    async fn fetch_connections(
        &self,
        user_id: &str,
        count: u32,
        cursor: Option<&str>,
        hash: &str,
        edge_key: &str,
    ) -> Result<Pagination<User>> {
        let vars = json!({
            "id": user_id,
            "first": count,
            "after": cursor
        });

        let url = format!(
            "{}?query_hash={}&variables={}",
            graphql::BASE,
            hash,
            urlencoding::encode(&vars.to_string())
        );

        let json = self.get_json(&url).await?;

        let data = json
            .pointer(&format!("/data/user/{}", edge_key))
            .ok_or_else(|| Error::UserNotFound(user_id.into()))?;

        let edges = data.get("edges").and_then(|e| e.as_array());
        let page_info = data.get("page_info");

        let items = edges
            .map(|arr| {
                arr.iter()
                    .filter_map(|e| e.get("node"))
                    .map(|n| User {
                        id: extract_str(n, "id"),
                        username: extract_str(n, "username"),
                        full_name: n.get("full_name").and_then(|v| v.as_str()).map(Into::into),
                        biography: None,
                        profile_pic_url: n
                            .get("profile_pic_url")
                            .and_then(|v| v.as_str())
                            .map(Into::into),
                        is_private: n
                            .get("is_private")
                            .and_then(|v| v.as_bool())
                            .unwrap_or(false),
                        is_verified: n
                            .get("is_verified")
                            .and_then(|v| v.as_bool())
                            .unwrap_or(false),
                        follower_count: 0,
                        following_count: 0,
                        media_count: 0,
                        external_url: None,
                        business_email: None,
                        business_phone: None,
                        category: None,
                    })
                    .collect()
            })
            .unwrap_or_default();

        Ok(Pagination {
            items,
            has_next: page_info
                .and_then(|p| p.get("has_next_page"))
                .and_then(|v| v.as_bool())
                .unwrap_or(false),
            cursor: page_info
                .and_then(|p| p.get("end_cursor"))
                .and_then(|v| v.as_str())
                .map(Into::into),
        })
    }

    pub async fn highlights(&self, user_id: &str) -> Result<Vec<Highlight>> {
        let vars = json!({
            "user_id": user_id,
            "include_chaining": false,
            "include_reel": false,
            "include_suggested_users": false,
            "include_logged_out_extras": false,
            "include_highlight_reels": true
        });

        let url = format!(
            "{}?query_hash={}&variables={}",
            graphql::BASE,
            graphql::query_hash_legacy::HIGHLIGHTS,
            urlencoding::encode(&vars.to_string())
        );

        let json = self.get_json(&url).await?;

        let reels = json
            .pointer("/data/user/edge_highlight_reels/edges")
            .and_then(|e| e.as_array())
            .ok_or_else(|| Error::UserNotFound(user_id.into()))?;

        Ok(reels
            .iter()
            .filter_map(|e| e.get("node"))
            .map(|n| Highlight {
                id: extract_str(n, "id"),
                title: extract_str(n, "title"),
                cover_url: n
                    .pointer("/cover_media/thumbnail_src")
                    .and_then(|v| v.as_str())
                    .unwrap_or_default()
                    .into(),
                media_count: n
                    .pointer("/edge_highlight_media/count")
                    .and_then(|v| v.as_u64())
                    .unwrap_or(0),
                items: vec![],
            })
            .collect())
    }

    pub async fn search(&self, query: &str) -> Result<SearchResult> {
        let url = format!(
            "{}?query={}",
            endpoints::rest::SEARCH,
            urlencoding::encode(query)
        );
        let json = self.get_json(&url).await?;

        let users = json
            .get("users")
            .and_then(|u| u.as_array())
            .map(|arr| {
                arr.iter()
                    .filter_map(|u| u.get("user"))
                    .map(|u| UserSearchItem {
                        id: extract_str(u, "pk"),
                        username: extract_str(u, "username"),
                        full_name: u.get("full_name").and_then(|v| v.as_str()).map(Into::into),
                        profile_pic_url: u
                            .get("profile_pic_url")
                            .and_then(|v| v.as_str())
                            .map(Into::into),
                        is_verified: u
                            .get("is_verified")
                            .and_then(|v| v.as_bool())
                            .unwrap_or(false),
                        is_private: u
                            .get("is_private")
                            .and_then(|v| v.as_bool())
                            .unwrap_or(false),
                        follower_count: u.get("follower_count").and_then(|v| v.as_u64()),
                    })
                    .collect()
            })
            .unwrap_or_default();

        let hashtags = json
            .get("hashtags")
            .and_then(|h| h.as_array())
            .map(|arr| {
                arr.iter()
                    .filter_map(|h| h.get("hashtag"))
                    .map(|h| HashtagSearchItem {
                        id: extract_str(h, "id"),
                        name: extract_str(h, "name"),
                        media_count: h.get("media_count").and_then(|v| v.as_u64()).unwrap_or(0),
                    })
                    .collect()
            })
            .unwrap_or_default();

        let places = json
            .get("places")
            .and_then(|p| p.as_array())
            .map(|arr| {
                arr.iter()
                    .filter_map(|p| p.get("place"))
                    .map(|p| PlaceSearchItem {
                        id: p
                            .pointer("/location/pk")
                            .and_then(|v| v.as_str())
                            .unwrap_or_default()
                            .into(),
                        name: p
                            .get("title")
                            .and_then(|v| v.as_str())
                            .unwrap_or_default()
                            .into(),
                        address: p.get("subtitle").and_then(|v| v.as_str()).map(Into::into),
                    })
                    .collect()
            })
            .unwrap_or_default();

        Ok(SearchResult {
            users,
            hashtags,
            places,
        })
    }

    pub async fn hashtag_media(
        &self,
        tag: &str,
        count: u32,
        cursor: Option<&str>,
    ) -> Result<Pagination<Media>> {
        let vars = json!({
            "tag_name": tag,
            "first": count,
            "after": cursor
        });

        let url = format!(
            "{}?query_hash={}&variables={}",
            graphql::BASE,
            graphql::query_hash::HASHTAG,
            urlencoding::encode(&vars.to_string())
        );

        let json = self.get_json(&url).await?;

        let data = json
            .pointer("/data/hashtag/edge_hashtag_to_media")
            .ok_or_else(|| Error::Api(format!("hashtag not found / {}", tag)))?;

        let edges = data.get("edges").and_then(|e| e.as_array());
        let page_info = data.get("page_info");

        let items = edges
            .map(|arr| {
                arr.iter()
                    .filter_map(|e| e.get("node"))
                    .filter_map(|n| parse_media(n).ok())
                    .collect()
            })
            .unwrap_or_default();

        Ok(Pagination {
            items,
            has_next: page_info
                .and_then(|p| p.get("has_next_page"))
                .and_then(|v| v.as_bool())
                .unwrap_or(false),
            cursor: page_info
                .and_then(|p| p.get("end_cursor"))
                .and_then(|v| v.as_str())
                .map(Into::into),
        })
    }

    pub fn auth(&self) -> &Auth {
        &self.auth
    }
}

impl InstagramBuilder {
    pub fn id(self, id: impl Into<String>) -> CredentialsBuilder {
        CredentialsBuilder {
            id: id.into(),
            password: None,
            user_agent: self.user_agent,
        }
    }

    pub fn session(mut self, session: SessionData) -> Self {
        self.auth = Auth::Session(session);
        self
    }

    pub fn user_agent(mut self, ua: impl Into<String>) -> Self {
        self.user_agent = Some(ua.into());
        self
    }

    pub fn build(self) -> Result<Instagram> {
        let ua = self.user_agent.as_deref().unwrap_or(DEFAULT_USER_AGENT);

        let http = HttpClient::builder()
            .default_headers(Instagram::headers())
            .user_agent(ua)
            .cookie_store(true)
            .gzip(true)
            .brotli(true)
            .build()?;

        Ok(Instagram {
            http,
            auth: self.auth,
            csrf: Arc::new(RwLock::new(None)),
        })
    }
}

pub struct CredentialsBuilder {
    id: String,
    password: Option<String>,
    user_agent: Option<String>,
}

impl CredentialsBuilder {
    pub fn password(mut self, password: impl Into<String>) -> Self {
        self.password = Some(password.into());
        self
    }

    pub fn user_agent(mut self, ua: impl Into<String>) -> Self {
        self.user_agent = Some(ua.into());
        self
    }

    pub fn build(self) -> Result<Instagram> {
        let password = self.password.ok_or(Error::InvalidCredentials)?;
        let ua = self.user_agent.as_deref().unwrap_or(DEFAULT_USER_AGENT);

        let http = HttpClient::builder()
            .default_headers(Instagram::headers())
            .user_agent(ua)
            .cookie_store(true)
            .gzip(true)
            .brotli(true)
            .build()?;

        Ok(Instagram {
            http,
            auth: Auth::Credentials {
                id: self.id,
                password,
            },
            csrf: Arc::new(RwLock::new(None)),
        })
    }
}

fn extract_str(v: &Value, key: &str) -> String {
    v.get(key)
        .and_then(|v| v.as_str())
        .unwrap_or_default()
        .into()
}

fn extract_count(v: &Value, key: &str) -> u64 {
    v.get(key)
        .and_then(|e| e.get("count"))
        .and_then(|c| c.as_u64())
        .unwrap_or(0)
}

fn parse_media_owner(v: &Value) -> MediaOwner {
    MediaOwner {
        id: extract_str(v, "id"),
        username: extract_str(v, "username"),
        profile_pic_url: v
            .get("profile_pic_url")
            .and_then(|p| p.as_str())
            .map(Into::into),
        is_verified: v
            .get("is_verified")
            .and_then(|b| b.as_bool())
            .unwrap_or(false),
    }
}

fn parse_media(v: &Value) -> Result<Media> {
    let typename = v.get("__typename").and_then(|t| t.as_str()).unwrap_or("");
    let is_video = v.get("is_video").and_then(|b| b.as_bool()).unwrap_or(false);

    let media_type = match typename {
        "GraphSidecar" | "XDTGraphSidecar" => MediaType::Carousel,
        "GraphStoryImage" | "GraphStoryVideo" => MediaType::Story,
        _ if v.get("product_type").and_then(|p| p.as_str()) == Some("clips") => MediaType::Reel,
        _ if is_video => MediaType::Video,
        _ => MediaType::Image,
    };

    let tagged = v
        .pointer("/edge_media_to_tagged_user/edges")
        .and_then(|e| e.as_array())
        .map(|arr| {
            arr.iter()
                .filter_map(|e| e.pointer("/node/user"))
                .map(|u| TaggedUser {
                    id: extract_str(u, "id"),
                    username: extract_str(u, "username"),
                })
                .collect()
        })
        .unwrap_or_default();

    let location = v.get("location").and_then(|l| {
        if l.is_null() {
            return None;
        }
        Some(Location {
            id: extract_str(l, "id"),
            name: extract_str(l, "name"),
            slug: l.get("slug").and_then(|s| s.as_str()).map(Into::into),
        })
    });

    Ok(Media {
        id: extract_str(v, "id"),
        shortcode: extract_str(v, "shortcode"),
        media_type,
        caption: v
            .pointer("/edge_media_to_caption/edges/0/node/text")
            .and_then(|c| c.as_str())
            .map(Into::into),
        timestamp: v
            .get("taken_at_timestamp")
            .and_then(|t| t.as_i64())
            .unwrap_or(0),
        like_count: extract_count(v, "edge_media_preview_like"),
        comment_count: extract_count(v, "edge_media_to_parent_comment"),
        view_count: v.get("video_view_count").and_then(|c| c.as_u64()),
        play_count: v.get("video_play_count").and_then(|c| c.as_u64()),
        display_url: extract_str(v, "display_url"),
        video_url: v.get("video_url").and_then(|u| u.as_str()).map(Into::into),
        owner: parse_media_owner(v.get("owner").unwrap_or(&Value::Null)),
        is_video,
        tagged_users: tagged,
        location,
    })
}

fn parse_comment(v: &Value) -> Option<Comment> {
    let replies = v
        .pointer("/edge_threaded_comments/edges")
        .and_then(|e| e.as_array())
        .map(|arr| {
            arr.iter()
                .filter_map(|e| parse_comment(e.get("node")?))
                .collect()
        })
        .unwrap_or_default();

    Some(Comment {
        id: extract_str(v, "id"),
        text: extract_str(v, "text"),
        timestamp: v.get("created_at").and_then(|t| t.as_i64()).unwrap_or(0),
        like_count: v
            .get("edge_liked_by")
            .and_then(|e| e.get("count"))
            .and_then(|c| c.as_u64())
            .unwrap_or(0),
        owner: CommentOwner {
            id: v
                .pointer("/owner/id")
                .and_then(|i| i.as_str())
                .unwrap_or_default()
                .into(),
            username: v
                .pointer("/owner/username")
                .and_then(|u| u.as_str())
                .unwrap_or_default()
                .into(),
            profile_pic_url: v
                .pointer("/owner/profile_pic_url")
                .and_then(|p| p.as_str())
                .map(Into::into),
            is_verified: v
                .pointer("/owner/is_verified")
                .and_then(|b| b.as_bool())
                .unwrap_or(false),
        },
        replies,
    })
}
