//! Typed projection from a parsed JSON manifest into the fields an extension
//! scanner cares about.

use crate::json::{parse as parse_json, Json, ParseError};

/// A single `content_scripts` entry from the manifest.
///
/// Content scripts are the dominant cross-origin exposure surface in a Chrome
/// extension: each entry injects code into every page matching one of its
/// `matches` patterns, so the match list plus the script file list is the
/// first thing a security review looks at.
#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub struct ContentScript {
    /// The `matches` array — match patterns the script runs on. Required by
    /// the manifest spec; if absent or non-string this is empty.
    pub matches: Vec<String>,
    /// The `exclude_matches` array, if present.
    pub exclude_matches: Vec<String>,
    /// The `js` files injected into matching pages.
    pub js: Vec<String>,
    /// The `css` files injected into matching pages.
    pub css: Vec<String>,
    /// Whether `run_at` is `document_start` (scripts inject before the page
    /// builds its DOM, the position that sees every password field as it is
    /// created). `None` means the field was absent (Chrome defaults to
    /// `document_idle`).
    pub run_at: Option<String>,
}

/// A typed view over the fields of a Chrome `manifest.json` that an extension
/// privacy &amp; security scanner inspects.
///
/// Every field is optional: manifests are user-supplied and routinely omit
/// keys, so the parser never hard-fails on a missing field — it surfaces
/// `None` / an empty `Vec` instead. A hard failure only happens when the
/// document is not valid JSON or a field has the wrong JSON type (e.g.
/// `permissions` given as a string instead of an array).
#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub struct Manifest {
    /// `manifest_version`. MV3 is `3`; legacy MV2 is `2`.
    pub manifest_version: Option<i64>,
    /// `name`.
    pub name: Option<String>,
    /// `version` (Chrome's own dotted-decimal string).
    pub version: Option<String>,
    /// `description`.
    pub description: Option<String>,
    /// `permissions` — named API tokens (`activeTab`, `tabs`, ...).
    pub permissions: Vec<String>,
    /// `optional_permissions` — tokens requested at runtime.
    pub optional_permissions: Vec<String>,
    /// `host_permissions` — match-patterns granting site access.
    pub host_permissions: Vec<String>,
    /// `content_scripts` entries.
    pub content_scripts: Vec<ContentScript>,
}

impl Manifest {
    /// Parse a `manifest.json` document into a typed [`Manifest`].
    ///
    /// Returns an error only when the document is not valid JSON or a known
    /// field has the wrong type; missing keys are tolerated and surface as
    /// `None` / empty vectors.
    ///
    /// ```
    /// use crx_manifest_parser::Manifest;
    /// let m = Manifest::from_json(r#"{ "manifest_version": 3, "name": "X" }"#).unwrap();
    /// assert_eq!(m.manifest_version, Some(3));
    /// assert_eq!(m.name.as_deref(), Some("X"));
    /// ```
    pub fn from_json(input: &str) -> Result<Self, ParseError> {
        let value = parse_json(input)?;
        Self::from_json_value(&value)
    }

    /// Build a [`Manifest`] from an already-parsed [`Json`] value. Useful when
    /// a caller wants to inspect raw fields via the JSON model as well.
    pub fn from_json_value(value: &Json) -> Result<Self, ParseError> {
        // The top level must be an object for a manifest.
        if !matches!(value, Json::Object(_)) {
            return Err(ParseError {
                offset: 0,
                message: "manifest root is not a JSON object".to_string(),
            });
        }

        let manifest_version = value.get("manifest_version").and_then(|v| v.as_i64());
        let name = value.get("name").and_then(|v| v.as_str()).map(str::to_string);
        let version = value.get("version").and_then(|v| v.as_str()).map(str::to_string);
        let description = value
            .get("description")
            .and_then(|v| v.as_str())
            .map(str::to_string);

        let permissions = string_array(value, "permissions")?;
        let optional_permissions = string_array(value, "optional_permissions")?;
        let host_permissions = string_array(value, "host_permissions")?;

        let content_scripts = match value.get("content_scripts") {
            None => Vec::new(),
            Some(Json::Null) => Vec::new(),
            Some(Json::Array(items)) => {
                let mut out = Vec::with_capacity(items.len());
                for item in items {
                    out.push(ContentScript::from_json_value(item)?);
                }
                out
            }
            Some(other) => {
                return Err(ParseError {
                    offset: 0,
                    message: format!(
                        "content_scripts must be an array, found {}",
                        json_type_name(other)
                    ),
                })
            }
        };

        Ok(Manifest {
            manifest_version,
            name,
            version,
            description,
            permissions,
            optional_permissions,
            host_permissions,
            content_scripts,
        })
    }

