#![deny(warnings)]

use std::convert::Infallible;
use std::fs;
use std::net::SocketAddr;
use std::pin::Pin;
use std::process::{Command, Stdio};
use std::sync::Arc;

use bytes::Bytes;
use clap::{Arg, ArgMatches, Command as ClapCommand};
use http_body_util::{BodyExt, Full};
use hyper::body::Incoming as IncomingBody;
use hyper::http::response::Builder;
use hyper::server::conn::http1;
use hyper::service::Service;
use hyper::{Method, Request, Response, StatusCode};
use hyper_util::rt::TokioIo;
use tokio::net::TcpListener;
use tokio::task;

use core::hash::{Hash, Hasher};
use std::collections::hash_map::DefaultHasher;
use std::future::Future;

struct Opts {
    port: u16,
    file: String,
    commit: bool,
}

fn empty() -> Full<Bytes> {
    Full::new(Bytes::new())
}

fn from<T: Into<Bytes>>(data: T) -> Full<Bytes> {
    Full::new(data.into())
}

fn no_changes(file: &str) -> bool {
    let output = Command::new("git")
        .args(&["status", "--porcelain", file])
        .output()
        .expect("Failed to execute git");
    output.stdout.is_empty()
}

fn commit(file: &str) {
    if no_changes(file) {
        return;
    }
    let commit = Command::new("git")
        .args(&[
            "commit",
            file,
            "-m",
            "Commit after PUT",
            "--author",
            "DAV Daemon <dav@example.org>",
        ])
        .stdout(Stdio::null())
        .status()
        .expect("Failed to execute git");
    if !commit.success() {
        panic!("Failed to commit: {}", file)
    }
}

fn check_if_tracked(file: &str) {
    let check = Command::new("git")
        .args(&["ls-files", "--error-unmatch", file])
        .stdout(Stdio::null())
        .status()
        .expect("Failed to execute git");
    if !check.success() {
        panic!("File not tracked in git: {}", file)
    }
}

fn etag(bytes: &[u8]) -> String {
    let mut hasher = DefaultHasher::new();
    bytes.hash(&mut hasher);
    hasher.finish().to_string()
}

fn respond(status: StatusCode) -> Builder {
    Response::builder().status(status)
}

fn favicon() -> Response<Full<Bytes>> {
    let file = "favicon.ico";
    match fs::read(file) {
        Ok(bytes) => respond(StatusCode::OK)
            .header("etag", etag(&bytes))
            .body(from(bytes))
            .unwrap(),
        Err(_) => respond(StatusCode::NOT_FOUND).body(empty()).unwrap(),
    }
}

fn get(opts: &Opts) -> Response<Full<Bytes>> {
    let bytes = fs::read(&opts.file).expect("the file is gone");
    respond(StatusCode::OK)
        .header("content-type", "text/html")
        .header("etag", etag(&bytes))
        .body(from(bytes))
        .unwrap()
}

fn head(opts: &Opts) -> Response<Full<Bytes>> {
    let mut result = get(opts);
    *result.body_mut() = empty();
    *result.status_mut() = StatusCode::NO_CONTENT;
    result
}

fn save(opts: &Opts, bytes: &[u8]) {
    fs::write(&opts.file, bytes).expect("cannot write to the file");
    if opts.commit {
        commit(&opts.file)
    }
}

async fn put(opts: &Opts, req: Request<IncomingBody>) -> Response<Full<Bytes>> {
    let client_etag = req
        .headers()
        .get("if-match")
        .map_or("", |etag| etag.to_str().unwrap());
    let our_etag = etag(&fs::read(&opts.file).unwrap());
    if client_etag != our_etag {
        respond(StatusCode::PRECONDITION_FAILED)
            .body(empty())
            .unwrap()
    } else {
        let bytes = req.collect().await.expect("put body").to_bytes();
        save(opts, &bytes);
        respond(StatusCode::NO_CONTENT).body(empty()).unwrap()
    }
}

fn bad_request() -> Response<Full<Bytes>> {
    respond(StatusCode::BAD_REQUEST)
        .header("content-type", "text/plain")
        .body(from("bad request\n"))
        .unwrap()
}

fn options() -> Response<Full<Bytes>> {
    respond(StatusCode::OK)
        .header("dav", "1")
        .body(empty())
        .unwrap()
}

