/* This Source Code Form is subject to the terms of the Mozilla Public
 * License, v. 2.0. If a copy of the MPL was not distributed with this
 * file, You can obtain one at https://mozilla.org/MPL/2.0/. */

//! Support for crash report creation and upload.
//!
//! Upload currently uses the system libcurl or curl binary rather than a rust network stack (as
//! curl is more mature, albeit the code to interact with it must be a bit more careful).

use super::http;
use crate::std::path::Path;

#[cfg(mock)]
use crate::std::mock::{mock_key, MockKey};

#[cfg(mock)]
mock_key! {
    /// The outer Result is for CrashReport::send(), the inner is for CrashReporterSender::finish().
    pub struct MockReport => Box<dyn Fn(&CrashReport) -> std::io::Result<std::io::Result<String>> + Send + Sync>
}

/// A crash report to upload.
///
/// Post a multipart form payload to the report URL.
///
/// The form data contains:
/// | name | filename | content | mime |
/// ====================================
/// | `extra` | `extra.json` | extra json object | `application/json`|
/// | `upload_file_minidump` | dump file name | dump file contents | derived (probably application/binary) |
/// if present:
/// | `memory_report` | memory file name | memory file contents | derived (probably gzipped json) |
pub struct CrashReport<'a> {
    pub extra: &'a serde_json::Value,
    pub dump_file: &'a Path,
    pub memory_file: Option<&'a Path>,
    pub url: &'a str,
}

impl CrashReport<'_> {
    /// Send the crash report.
    pub fn send(&self) -> anyhow::Result<Response> {
        let mut request = None;

        #[cfg(mock)]
        if let Some(r) = MockReport.try_get(|f| f(self)) {
            let response = r?;
            request = Some(http::Request::Mock {
                response: response.map(|s| s.into()),
            });
        }

        let extra_json_data;
        if request.is_none() {
            let mut extra = self.extra.clone();
            // Bug 2021130: If ProcessType is main (it always should be, because we only send
            // reports for main process crashes), remove the annotation. Socorro expects main
            // processes to not have the ProcessType annotation.
            //
            // This is mostly a workaround; ideally Socorro would change to accept a ProcessType of
            // "main" (see bug 2021204).
            if extra["ProcessType"].as_str() == Some("main") {
                extra.as_object_mut().unwrap().remove("ProcessType");
            }
            extra_json_data = serde_json::to_string(&extra)?;

            let mut parts = vec![
                http::MimePart {
                    name: "extra",
                    content: http::MimePartContent::String(&extra_json_data),
                    filename: Some("extra.json"),
                    mime_type: Some("application/json"),
                },
                http::MimePart {
                    name: "upload_file_minidump",
                    content: http::MimePartContent::File(self.dump_file),
                    filename: None,
                    mime_type: None,
                },
            ];
            if let Some(path) = self.memory_file {
                parts.push(http::MimePart {
                    name: "memory_report",
                    content: http::MimePartContent::File(path),
                    filename: None,
                    mime_type: None,
                })
            }

            request = Some(http::RequestBuilder::MimePost { parts }.build(self.url)?);
        }

        let response = request.unwrap().send()?;
        let response = String::from_utf8_lossy(&response).into_owned();
        log::debug!("received response from sending report: {:?}", &*response);
        Ok(Response::parse(response))
    }
}

/// A parsed response from submitting a crash report.
#[derive(Default, Debug)]
pub struct Response {
    pub crash_id: Option<String>,
    pub stop_sending_reports_for: Option<String>,
    pub view_url: Option<String>,
    pub discarded: bool,
}

impl Response {
    /// Parse a server response.
    ///
    /// The response should be newline-separated `<key>=<value>` pairs.
    fn parse<S: AsRef<str>>(response: S) -> Self {
        let mut ret = Self::default();
        // Fields may be omitted, and parsing is best-effort but will not produce any errors (just
        // a default Response struct).
        for line in response.as_ref().lines() {
            if let Some((key, value)) = line.split_once('=') {
                match key {
                    "StopSendingReportsFor" => {
                        ret.stop_sending_reports_for = Some(value.to_owned())
                    }
                    "Discarded" => ret.discarded = true,
                    "CrashID" => ret.crash_id = Some(value.to_owned()),
                    "ViewURL" => ret.view_url = Some(value.to_owned()),
                    _ => (),
                }
            }
        }
        ret
    }
}

#[cfg(test)]
mod test {
    use super::*;
    use crate::std::mock;

    #[test]
    fn report_http() {
        for memory_file in [None, Some(Path::new("minidump.memory.gz"))] {
            let report = CrashReport {
                extra: &serde_json::json! {{
                    "Foo": "Bar"
                }},
                dump_file: Path::new("minidump.dmp"),
                memory_file,
                url: "reports.example.com".as_ref(),
            };

            let checked = crate::test::Counter::new();

            mock::builder()
                .set(
                    http::MockHttp,
                    Box::new(cc!(
                        (checked)
                        move |request, url| {
                            checked.inc();
                            assert_eq!(url, "reports.example.com");
                            let mut parts = vec![
                                http::MimePart {
                                    name: "extra",
                                    content: http::MimePartContent::String(r#"{"Foo":"Bar"}"#),
                                    filename: Some("extra.json"),
                                    mime_type: Some("application/json"),
                                },
                                http::MimePart {
                                    name: "upload_file_minidump",
                                    content: http::MimePartContent::File(Path::new("minidump.dmp")),
                                    filename: None,
                                    mime_type: None,
                                },
                            ];
                            if let Some(name) = memory_file {
                                parts.push(http::MimePart {
                                    name: "memory_report",
                                    content: http::MimePartContent::File(name),
                                    filename: None,
                                    mime_type: None,
                                });
                            }
                            assert_eq!(request, &http::RequestBuilder::MimePost { parts });

                            Ok(Ok(vec![]))
                        }
                    )),
                )
                .run(|| report.send().unwrap());

            checked.assert_one();
        }
    }

    #[test]
    fn report_response() {
        let report = CrashReport {
            extra: &serde_json::json! {{}},
            dump_file: Path::new("minidump.dmp"),
            memory_file: None,
            url: "reports.example.com".as_ref(),
        };

        mock::builder()
                .set(
                    http::MockHttp,
                    Box::new(|_request, _url| {
                        Ok(Ok("CrashID=1234\nDiscarded=1\nStopSendingReportsFor=100\nViewURL=reports.example.com/foo".into()))
                    }),
                )
                .run(|| {
                    let response = report.send().unwrap();
                    assert_eq!(response.crash_id.as_deref(), Some("1234"));
                    assert!(response.discarded);
                    assert_eq!(response.stop_sending_reports_for.as_deref(), Some("100"));
                    assert_eq!(response.view_url.as_deref(), Some("reports.example.com/foo"));
                });
    }
}
