#import "modules/std.sx";
#import "platform.sx";
#import "app.sx";
#import "release.sx";
#import "artifact.sx";
#import "channel.sx";
#import "token.sx";

// Typed failures from boundary validation. One distinct tag per failure
// class so callers (and, later, the CLI) can surface a precise message
// instead of a bare bool that drops the reason.
ValidationErr :: error {
    MissingField,     // a required string field was empty
    BadSlug,          // slug broke the [a-z0-9-] / hyphen-position rules
    EmptyVersion,     // version string was empty
    BadVersion,       // version non-empty but not MAJOR.MINOR.PATCH(-suffix)
    BadChannelName,   // channel name empty or outside [a-z0-9-]
    UnknownPlatform,  // platform id did not name a Platform variant
    BadRollout,       // rollout_percent outside 0..100
    BadSize,          // artifact size_bytes was not positive
    BadDigest,        // sha256 was not exactly 64 lowercase-hex chars
    BadTokenName,     // token name empty or outside [a-z0-9._-]
    BadScope,         // scopes empty or a word outside the known scope set
    BadIosMode,            // ios mode id did not name an IosMode variant
    TestflightUrlRequired, // ios_mode testflight without a testflight_url
    BundleIdRequired,      // ios_mode enterprise without an ios bundle id
}

// ── Character classes (ASCII byte codes) ────────────────────────────────
is_lower :: (c: u8) -> bool { return c >= 97 and c <= 122; }   // 'a'..'z'
is_upper :: (c: u8) -> bool { return c >= 65 and c <= 90; }    // 'A'..'Z'
is_digit :: (c: u8) -> bool { return c >= 48 and c <= 57; }    // '0'..'9'

// ── Field validators ────────────────────────────────────────────────────

// slug: non-empty; every byte in [a-z0-9-]; no leading or trailing hyphen;
// no two consecutive hyphens. So "acme-app" passes; "", "-x", "x-", "a--b",
// and "App" are all rejected.
validate_slug :: (s: string) -> !ValidationErr {
    if s.len == 0 { raise error.BadSlug; }
    if s[0] == 45 { raise error.BadSlug; }            // 45='-' leading
    if s[s.len - 1] == 45 { raise error.BadSlug; }    // trailing
    i := 0;
    while i < s.len {
        c := s[i];
        if !(is_lower(c) or is_digit(c) or c == 45) { raise error.BadSlug; }
        if c == 45 and i > 0 {
            if s[i - 1] == 45 { raise error.BadSlug; } // double hyphen
        }
        i += 1;
    }
    return;
}

// version: MAJOR.MINOR.PATCH, each component a run of >=1 digit, optionally
// followed by "-<suffix>" where suffix is a non-empty run of [A-Za-z0-9.-]
// (e.g. "1.0.0", "2.10.3-beta.1"). Empty -> EmptyVersion; any other
// deviation -> BadVersion.
validate_version :: (s: string) -> !ValidationErr {
    if s.len == 0 { raise error.EmptyVersion; }
    i := 0;
    comp := 0;
    while comp < 3 {
        start := i;
        while i < s.len {
            if !is_digit(s[i]) { break; }
            i += 1;
        }
        if i == start { raise error.BadVersion; }      // no digits in component
        if comp < 2 {
            if i >= s.len { raise error.BadVersion; }   // missing '.' separator
            if s[i] != 46 { raise error.BadVersion; }   // 46='.'
            i += 1;
        }
        comp += 1;
    }
    if i < s.len {
        if s[i] != 45 { raise error.BadVersion; }       // only a '-' suffix allowed
        i += 1;
        if i >= s.len { raise error.BadVersion; }        // empty suffix
        while i < s.len {
            c := s[i];
            if !(is_lower(c) or is_upper(c) or is_digit(c) or c == 45 or c == 46) {
                raise error.BadVersion;
            }
            i += 1;
        }
    }
    return;
}

// sha256: exactly 64 lowercase-hex bytes ([0-9a-f]) — the rendered form of
// a 32-byte digest. Any other length or character -> BadDigest.
validate_sha256 :: (s: string) -> !ValidationErr {
    if s.len != 64 { raise error.BadDigest; }
    i := 0;
    while i < s.len {
        c := s[i];
        if !(is_digit(c) or (c >= 97 and c <= 102)) { raise error.BadDigest; }  // 'a'..'f'
        i += 1;
    }
    return;
}

// channel name: non-empty; every byte in [a-z0-9-]. Looser than slug —
// hyphen position is unconstrained (e.g. "stable", "beta-2").
validate_channel_name :: (s: string) -> !ValidationErr {
    if s.len == 0 { raise error.BadChannelName; }
    i := 0;
    while i < s.len {
        c := s[i];
        if !(is_lower(c) or is_digit(c) or c == 45) { raise error.BadChannelName; }
        i += 1;
    }
    return;
}

// Parse a platform id into the Platform enum. Accepted ids are exactly the
// variant names; anything else -> UnknownPlatform.
parse_platform :: (s: string) -> (Platform, !ValidationErr) {
    if s == "ios"         { return .ios; }
    if s == "android_apk" { return .android_apk; }
    if s == "macos"       { return .macos; }
    if s == "linux"       { return .linux; }
    if s == "windows"     { return .windows; }
    raise error.UnknownPlatform;
}