fn log_page(opts: &Opts) -> Response<Full<Bytes>> {
    let output = Command::new("git")
        .args(&[
            "log",
            "--pretty=format:<li><a href='show/%H'>%h on %aD</a></li>",
            "--",
            &opts.file,
        ])
        .output()
        .expect("Failed to execute git log");
    let list = String::from_utf8_lossy(&output.stdout);
    let html = format!(
        "<html>\
            <head><title>Git Log for {file}</title></head>\
            <body>\
                <h1>Git Log for {file}</h1>\
                <ul>{list}</ul>\
            </body>\
         </html>",
        file = opts.file,
        list = list
    );
    respond(StatusCode::OK)
        .header("content-type", "text/html")
        .body(from(html))
        .unwrap()
}

fn get_show_commit(opts: &Opts, commit: &str) -> Response<Full<Bytes>> {
    let show_arg = format!("{}:{}", commit, &opts.file);
    let output = Command::new("git")
        .args(&["show", &show_arg])
        .output()
        .expect("Failed to execute git show");
    if output.status.success() {
        let script = r#"<script>
document.body.setAttribute('data-historical', 'true');
</script>
<style>
body[data-historical]::before {
    content: "Historical Snapshot";
    display: block;
    background-color: #ffdd57;
    padding: 8px;
    text-align: center;
    font-weight: bold;
}

body[data-historical]::after {
    content: "";
    position: fixed;
    top: 0;
    left: 0;
    width: 1em;
    height: 100%;
    background-color: #ffdd57;
}
</style>"#;
        let mut buf = bytes::BytesMut::from(&output.stdout[..]);
        buf.extend(script.as_bytes());

        respond(StatusCode::OK)
            .header("content-type", "text/html")
            .body(from(buf.freeze()))
            .unwrap()
    } else {
        respond(StatusCode::NOT_FOUND).body(empty()).unwrap()
    }
}

fn valid_show_path(path: &str) -> bool {
    if let Some(commit) = path.strip_prefix("/show/") {
        commit.len() == 40 && commit.chars().all(|c| c.is_digit(16))
    } else {
        false
    }
}

async fn handle(opts: Arc<Opts>, req: Request<IncomingBody>) -> Response<Full<Bytes>> {
    match (req.method(), req.uri().path()) {
        (&Method::GET, "/") => get(&opts),
        (&Method::GET, "/favicon.ico") => favicon(),
        (&Method::GET, "/log") => log_page(&opts),
        (&Method::GET, p) if valid_show_path(p) => {
            // GET /show/{commit}
            get_show_commit(&opts, &p["/show/".len()..])
        }
        (&Method::HEAD, "/") => head(&opts),
        (&Method::OPTIONS, "/") => options(),
        (&Method::PUT, "/") => put(&opts, req).await,
        _ => bad_request(),
    }
}

fn commit_opt(file: &str, matches: &ArgMatches) -> bool {
    if matches.is_present("git") {
        check_if_tracked(file);
        true
    } else {
        false
    }
}

fn opts() -> Opts {
    let matches = ClapCommand::new("dav")
        .arg(
            Arg::new("port")
                .help("TCP port to listen on")
                .short('p')
                .default_value("53502"),
        )
        .arg(
            Arg::new("git")
                .help("Periodically commit changes to git")
                .takes_value(false)
                .short('g'),
        )
        .arg(
            Arg::new("file")
                .help("File to serve")
                .short('f')
                .default_value("tiddlywiki.html"),
        )
        .get_matches();

    let port = matches.value_of("port").unwrap().parse().unwrap();
    let file = matches.value_of("file").unwrap().to_owned();

    if fs::metadata(&file).is_err() {
        panic!("File doesn't exist: {}", &file);
    }

    let commit = commit_opt(&file, &matches);

    Opts { file, port, commit }
}

#[derive(Clone)]
struct MyService {
    opts: Arc<Opts>,
}

impl Service<Request<IncomingBody>> for MyService {
    type Response = Response<Full<Bytes>>;
    type Error = Infallible;
    type Future = Pin<Box<dyn Future<Output = Result<Self::Response, Self::Error>> + Send>>;

    fn call(&self, req: Request<IncomingBody>) -> Self::Future {
        let opts = self.opts.clone();
        Box::pin(async move { Ok(handle(opts, req).await) })
    }
}

#[tokio::main]
pub async fn main() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
    let opts = Arc::new(opts());

    let addr = SocketAddr::from(([127, 0, 0, 1], opts.port));
    let listener = TcpListener::bind(addr).await?;
    println!("Listening on http://{}", addr);

    loop {
        let (stream, _) = listener.accept().await?;
        let io = TokioIo::new(stream);
        let service = MyService { opts: opts.clone() };

        task::spawn(async move {
            if let Err(err) = http1::Builder::new().serve_connection(io, service).await {
                eprintln!("Error serving connection: {:?}", err);
            }
        });
    }
}