//! Distro specifier parsing.
//!
//! Accepts four input forms and normalizes each to a strongly-typed
//! `DistroSpec { org, repo }`:
//!
//!   - bare name `omne-nosce` → `{ omne-org, omne-nosce }` (default org)
//!   - `<org>/<repo>` → `{ org, repo }` (trailing `.git` stripped)
//!   - HTTPS URL `https://github.com/<org>/<repo>(.git)?`
//!   - SSH URL `git@github.com:<org>/<repo>(.git)?`
//!
//! The returned struct drops the URL form the Python parser emitted
//! because downstream code (github.rs in Unit 4) keys the GitHub
//! Releases API on `(org, repo)` — the URL was never actually used.
//!
//! **Fixes two Python bugs** (R12):
//!   - `file://` URLs are rejected outright, not silently accepted.
//!   - Non-github.com HTTPS/SSH hosts are rejected explicitly.

#![allow(dead_code)]

use thiserror::Error;

/// A parsed distro reference keyed on `(org, repo)`.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct DistroSpec {
    pub org: String,
    pub repo: String,
}

/// Errors produced by `parse()`. Wrapped into `CliError::Distro` at the
/// command boundary once Unit 8a wires distro parsing into `init::run`.
#[derive(Debug, Error, PartialEq, Eq)]
pub enum Error {
    /// The specifier is not one of the four accepted forms, or it
    /// names a scheme (`file://`) or host we deliberately refuse.
    #[error("unsupported distro specifier `{spec}`: {reason}")]
    UnsupportedSpec { spec: String, reason: &'static str },
}

/// Parse a distro specifier into `DistroSpec { org, repo }`.
pub fn parse(spec: &str) -> Result<DistroSpec, Error> {
    // file:// is rejected outright — the Python parser silently
    // accepted these and produced no working end-to-end path (R12).
    if spec.starts_with("file://") {
        return Err(Error::UnsupportedSpec {
            spec: spec.to_string(),
            reason: "file:// URLs are not supported; use a GitHub org/repo specifier",
        });
    }

    // HTTPS URL — only github.com.
    if let Some(rest) = spec.strip_prefix("https://") {
        let path = rest
            .strip_prefix("github.com/")
            .ok_or_else(|| Error::UnsupportedSpec {
                spec: spec.to_string(),
                reason: "only github.com HTTPS URLs are supported",
            })?;
        return parse_org_repo_path(spec, path);
    }

    // SSH URL — only github.com.
    if let Some(rest) = spec.strip_prefix("git@") {
        let path = rest
            .strip_prefix("github.com:")
            .ok_or_else(|| Error::UnsupportedSpec {
                spec: spec.to_string(),
                reason: "only git@github.com: SSH URLs are supported",
            })?;
        return parse_org_repo_path(spec, path);
    }

    if spec.is_empty() {
        return Err(Error::UnsupportedSpec {
            spec: spec.to_string(),
            reason: "distro specifier must not be empty",
        });
    }

    // `<org>/<repo>` shorthand.
    if let Some((org, repo)) = spec.split_once('/') {
        if org.is_empty() || repo.is_empty() || repo.contains('/') {
            return Err(Error::UnsupportedSpec {
                spec: spec.to_string(),
                reason: "expected `<org>/<repo>` with no extra path components",
            });
        }
        let repo = strip_git_suffix_required(repo, spec)?;
        validate_segment(org, spec)?;
        validate_segment(repo, spec)?;
        return Ok(DistroSpec {
            org: org.to_string(),
            repo: repo.to_string(),
        });
    }

    // Bare name — default to omne-org. The hardcoded `omne-org` org is
    // safe by construction; only the user-supplied repo needs validation.
    let repo = strip_git_suffix_required(spec, spec)?;
    validate_segment(repo, spec)?;
    Ok(DistroSpec {
        org: "omne-org".to_string(),
        repo: repo.to_string(),
    })
}

/// Extract `<org>/<repo>` from the path portion of an HTTPS or SSH URL.
/// Rejects empty org/repo and any URL with extra path components
/// (`org/repo/extra`) — GitHub Releases only exist at the repo level.
fn parse_org_repo_path(spec: &str, path: &str) -> Result<DistroSpec, Error> {
    let (org, repo) = path.split_once('/').ok_or_else(|| Error::UnsupportedSpec {
        spec: spec.to_string(),
        reason: "expected `<org>/<repo>` in URL path",
    })?;

    if org.is_empty() || repo.is_empty() {
        return Err(Error::UnsupportedSpec {
            spec: spec.to_string(),
            reason: "org and repo must be non-empty",
        });
    }

    let repo = strip_git_suffix_required(repo, spec)?;

    // Reject `org/repo/extra` — after stripping .git, the tail must
    // be a single path segment.
    if repo.contains('/') {
        return Err(Error::UnsupportedSpec {
            spec: spec.to_string(),
            reason: "URL path must be exactly `<org>/<repo>(.git)?`",
        });
    }

    validate_segment(org, spec)?;
    validate_segment(repo, spec)?;

    Ok(DistroSpec {
        org: org.to_string(),
        repo: repo.to_string(),
    })
}

fn strip_git_suffix(s: &str) -> &str {
    s.strip_suffix(".git").unwrap_or(s)
}

/// Strip a trailing `.git` from a repo segment and reject the result if
/// it is empty. Catches `.git`, `org/.git`, and URL paths ending in
/// `.git` — all of which previously produced `DistroSpec { repo: "" }`
/// because `strip_git_suffix(".git")` returned the empty string.
fn strip_git_suffix_required<'a>(repo: &'a str, spec: &str) -> Result<&'a str, Error> {
    let stripped = strip_git_suffix(repo);
    if stripped.is_empty() {
        return Err(Error::UnsupportedSpec {
            spec: spec.to_string(),
            reason: "repo name must not be empty after stripping `.git` suffix",
        });
    }
    Ok(stripped)
}

