use std::borrow::Cow;
use std::num::NonZeroU16;
use std::str::FromStr;

use fancy_regex::Regex;
use idna::uts46::{AsciiDenyList, Hyphens, Uts46};
use percent_encoding::percent_decode_str;
use unicode_security::confusable_detection::skeleton;
use unicode_security::{RestrictionLevel, RestrictionLevelDetection};

use crate::appearance::theme;
use crate::config::server::default_port;
use crate::server::ServerName;
use crate::{config, isupport};

#[derive(Debug, Clone)]
pub enum Url {
    ServerConnect {
        url: String,
        server: ServerName,
        config: config::Server,
    },
    Theme {
        url: String,
        styles: theme::Styles,
    },
    Unknown(String),
}

impl std::fmt::Display for Url {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        write!(
            f,
            "{}",
            match self {
                Url::ServerConnect { url, .. }
                | Url::Theme { url, .. }
                | Url::Unknown(url) => url,
            }
        )
    }
}

pub fn theme(colors: &theme::Styles) -> String {
    format!("halloy:///theme?e={}", colors.encode_base64())
}

pub fn theme_submit(colors: &theme::Styles) -> String {
    format!(
        "https://themes.halloy.chat/submit?e={}",
        colors.encode_base64()
    )
}

impl Url {
    pub fn find_in(mut args: impl Iterator<Item = String>) -> Option<Self> {
        args.find_map(|arg| arg.parse().ok())
    }
}

impl FromStr for Url {
    type Err = ();

    fn from_str(s: &str) -> Result<Self, Self::Err> {
        let url = s.parse::<url::Url>().map_err(|_| ())?;

        if [
            "irc",
            "irc+insecure",
            "ircs",
            "halloy",
            "ws",
            "ws+insecure",
            "wss",
        ]
        .contains(&url.scheme())
        {
            Ok(parse(url.clone()).unwrap_or(Url::Unknown(url.to_string())))
        } else {
            Err(())
        }
    }
}

fn parse(url: url::Url) -> Result<Url, Error> {
    match url.scheme().to_lowercase().as_str() {
        "irc" | "irc+insecure" | "ircs" | "ws" | "ws+insecure" | "wss" => {
            let config = parse_server_config(&url).ok_or(Error::ParseServer)?;
            let server = generate_server_name(config.server.as_str());
            let url = url.into();

            Ok(Url::ServerConnect {
                url,
                server: server.into(),
                config,
            })
        }
        "halloy" if url.path() == "/theme" => {
            let (_, encoded) = url
                .query_pairs()
                .find(|(key, _)| key == "e")
                .ok_or(Error::MissingQueryPair)?;

            let styles = theme::Styles::decode_base64(&encoded)?;

            Ok(Url::Theme {
                url: url.into(),
                styles,
            })
        }
        _ => Err(Error::Unknown),
    }
}

fn generate_server_name(host: &str) -> &str {
    let pattern = Regex::new(r"irc\.([^.]+)").unwrap();

    if let Ok(Some(captures)) = pattern.captures(host)
        && let Some(matched) = captures.get(1)
    {
        return matched.as_str();
    }

    host
}

fn parse_server_config(url: &url::Url) -> Option<config::Server> {
    let nickname = config::random_nickname();

    // Match on the host so IPv6 literals are stored without the square
    // brackets that `url::Host::to_string()` would include.
    let server = match url.host()? {
        url::Host::Ipv6(address) => address.to_string(),
        host => host.to_string(),
    };
    let port = url.port();
    let use_tls = match url.scheme().to_lowercase().as_str() {
        "irc" | "irc+insecure" | "ws" | "ws+insecure" => false,
        "ircs" | "wss" => true,
        _ => return None,
    };
    let use_websocket = match url.scheme().to_lowercase().as_str() {
        "irc" | "irc+insecure" | "ircs" => false,
        "ws" | "ws+insecure" | "wss" => true,
        _ => return None,
    };
    let channels = {
        let default_chantype =
            isupport::DEFAULT_CHANTYPES.first().copied().unwrap_or('#');

        let normalize_channel = |channel: &str| -> Option<String> {
            let channel = percent_decode_str(channel).decode_utf8_lossy();

            if channel.is_empty() {
                return None;
            }

            // URL parsing runs before we know the server's CHANTYPES, so fall
            // back to the default chantype set and prepend its first prefix
            // for bare targets.
            if channel.starts_with(isupport::DEFAULT_CHANTYPES) {
                Some(channel.into_owned())
            } else {
                Some(format!("{default_chantype}{channel}"))
            }
        };

        let mut channels = url.fragment().map_or(vec![], |fragment| {
            // Fragment starts with #. We consider them as channels.
            // Eg: [...]/#channel1,#channel2
            fragment
                .split(',')
                .filter_map(normalize_channel)
                .collect::<Vec<_>>()
        });

        if !url.path().is_empty() {
            // We also consider path as channels separated by ','.
            // Eg: [...]/channel1,channel2
            channels.extend(
                url.path()[1..]
                    .split(',')
                    .filter_map(normalize_channel)
                    .collect::<Vec<_>>(),
            );
        }

        channels
    };

    Some(config::Server::new(
        server,
        match port {
            Some(port) => NonZeroU16::new(port),
            None => Some(default_port(use_tls, use_websocket)),
        },
        nickname,
        channels,
        use_tls,
        use_websocket,
    ))
}