// ── Aggregate validators (required-field presence + field rules) ─────────

// Parse an iOS install-mode id into the IosMode enum. Accepted ids are
// exactly the variant names; anything else -> BadIosMode.
parse_ios_mode :: (s: string) -> (IosMode, !ValidationErr) {
    if s == "artifact_only" { return .artifact_only; }
    if s == "testflight"    { return .testflight; }
    if s == "enterprise"    { return .enterprise; }
    raise error.BadIosMode;
}

// App requires id, slug, display_name, owner; slug must also pass
// validate_slug. Presence is checked first, so an empty slug surfaces as
// MissingField and a present-but-malformed slug as BadSlug. The iOS
// install policy must be satisfiable: testflight needs a URL to send the
// user to, enterprise needs the bundle id the OTA manifest declares.
validate_app :: (a: App) -> !ValidationErr {
    if a.id.len == 0 { raise error.MissingField; }
    if a.slug.len == 0 { raise error.MissingField; }
    if a.display_name.len == 0 { raise error.MissingField; }
    if a.owner.len == 0 { raise error.MissingField; }
    try validate_slug(a.slug);
    if a.ios_mode == .testflight and a.testflight_url.len == 0 {
        raise error.TestflightUrlRequired;
    }
    if a.ios_mode == .enterprise and bundle_id_for(a, .ios).len == 0 {
        raise error.BundleIdRequired;
    }
    return;
}

// Release requires id, app_id, created_by; version goes through
// validate_version (empty -> EmptyVersion) and channel through
// validate_channel_name.
validate_release :: (r: Release) -> !ValidationErr {
    if r.id.len == 0 { raise error.MissingField; }
    if r.app_id.len == 0 { raise error.MissingField; }
    if r.created_by.len == 0 { raise error.MissingField; }
    try validate_version(r.version);
    try validate_channel_name(r.channel);
    return;
}

// Artifact requires id, app_id, release_id, filename, content_type,
// storage_key (non-empty -> MissingField); size_bytes must be positive
// (-> BadSize); sha256 must be a real digest (-> BadDigest via
// validate_sha256). platform and validation_status are enums, hence always
// well-formed. `metadata` is opaque and unchecked.
validate_artifact :: (a: Artifact) -> !ValidationErr {
    if a.id.len == 0 { raise error.MissingField; }
    if a.app_id.len == 0 { raise error.MissingField; }
    if a.release_id.len == 0 { raise error.MissingField; }
    if a.filename.len == 0 { raise error.MissingField; }
    if a.content_type.len == 0 { raise error.MissingField; }
    if a.storage_key.len == 0 { raise error.MissingField; }
    if a.size_bytes <= 0 { raise error.BadSize; }
    try validate_sha256(a.sha256);
    return;
}

// Channel requires app_id; name goes through validate_channel_name and
// rollout_percent must be in 0..100.
validate_channel :: (c: Channel) -> !ValidationErr {
    if c.app_id.len == 0 { raise error.MissingField; }
    try validate_channel_name(c.name);
    if c.rollout_percent < 0 or c.rollout_percent > 100 { raise error.BadRollout; }
    return;
}

// token name: non-empty; every byte in [a-z0-9._-] (e.g. "ci-main",
// "release.bot").
validate_token_name :: (s: string) -> !ValidationErr {
    if s.len == 0 { raise error.BadTokenName; }
    i := 0;
    while i < s.len {
        c := s[i];
        if !(is_lower(c) or is_digit(c) or c == 45 or c == 46 or c == 95) {  // - . _
            raise error.BadTokenName;
        }
        i += 1;
    }
    return;
}

// The closed scope vocabulary: `publish` writes releases, `read` only
// inspects. New scopes are added here, never free-form.
valid_scope_word :: (w: string) -> bool {
    return w == "publish" or w == "read";
}

// scopes: at least one word; every space-separated word in the known set.
validate_scopes :: (s: string) -> !ValidationErr {
    words := 0;
    i := 0;
    while i < s.len {
        while i < s.len and s[i] == 32 { i += 1; }   // 32 = ' '
        start := i;
        while i < s.len and s[i] != 32 { i += 1; }
        if i > start {
            w := string.{ ptr = @s[start], len = i - start };
            if !valid_scope_word(w) { raise error.BadScope; }
            words += 1;
        }
    }
    if words == 0 { raise error.BadScope; }
    return;
}

// Token requires id; name goes through validate_token_name, token_hash
// through validate_sha256 (the hash IS a sha256 digest), scopes through
// validate_scopes. The optional narrowing fields are validated only when
// present: app_slug as a slug, channel as a channel name.
validate_token :: (t: Token) -> !ValidationErr {
    if t.id.len == 0 { raise error.MissingField; }
    try validate_token_name(t.name);
    try validate_sha256(t.token_hash);
    try validate_scopes(t.scopes);
    if t.app_slug.len > 0 { try validate_slug(t.app_slug); }
    if t.channel.len > 0 { try validate_channel_name(t.channel); }
    return;
}