/// Validate that a parsed `org` or `repo` segment is structurally safe
/// to interpolate into URLs, command-line arguments, and filesystem
/// paths. Called at every success path of `parse()` so the returned
/// `DistroSpec` needs no further escaping at downstream call sites.
///
/// Enforces a **positive allowlist** matching GitHub's actual repo and
/// org name charset (`[a-zA-Z0-9._-]`), plus three structural rules:
///
///   - non-empty (defense-in-depth — also caught earlier)
///   - not literally `.` or `..` (path traversal / dot-segment)
///   - does not start with `-` (CLI argument confusion when interpolated
///     into `git clone` or similar)
///
/// `@` gets a more specific error message than the generic allowlist
/// rejection because it is a plausible thing for users to type (the
/// `@ref` version pin syntax) and we may add a `--ref` flag later.
///
/// The allowlist subsumes the older denylist of NUL, control chars,
/// `%`, `/`, `\`, and additionally rejects URL query/fragment
/// delimiters (`?`, `#`), whitespace, quotes, and other punctuation
/// that would otherwise build invalid GitHub API URLs downstream.
fn validate_segment(segment: &str, spec: &str) -> Result<(), Error> {
    let bad = |reason: &'static str| Error::UnsupportedSpec {
        spec: spec.to_string(),
        reason,
    };

    if segment.is_empty() {
        return Err(bad("segment must not be empty"));
    }
    if segment == "." || segment == ".." {
        return Err(bad("segment must not be `.` or `..`"));
    }
    if segment.starts_with('-') {
        return Err(bad("segment must not start with `-`"));
    }
    for c in segment.chars() {
        if c == '@' {
            return Err(bad(
                "`@` is not supported in distro segments (no @ref version pins)",
            ));
        }
        if !matches!(c, 'a'..='z' | 'A'..='Z' | '0'..='9' | '-' | '_' | '.') {
            return Err(bad(
                "segment may only contain ASCII alphanumerics, `-`, `_`, and `.`",
            ));
        }
    }
    Ok(())
}

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

    // ----- Happy paths (port of `test_distro.py`) -----

    #[test]
    fn parse_bare_name_defaults_to_omne_org() {
        let spec = parse("omne-faber").expect("bare name should parse");
        assert_eq!(
            spec,
            DistroSpec {
                org: "omne-org".to_string(),
                repo: "omne-faber".to_string(),
            }
        );
    }

    #[test]
    fn parse_full_spec_with_org() {
        let spec = parse("omne-org/omne-faber").expect("org/repo should parse");
        assert_eq!(
            spec,
            DistroSpec {
                org: "omne-org".to_string(),
                repo: "omne-faber".to_string(),
            }
        );
    }

    #[test]
    fn parse_different_org() {
        let spec = parse("acme-corp/omne-custom").expect("any org should parse");
        assert_eq!(
            spec,
            DistroSpec {
                org: "acme-corp".to_string(),
                repo: "omne-custom".to_string(),
            }
        );
    }

    #[test]
    fn parse_https_url() {
        let spec =
            parse("https://github.com/myorg/my-distro.git").expect("github HTTPS URL should parse");
        assert_eq!(
            spec,
            DistroSpec {
                org: "myorg".to_string(),
                repo: "my-distro".to_string(),
            }
        );
    }

    #[test]
    fn parse_https_url_without_dotgit_suffix() {
        let spec = parse("https://github.com/myorg/my-distro")
            .expect("github HTTPS URL without .git should parse");
        assert_eq!(
            spec,
            DistroSpec {
                org: "myorg".to_string(),
                repo: "my-distro".to_string(),
            }
        );
    }

    #[test]
    fn parse_ssh_url() {
        let spec =
            parse("git@github.com:omne-org/omne-faber.git").expect("github SSH URL should parse");
        assert_eq!(
            spec,
            DistroSpec {
                org: "omne-org".to_string(),
                repo: "omne-faber".to_string(),
            }
        );
    }

    // ----- Edge: `.git` stripping -----

    #[test]
    fn parse_org_repo_strips_trailing_dotgit() {
        let spec =
            parse("omne-org/omne-faber.git").expect("trailing .git on org/repo should strip");
        assert_eq!(spec.repo, "omne-faber");
    }

    // ----- Error paths (new — guarding Python bugs) -----

    #[test]
    fn parse_file_url_is_rejected() {
        // Previously silently accepted by the Python parser with no
        // working end-to-end path. R12 mandates hard rejection.
        let err = parse("file:///tmp/distro").expect_err("file:// should be rejected");
        match err {
            Error::UnsupportedSpec { spec, .. } => {
                assert_eq!(spec, "file:///tmp/distro");
            }
        }
    }

    #[test]
    fn parse_non_github_https_is_rejected() {
        // The Python parser implicitly assumed github.com because it
        // only constructed github URLs in the default path. Any other
        // HTTPS host was accepted as a pass-through that would later
        // fail in the release-fetch step. Reject cleanly instead.
        let err = parse("https://gitlab.com/org/repo")
            .expect_err("non-github HTTPS host should be rejected");
        match err {
            Error::UnsupportedSpec { spec, .. } => {
                assert_eq!(spec, "https://gitlab.com/org/repo");
            }
        }
    }

    #[test]
    fn parse_non_github_ssh_is_rejected() {
        let err = parse("git@gitlab.com:org/repo.git")
            .expect_err("non-github SSH host should be rejected");
        match err {
            Error::UnsupportedSpec { spec, .. } => {
                assert_eq!(spec, "git@gitlab.com:org/repo.git");
            }
        }
    }

    #[test]
    fn parse_empty_string_is_rejected() {
        let err = parse("").expect_err("empty specifier should be rejected");
        match err {
            Error::UnsupportedSpec { .. } => {}
        }
    }

    #[test]
    fn parse_url_with_extra_path_components_is_rejected() {
        // `github.com/org/repo/extra` is not a valid distro spec —
        // GitHub Releases only exist at the repo level.
        let err = parse("https://github.com/myorg/my-distro/extra")
            .expect_err("extra path components should be rejected");
        match err {
            Error::UnsupportedSpec { .. } => {}
        }
    }

    // ----- Edge: bare `.git` and `.git`-only repo segments -----
    //
    // Pre-fix behavior: `strip_git_suffix(".git")` returned `""`, which
    // propagated into a `DistroSpec { repo: "" }` from all three call
    // sites (bare name, org/repo shorthand, URL path). Each must now
    // surface `UnsupportedSpec` instead.

    #[test]
    fn parse_bare_dot_git_is_rejected() {
        let err = parse(".git").expect_err("bare `.git` should be rejected");
        match err {
            Error::UnsupportedSpec { .. } => {}
        }
    }

    #[test]
    fn parse_org_slash_dot_git_is_rejected() {
        let err = parse("org/.git").expect_err("`org/.git` should be rejected");
        match err {
            Error::UnsupportedSpec { .. } => {}
        }
    }

    #[test]
    fn parse_https_url_with_dot_git_only_repo_is_rejected() {
        let err = parse("https://github.com/org/.git")
            .expect_err("`https://github.com/org/.git` should be rejected");
        match err {
            Error::UnsupportedSpec { .. } => {}
        }
    }

    // ----- Edge: structural segment validation (B2) -----
    //
    // Each parsed `org` and `repo` must be structurally safe to
    // interpolate into URLs, command-line arguments, and filesystem
    // paths. The pre-fix parser accepted dot-segments, version-pin
    // `@ref` syntax, control characters, NUL bytes, percent-encoding,
    // and leading `-`, all of which were either security risks
    // (path traversal, header injection) or sources of CLI/argument
    // confusion in downstream call sites.

    #[test]
    fn parse_dotdot_org_segment_is_rejected() {
        let err = parse("../evil").expect_err("`..` org segment should be rejected");
        match err {
            Error::UnsupportedSpec { .. } => {}
        }
    }

    #[test]
    fn parse_at_ref_version_pin_is_rejected() {
        // Defers `@ref` version pin parsing to a future `--ref` flag.
        let err =
            parse("omne-nosce@v1.0").expect_err("`@ref` version pin should be rejected for now");
        match err {
            Error::UnsupportedSpec { .. } => {}
        }
    }

    #[test]
    fn parse_crlf_in_repo_segment_is_rejected() {
        let err = parse("omne-org/repo\r\n").expect_err("CRLF in repo should be rejected");
        match err {
            Error::UnsupportedSpec { .. } => {}
        }
    }

    #[test]
    fn parse_nul_in_repo_segment_is_rejected() {
        let err = parse("omne-org/repo\0extra").expect_err("NUL in repo should be rejected");
        match err {
            Error::UnsupportedSpec { .. } => {}
        }
    }

    #[test]
    fn parse_percent_encoding_in_url_segment_is_rejected() {
        let err = parse("https://github.com/%2E%2E/repo")
            .expect_err("percent-encoded `..` should be rejected");
        match err {
            Error::UnsupportedSpec { .. } => {}
        }
    }

    #[test]
    fn parse_leading_dash_org_segment_is_rejected() {
        // Guards future CLI-argument confusion if `org` is interpolated
        // unquoted into a `git clone` invocation.
        let err = parse("-invalid/repo").expect_err("leading `-` org segment should be rejected");
        match err {
            Error::UnsupportedSpec { .. } => {}
        }
    }

    // Acceptance tests — guard against over-rejection by validate_segment.
    // GitHub allows dashes, underscores, dots, and numeric leading characters
    // in repo and org names; these must continue to parse cleanly.

    #[test]
    fn parse_repo_with_dashes_is_accepted() {
        let spec = parse("omne-org/repo-with-dashes").expect("dashes in repo should be accepted");
        assert_eq!(spec.repo, "repo-with-dashes");
    }

    #[test]
    fn parse_repo_with_underscores_is_accepted() {
        let spec = parse("omne-org/repo_with_underscores")
            .expect("underscores in repo should be accepted");
        assert_eq!(spec.repo, "repo_with_underscores");
    }

    #[test]
    fn parse_repo_with_internal_dots_is_accepted() {
        // Single dots in the middle of a segment are fine — only `.`
        // and `..` as whole segments are rejected.
        let spec =
            parse("omne-org/repo.with.dots").expect("internal dots in repo should be accepted");
        assert_eq!(spec.repo, "repo.with.dots");
    }

    #[test]
    fn parse_numeric_leading_org_and_repo_are_accepted() {
        let spec = parse("123org/456repo").expect("numeric-leading names should be accepted");
        assert_eq!(spec.org, "123org");
        assert_eq!(spec.repo, "456repo");
    }

    // ----- Edge: positive-allowlist enforcement -----
    //
    // The original `validate_segment` used a denylist of bad characters
    // (control chars, NUL, `%`, `/`, `\`, `@`, leading `-`). That left a
    // gap: URL query/fragment delimiters (`?`, `#`) and ASCII whitespace
    // would slip through and produce specs that look valid but later
    // build invalid GitHub API URLs. The fix is to require every char
    // be in `[a-zA-Z0-9._-]` (GitHub's actual repo/org name charset).

    #[test]
    fn parse_url_with_query_string_is_rejected() {
        let err = parse("https://github.com/org/repo.git?ref=foo")
            .expect_err("URL query string should be rejected");
        match err {
            Error::UnsupportedSpec { .. } => {}
        }
    }

    #[test]
    fn parse_url_with_fragment_is_rejected() {
        let err =
            parse("https://github.com/org/repo#frag").expect_err("URL fragment should be rejected");
        match err {
            Error::UnsupportedSpec { .. } => {}
        }
    }

    #[test]
    fn parse_segment_with_whitespace_is_rejected() {
        let err = parse("omne-org/repo with space")
            .expect_err("whitespace in repo segment should be rejected");
        match err {
            Error::UnsupportedSpec { .. } => {}
        }
    }
}