    /// Concatenation of `permissions` and `host_permissions` — the full set
    /// of grant tokens an extension requests, in declaration order. This is
    /// the input a permission auditor (e.g. `permission-auditor`) consumes.
    ///
    /// ```
    /// use crx_manifest_parser::Manifest;
    /// let m = Manifest::from_json(r#"{ "permissions": ["tabs"], "host_permissions": ["<all_urls>"] }"#).unwrap();
    /// assert_eq!(m.all_grants(), &["tabs".to_string(), "<all_urls>".to_string()]);
    /// ```
    pub fn all_grants(&self) -> Vec<String> {
        let mut out =
            Vec::with_capacity(self.permissions.len() + self.host_permissions.len());
        out.extend(self.permissions.iter().cloned());
        out.extend(self.host_permissions.iter().cloned());
        out
    }

    /// Convenience: every `matches` pattern across all content scripts,
    /// deduplicated, in first-seen order. This is the set of sites the
    /// extension can run injected code on.
    ///
    /// ```
    /// use crx_manifest_parser::Manifest;
    /// let m = Manifest::from_json(r#"{ "content_scripts": [
    ///     { "matches": ["https://*/*"], "js": ["a.js"] },
    ///     { "matches": ["https://*.example.com/*", "https://*/*"] }
    /// ] }"#).unwrap();
    /// assert_eq!(m.content_match_patterns(), &["https://*/*".to_string(), "https://*.example.com/*".to_string()]);
    /// ```
    pub fn content_match_patterns(&self) -> Vec<String> {
        let mut seen: Vec<String> = Vec::new();
        for cs in &self.content_scripts {
            for m in &cs.matches {
                if !seen.iter().any(|s| s == m) {
                    seen.push(m.clone());
                }
            }
        }
        seen
    }

    /// Whether the manifest declares Manifest V3 (`manifest_version == 3`).
    /// Returns `false` for MV2, missing, or any other value.
    pub fn is_mv3(&self) -> bool {
        self.manifest_version == Some(3)
    }

    /// Whether the manifest declares the legacy Manifest V2
    /// (`manifest_version == 2`).
    pub fn is_mv2(&self) -> bool {
        self.manifest_version == Some(2)
    }
}

impl ContentScript {
    fn from_json_value(value: &Json) -> Result<Self, ParseError> {
        if !matches!(value, Json::Object(_)) {
            return Err(ParseError {
                offset: 0,
                message: "content_scripts entry is not an object".to_string(),
            });
        }
        Ok(ContentScript {
            matches: string_array(value, "matches")?,
            exclude_matches: string_array(value, "exclude_matches")?,
            js: string_array(value, "js")?,
            css: string_array(value, "css")?,
            run_at: value.get("run_at").and_then(|v| v.as_str()).map(str::to_string),
        })
    }
}

/// Extract a string-array field from an object, tolerating `null` and
/// missing keys (both yield an empty `Vec`), but erroring if the field is
/// present with a non-array type or contains non-string elements.
fn string_array(obj: &Json, key: &str) -> Result<Vec<String>, ParseError> {
    match obj.get(key) {
        None => Ok(Vec::new()),
        Some(Json::Null) => Ok(Vec::new()),
        Some(Json::Array(items)) => {
            let mut out = Vec::with_capacity(items.len());
            for item in items {
                match item {
                    Json::String(s) => out.push(s.clone()),
                    other => {
                        return Err(ParseError {
                            offset: 0,
                            message: format!(
                                "{} array contains a non-string element ({})",
                                key,
                                json_type_name(other)
                            ),
                        })
                    }
                }
            }
            Ok(out)
        }
        Some(other) => Err(ParseError {
            offset: 0,
            message: format!(
                "{} must be an array of strings, found {}",
                key,
                json_type_name(other)
            ),
        }),
    }
}