#[derive(Debug, thiserror::Error)]
pub enum Error {
    #[error(transparent)]
    ParseUrl(#[from] url::ParseError),
    #[error("can't convert url to a valid server")]
    ParseServer,
    #[error("unknown route")]
    Unknown,
    #[error("missing query pair")]
    MissingQueryPair,
    #[error("failed to parse encoded theme: {0}")]
    ParseEncodedTheme(#[from] theme::Error),
}

/// Returns the human-readable form of a URL.
///
/// If hosts don't pass a specific validation criteria, they are displayed as punycode.
/// The path segment of a URL is always `percent-encoding` decoded.
///
/// We apply a best effort to handle international domain names in a way that matches browser
/// status-quo; The host is rendered according to UTS #46 when it passes UTS #39 "Highly Restrictive"
/// level, and its skeleton is not pure ASCII (which guards against homograph attacks).
///
/// See https://unicode.org/reports/tr46/ and https://www.unicode.org/reports/tr39/ for
/// motivations. https://chromium.googlesource.com/chromium/src/+/main/docs/idn.md
/// is not a bad read either - though note that IRC clients have very different threat
/// models than browsers.
///
pub fn display(u: &url::Url) -> Cow<'_, str> {
    u.host_str()
        .and_then(|host| {
            let (host_str, _) = Uts46::new().to_user_interface(
                host.as_bytes(),
                AsciiDenyList::EMPTY,
                Hyphens::Allow,
                |label, _tld, _is_bidi| {
                    let label_str: String = label.iter().collect();
                    let s = label_str.as_str();
                    // reject mixed-script confusables
                    s.check_restriction_level(RestrictionLevel::HighlyRestrictive)
                        // OR if every character maps to an ASCII confusable prototype;
                        // not something the spec mandates, but browsers do this
                        && !skeleton(s).all(|c| c.is_ascii())
                },
            );
            u.as_str().split_once(host).map(|(scheme, path)| {
                // https://ja.wikipedia.org/wiki/%E9%87%8D%E9%9F%B3%E3%83%86%E3%83%88
                // -> https://ja.wikipedia.org/wiki/重音テト
                let path = percent_decode_str(path).decode_utf8_lossy();
                Cow::Owned(format!("{scheme}{host_str}{path}"))
            })
        })
        .unwrap_or(Cow::Borrowed(u.as_str()))
}

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

    #[track_caller]
    fn assert_server_connect(
        input: &str,
        expected_server_name: &str,
        expected_host: &str,
        expected_port: Option<NonZeroU16>,
        expected_channels: &[&str],
        expected_use_tls: bool,
        expected_use_websocket: bool,
    ) {
        let url = Url::from_str(input).unwrap();

        let Url::ServerConnect { server, config, .. } = url else {
            panic!("expected server connect URL");
        };

        assert_eq!(&*server, expected_server_name);
        assert_eq!(config.server, expected_host);
        assert_eq!(config.port, expected_port);
        assert_eq!(config.use_tls, expected_use_tls);
        assert_eq!(config.use_websocket, expected_use_websocket);
        assert_eq!(
            config.channels,
            expected_channels
                .iter()
                .map(ToString::to_string)
                .collect::<Vec<_>>()
        );
    }

    #[test]
    fn parses_hostname_without_channels() {
        assert_server_connect(
            "irc://irc.libera.chat",
            "libera",
            "irc.libera.chat",
            NonZeroU16::new(6667),
            &[],
            false,
            false,
        );
    }

