#![feature(ip_from)]

extern crate html5ever;

use std::{
    fs::File,
    io::Read,
    net::{IpAddr, Ipv4Addr},
    time::Instant,
};

use db::{connect, Website};
use metrics::{counter, gauge};
use metrics_exporter_prometheus::PrometheusBuilder;
use s3::S3;
use serde::Deserialize;
use surrealdb::{engine::remote::ws::Client, Surreal};
use tokio::task::JoinSet;
use tracing::{debug, error, info, instrument, trace, trace_span, warn};
use tracing_subscriber::{fmt, layer::SubscriberExt, EnvFilter, Layer, Registry};

mod db;
mod parser;
mod s3;

const GET_METRIC: &'static str = "total_gets";
const GET_IN_FLIGHT: &'static str = "gets_in_flight";
const SITES_CRAWLED: &'static str = "pages_crawled";
const BEING_PROCESSED: &'static str = "pages_being_processed";

#[derive(Deserialize)]
struct Config {
    surreal_ns: String,
    surreal_db: String,
    surreal_url: String,
    surreal_username: String,
    surreal_password: String,

    s3_url: String,
    s3_bucket: String,
    s3_access_key: String,
    s3_secret_key: String,

    crawl_filter: String,
    budget: usize,
}

#[tokio::main]
async fn main() {
    let total_runtime = Timer::start("Completed");

    let writer = std::fs::OpenOptions::new()
        .append(true)
        .create(true)
        .open("./docker/logs/tracing.log")
        .expect("Couldn't make log file!");

    let filter = EnvFilter::from_default_env();

    let registry = Registry::default().with(
        fmt::layer()
            .with_line_number(true)
            .with_thread_ids(true)
            .with_file(true)
            .json()
            .with_writer(writer)
            .with_filter(filter)
    );

    tracing::subscriber::set_global_default(registry).expect("Failed to set default subscriber");

    let builder = PrometheusBuilder::new();
    builder
        .with_http_listener(std::net::SocketAddr::new(
            IpAddr::V4(Ipv4Addr::from_octets([0, 0, 0, 0])),
            2500,
        ))
        .install()
        .expect("failed to install recorder/exporter");

    debug!("Starting...");
    // Would probably take these in as parameters from a cli
    let starting_url = "https://en.wikipedia.org/";
    // When getting uncrawled pages, name must contain this variable. "" will effectively get ignored.
    // let crawl_filter = "en.wikipedia.org/";
    // let budget = 50;
    let mut crawled = 0;

    let mut file = File::open("./Crawler.toml").expect("Failed to read Crawler.toml");
    let mut buf = String::new();
    let _ = file.read_to_string(&mut buf);

    let config: Config = toml::from_str(&buf).expect("Failed to parse Crawler.toml");

    let db = connect(&config)
        .await
        .expect("Failed to connect to surreal, aborting.");
    let s3 = S3::connect(&config)
        .await
        .expect("Failed to connect to minio, aborting.\n\nThis probably means you need to login to the minio console and get a new access key!\n\n(Probably here) http://localhost:9001/access-keys/new-account\n\n");

    let reqwest = reqwest::Client::builder()
        // .use_rustls_tls()
        .gzip(true)
        .build()
        .expect("Failed to build reqwest client.");

    // Kick off the whole machine - This Website object doesn't matter, it's just to allow for
    // get() to work.
    let span = trace_span!("Pre-Loop");
    let pre_loop_span = span.enter();
    // Download the site
    let site = Website::new(&starting_url, false);
    process(site, db.clone(), reqwest.clone(), s3.clone()).await;

    drop(pre_loop_span);

    let span = trace_span!("Loop");
    let span = span.enter();
    while crawled < config.budget {
        let get_num = if config.budget - crawled < 100 {
            config.budget - crawled
        } else {
            100
        };

        let uncrawled = get_uncrawled_links(&db, get_num, config.crawl_filter.clone()).await;
        if uncrawled.len() == 0 {
            info!("Had more budget but finished crawling everything.");
            return;
        }
        debug!("Crawling {} pages...", uncrawled.len());

        let span = trace_span!("Crawling");
        let _ = span.enter();

        {
            let mut futures = JoinSet::new();
            for site in uncrawled {
                gauge!(BEING_PROCESSED).increment(1);
                futures.spawn(process(site, db.clone(), reqwest.clone(), s3.clone()));
                // let percent = format!("{:.2}%", (crawled as f32 / budget as f32) * 100f32);
                // info!("Crawled {crawled} out of {budget} pages. ({percent})");
            }
            debug!("Joining {} futures...", futures.len());

            let c = counter!(SITES_CRAWLED);
            // As futures complete runs code in while block
            while let Some(_) = futures.join_next().await {
                c.increment(1);
                gauge!(BEING_PROCESSED).decrement(1);
                crawled += 1;
            }
        }
    }
    drop(span);

    debug!("Done");
    drop(total_runtime);
}

#[instrument(skip(db, s3, reqwest))]
/// Downloads and crawls and stores a webpage.
/// It is acceptable to clone `db`, `reqwest`, and `s3` because they all use `Arc`s internally. - Noted by Oliver
async fn process(mut site: Website, db: Surreal<Client>, reqwest: reqwest::Client, s3: S3) {
    
    // METRICS
    trace!("Process: {}", &site.site);
    let timer = Timer::start("Built request");
    // Build the request
    let request_builder = reqwest.get(&site.site.to_string());
    // METRICS
    timer.stop();

    // METRICS
    let g = gauge!(GET_IN_FLIGHT);
    g.increment(1);
    let timer = Timer::start("Got page");

    // Send the http request (get)
    if let Ok(response) = request_builder.send().await {

        // METRICS
        timer.stop();
        g.decrement(1);
        counter!(GET_METRIC).increment(1);

        // Get body from response
        let data = response
            .text()
            .await
            .expect("Failed to read http response's body!");
        // Store document
        s3.store(&data, &site.site).await;

        // Parse document and get relationships
        let sites = parser::parse(&site, &data).await;
        
        // update self in db
        site.set_crawled();
        Website::store_all(vec![site.clone()], &db).await;

        // Store all the other sites so that we can link to them.
        // let mut links_to = Vec::new();
        let others = Website::store_all(sites, &db).await;

        // Make the database's links reflect the html links between sites
        site.links_to(others, &db).await;
    } else {
        error!("Failed to get: {}", &site.site);
    }
}

/// Returns uncrawled links
#[instrument(skip(db))]
async fn get_uncrawled_links(
    db: &Surreal<Client>,
    mut count: usize,
    filter: String,
) -> Vec<Website> {
    if count > 100 {
        count = 100
    }
    debug!("Getting uncrawled links");

    let mut response = db
        .query("SELECT * FROM website WHERE crawled = false AND site ~ type::string($format) LIMIT $count;")
        .bind(("format", filter))
        .bind(("count", count))
        .await
        .expect("Hard-coded query failed..?");
    response
        .take(0)
        .expect("Returned websites couldn't be parsed")
}

pub struct Timer<'a> {
    start: Instant,
    msg: &'a str,
}

impl<'a> Timer<'a> {
    #[inline]
    pub fn start(msg: &'a str) -> Self {
        Self {
            start: Instant::now(),
            msg,
        }
    }
    pub fn stop(&self) -> f64 {
        let dif = self.start.elapsed().as_micros();
        let ms = dif as f64 / 1000.;

        if ms > 200. {
            warn!("{}", format!("{} in {:.3}ms", self.msg, ms));
        }

        ms
    }
}

impl Drop for Timer<'_> {
    fn drop(&mut self) {
        self.stop();
    }
}