fn json_type_name(value: &Json) -> &'static str {
    match value {
        Json::Null => "null",
        Json::Bool(_) => "bool",
        Json::Number(_) => "number",
        Json::String(_) => "string",
        Json::Array(_) => "array",
        Json::Object(_) => "object",
    }
}

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

    const FULL_MV3: &str = r#"{
        "manifest_version": 3,
        "name": "Privacy Badge",
        "version": "1.4.2",
        "description": "Shows a badge for trackers.",
        "permissions": ["activeTab", "storage", "tabs"],
        "optional_permissions": ["history"],
        "host_permissions": ["https://*/*"],
        "content_scripts": [
            {
                "matches": ["https://*/*"],
                "exclude_matches": ["https://*.bank.com/*"],
                "js": ["content.js"],
                "css": ["style.css"],
                "run_at": "document_start"
            }
        ]
    }"#;

    #[test]
    fn parses_full_mv3_manifest() {
        let m = Manifest::from_json(FULL_MV3).expect("valid");
        assert_eq!(m.manifest_version, Some(3));
        assert_eq!(m.name.as_deref(), Some("Privacy Badge"));
        assert_eq!(m.version.as_deref(), Some("1.4.2"));
        assert_eq!(
            m.description.as_deref(),
            Some("Shows a badge for trackers.")
        );
        assert_eq!(m.permissions, ["activeTab", "storage", "tabs"]);
        assert_eq!(m.optional_permissions, ["history"]);
        assert_eq!(m.host_permissions, ["https://*/*"]);
        assert!(m.is_mv3());
        assert!(!m.is_mv2());
    }

    #[test]
    fn parses_content_scripts_block() {
        let m = Manifest::from_json(FULL_MV3).expect("valid");
        assert_eq!(m.content_scripts.len(), 1);
        let cs = &m.content_scripts[0];
        assert_eq!(cs.matches, ["https://*/*"]);
        assert_eq!(cs.exclude_matches, ["https://*.bank.com/*"]);
        assert_eq!(cs.js, ["content.js"]);
        assert_eq!(cs.css, ["style.css"]);
        assert_eq!(cs.run_at.as_deref(), Some("document_start"));
    }

    #[test]
    fn mv2_detected() {
        let m = Manifest::from_json(r#"{ "manifest_version": 2, "name": "Old" }"#).unwrap();
        assert!(m.is_mv2());
        assert!(!m.is_mv3());
    }

    #[test]
    fn missing_fields_default_to_none() {
        let m = Manifest::from_json(r#"{}"#).expect("empty object is valid");
        assert_eq!(m.manifest_version, None);
        assert_eq!(m.name, None);
        assert_eq!(m.version, None);
        assert!(m.permissions.is_empty());
        assert!(m.host_permissions.is_empty());
        assert!(m.content_scripts.is_empty());
        assert!(!m.is_mv3());
    }

    #[test]
    fn null_permissions_treated_as_empty() {
        let m = Manifest::from_json(r#"{ "permissions": null }"#).expect("null ok");
        assert!(m.permissions.is_empty());
    }

    #[test]
    fn empty_arrays_round_trip() {
        let m = Manifest::from_json(r#"{ "permissions": [], "host_permissions": [] }"#).unwrap();
        assert!(m.permissions.is_empty());
        assert!(m.host_permissions.is_empty());
    }

    #[test]
    fn non_object_root_rejected() {
        let err = Manifest::from_json(r#"["not", "an", "object"]"#).unwrap_err();
        assert!(err.message.contains("not a JSON object"), "{}", err.message);
    }

    #[test]
    fn permissions_wrong_type_rejected() {
        let err = Manifest::from_json(r#"{ "permissions": "activeTab" }"#).unwrap_err();
        assert!(err.message.contains("permissions"), "{}", err.message);
        assert!(err.message.contains("array"), "{}", err.message);
    }

    #[test]
    fn non_string_element_rejected() {
        let err = Manifest::from_json(r#"{ "permissions": ["activeTab", 42] }"#).unwrap_err();
        assert!(err.message.contains("non-string"), "{}", err.message);
    }

    #[test]
    fn content_scripts_wrong_type_rejected() {
        let err = Manifest::from_json(r#"{ "content_scripts": "nope" }"#).unwrap_err();
        assert!(err.message.contains("content_scripts"), "{}", err.message);
    }

    #[test]
    fn content_script_entry_wrong_type_rejected() {
        let err =
            Manifest::from_json(r#"{ "content_scripts": ["not-an-object"] }"#).unwrap_err();
        assert!(err.message.contains("not an object"), "{}", err.message);
    }

    #[test]
    fn all_grants_concatenates_permissions_and_hosts() {
        let m = Manifest::from_json(FULL_MV3).unwrap();
        assert_eq!(
            m.all_grants(),
            ["activeTab", "storage", "tabs", "https://*/*"]
        );
    }

    #[test]
    fn content_match_patterns_dedupes_across_scripts() {
        let m = Manifest::from_json(
            r#"{ "content_scripts": [
                { "matches": ["https://*/*"], "js": ["a.js"] },
                { "matches": ["https://*.example.com/*", "https://*/*"] }
            ] }"#,
        )
        .unwrap();
        assert_eq!(
            m.content_match_patterns(),
            ["https://*/*", "https://*.example.com/*"]
        );
    }

    #[test]
    fn handles_string_escapes_and_unicode() {
        // Escaped quotes, a backslash, and a surrogate-pair emoji in the name.
        let m = Manifest::from_json(
            r#"{ "name": "A \"cool\" ✓ ext 😀" }"#,
        )
        .unwrap();
        let name = m.name.as_deref().unwrap();
        assert!(name.contains("\"cool\""));
        assert!(name.contains('\u{2713}'));
        assert!(name.contains('\u{1F600}')); // 😀
    }

    #[test]
    fn trailing_garbage_rejected() {
        let err = Manifest::from_json(r#"{ "name": "X" } junk"#).unwrap_err();
        assert!(err.message.contains("trailing"), "{}", err.message);
    }

    #[test]
    fn manifest_version_as_float_is_not_silently_coerced() {
        // Chrome writes manifest_version as an integer. A fractional value is
        // surfaced as None rather than silently truncated — callers should
        // not have to guess whether Some(3) came from 3 or 3.9.
        let m = Manifest::from_json(r#"{ "manifest_version": 3.0 }"#).unwrap();
        assert_eq!(m.manifest_version, None);
        assert!(!m.is_mv3());
    }
}