    #[test]
    fn parses_hostname_with_fragment_channels_and_explicit_tls_port() {
        assert_server_connect(
            "ircs://irc.libera.chat:7000/#halloy,#rust",
            "libera",
            "irc.libera.chat",
            NonZeroU16::new(7000),
            &["#halloy", "#rust"],
            true,
            false,
        );
    }

    #[test]
    fn parses_ipv4_host_with_path_channels() {
        assert_server_connect(
            "irc://127.0.0.1:6669/channel,%26local,%2Bops,!safe",
            "127.0.0.1",
            "127.0.0.1",
            NonZeroU16::new(6669),
            &["#channel", "&local", "#+ops", "#!safe"],
            false,
            false,
        );
    }

    #[test]
    fn parses_ipv6_host_without_channels() {
        assert_server_connect(
            "ircs://[2001:db8::1]",
            "2001:db8::1",
            "2001:db8::1",
            NonZeroU16::new(6697),
            &[],
            true,
            false,
        );
    }

    #[test]
    fn parse_server_config_strips_ipv6_brackets() {
        let url = url::Url::parse("irc://[2001:db8::1]/channel").unwrap();
        let config = parse_server_config(&url).unwrap();

        assert_eq!(config.server, "2001:db8::1");
        assert_eq!(config.port, NonZeroU16::new(6667));
        assert_eq!(config.channels, vec!["#channel"]);
        assert!(!config.use_tls);
    }

    #[test]
    fn parse_server_config_decodes_percent_encoded_path_channels() {
        let url =
            url::Url::parse("irc://irc.example.org/%23foo%25bar,%26local")
                .unwrap();
        let config = parse_server_config(&url).unwrap();

        assert_eq!(config.channels, vec!["#foo%bar", "&local"]);
    }

    #[test]
    fn parse_server_config_decodes_percent_encoded_fragment_channels() {
        let url =
            url::Url::parse("irc://irc.example.org/#foo%25bar,%2Bops").unwrap();
        let config = parse_server_config(&url).unwrap();

        assert_eq!(config.channels, vec!["#foo%bar", "#+ops"]);
    }

    #[test]
    fn parses_channels_with_percent_encoded_special_characters() {
        assert_server_connect(
            "irc://irc.example.org/%23ops%5Btest%5D%7Bdev%7D%5Efoo,%23foo%25bar",
            "example",
            "irc.example.org",
            NonZeroU16::new(6667),
            &["#ops[test]{dev}^foo", "#foo%bar"],
            false,
            false,
        );
    }

    #[test]
    fn display_latin_umlaut() {
        // bücher.de — latin with umlaut, safe to show as unicode according to UTS #46 and #39
        let u = url::Url::parse("https://bücher.de/").unwrap();
        assert_eq!(display(&u), "https://bücher.de/");
    }

    #[test]
    fn display_percent_encoded_path() {
        // ASCII host, percent-encoded unicode path. path should be percent-decoded.
        let u = url::Url::parse(
            "https://ja.wikipedia.org/wiki/%E9%87%8D%E9%9F%B3%E3%83%86%E3%83%88",
        )
        .unwrap();
        assert_eq!(display(&u), "https://ja.wikipedia.org/wiki/重音テト");
    }

    #[test]
    fn display_homograph_attack_stays_punycode() {
        // all-cyrillic that looks identical to apple.com.
        // got famous in 2017 and made browsers change their logic!
        let u = url::Url::parse("https://www.аррӏе.com").unwrap();
        assert_eq!(display(&u), "https://www.xn--80ak6aa92e.com/");
    }

    #[test]
    fn display_mixed_script_stays_punycode() {
        // cyrillic 'а' mixed with latin 'pple'
        let u = url::Url::parse("https://аpple.com/").unwrap();
        assert_eq!(display(&u), "https://xn--pple-43d.com/");
    }

    #[test]
    fn display_japanese_domain() {
        // CJK characters have no ASCII-confusable prototypes
        let u = url::Url::parse("https://日本語.jp/").unwrap();
        assert_eq!(display(&u), "https://日本語.jp/");
    }

    #[test]
    fn parses_wss_with_default_ports() {
        assert_server_connect(
            "wss://irc.libera.chat",
            "libera",
            "irc.libera.chat",
            NonZeroU16::new(443),
            &[],
            true,
            true,
        );
    }

    #[test]
    fn parses_websocket_with_default_insecure_port() {
        assert_server_connect(
            "ws+insecure://irc.libera.chat",
            "libera",
            "irc.libera.chat",
            NonZeroU16::new(80),
            &[],
            false,
            true,
        );
    }
}
