//! Entity & value-object invariant tests: valid construction plus the expected
//! rejections (ARCHITECTURE §3.2).

mod helpers;

use domain::{
    Agent, AgentManifest, AgentOrigin, AgentProfile, AgentTemplate, ContextInjection, DomainError,
    ManifestEntry, MarkdownDoc, ProfileId, Project, ProjectPath, PtySize, RemoteRef,
    SessionStrategy, Skill, SkillId, SkillRef, SkillScope, SshAuth, TemplateId, TemplateVersion,
};
use helpers::{AtomicSeqIdGenerator, FixedClock};
use uuid::Uuid;

fn profile_id() -> ProfileId {
    ProfileId::from_uuid(Uuid::from_u128(42))
}

fn template_id() -> TemplateId {
    TemplateId::from_uuid(Uuid::from_u128(7))
}

// ---------------------------------------------------------------------------
// ProjectPath
// ---------------------------------------------------------------------------

#[test]
fn project_path_accepts_posix_absolute() {
    assert!(ProjectPath::new("/home/user/proj").is_ok());
}

#[test]
fn project_path_accepts_windows_drive_and_unc() {
    assert!(ProjectPath::new("C:\\Users\\x").is_ok());
    assert!(ProjectPath::new("C:/Users/x").is_ok());
    assert!(ProjectPath::new("\\\\server\\share").is_ok());
}

#[test]
fn project_path_accepts_wsl_mount() {
    assert!(ProjectPath::new("/mnt/c/code").is_ok());
}

#[test]
fn project_path_rejects_relative() {
    let err = ProjectPath::new("relative/path").unwrap_err();
    assert!(matches!(err, DomainError::PathNotAbsolute { .. }));
}

#[test]
fn project_path_rejects_empty() {
    let err = ProjectPath::new("").unwrap_err();
    assert!(matches!(err, DomainError::EmptyField { .. }));
}

// ---------------------------------------------------------------------------
// Project (also exercises the Clock/IdGenerator port fakes for determinism)
// ---------------------------------------------------------------------------

#[test]
fn project_valid_with_fixed_clock_and_seq_ids() {
    use domain::{ports::Clock, ports::IdGenerator, ProjectId};
    let clock = FixedClock(1_700_000_000_000);
    let ids = AtomicSeqIdGenerator::new();
    let id = ProjectId::from_uuid(ids.new_uuid());
    let p = Project::new(
        id,
        "demo",
        ProjectPath::new("/srv/demo").unwrap(),
        RemoteRef::local(),
        clock.now_millis(),
    )
    .unwrap();
    assert_eq!(p.created_at, 1_700_000_000_000);
    assert_eq!(p.id.as_uuid(), Uuid::from_u128(1));
}

#[test]
fn project_rejects_empty_name() {
    let err = Project::new(
        domain::ProjectId::from_uuid(Uuid::nil()),
        "   ",
        ProjectPath::new("/x").unwrap(),
        RemoteRef::local(),
        0,
    )
    .unwrap_err();
    assert!(matches!(err, DomainError::EmptyField { .. }));
}

// ---------------------------------------------------------------------------
// Agent invariants
// ---------------------------------------------------------------------------

#[test]
fn agent_scratch_not_synchronized_is_ok() {
    let a = Agent::new(
        domain::AgentId::from_uuid(Uuid::from_u128(1)),
        "scratch",
        "agents/foo.md",
        profile_id(),
        AgentOrigin::Scratch,
        false,
    );
    assert!(a.is_ok());
}

#[test]
fn agent_from_template_synchronized_is_ok() {
    let a = Agent::new(
        domain::AgentId::from_uuid(Uuid::from_u128(1)),
        "tpl",
        "agents/foo.md",
        profile_id(),
        AgentOrigin::FromTemplate {
            template_id: template_id(),
            synced_template_version: TemplateVersion::INITIAL,
        },
        true,
    );
    assert!(a.is_ok());
}

#[test]
fn agent_synchronized_without_template_is_rejected() {
    let err = Agent::new(
        domain::AgentId::from_uuid(Uuid::from_u128(1)),
        "bad",
        "agents/foo.md",
        profile_id(),
        AgentOrigin::Scratch,
        true,
    )
    .unwrap_err();
    assert_eq!(err, DomainError::SyncRequiresTemplate);
}

#[test]
fn agent_rejects_absolute_context_path() {
    let err = Agent::new(
        domain::AgentId::from_uuid(Uuid::from_u128(1)),
        "x",
        "/etc/passwd",
        profile_id(),
        AgentOrigin::Scratch,
        false,
    )
    .unwrap_err();
    assert!(matches!(err, DomainError::PathNotRelativeSafe { .. }));
}

#[test]
fn agent_rejects_dotdot_context_path() {
    let err = Agent::new(
        domain::AgentId::from_uuid(Uuid::from_u128(1)),
        "x",
        "agents/../../secret.md",
        profile_id(),
        AgentOrigin::Scratch,
        false,
    )
    .unwrap_err();
    assert!(matches!(err, DomainError::PathNotRelativeSafe { .. }));
}

// ---------------------------------------------------------------------------
// AgentProfile: command non-empty
// ---------------------------------------------------------------------------

fn ci_stdin() -> ContextInjection {
    ContextInjection::stdin()
}

#[test]
fn profile_valid() {
    let p = AgentProfile::new(
        profile_id(),
        "Claude",
        "claude",
        vec!["--yolo".into()],
        ci_stdin(),
        Some("claude --version".into()),
        "{projectRoot}",
        None,
    );
    assert!(p.is_ok());
}

#[test]
fn profile_rejects_empty_command() {
    let err = AgentProfile::new(
        profile_id(),
        "Name",
        "",
        vec![],
        ci_stdin(),
        None,
        "{projectRoot}",
        None,
    )
    .unwrap_err();
    assert!(matches!(err, DomainError::EmptyField { field } if field == "profile.command"));
}

#[test]
fn profile_rejects_empty_name() {
    let err = AgentProfile::new(
        profile_id(),
        "",
        "claude",
        vec![],
        ci_stdin(),
        None,
        "{r}",
        None,
    )
    .unwrap_err();
    assert!(matches!(err, DomainError::EmptyField { field } if field == "profile.name"));
}

#[test]
fn session_strategy_valid_with_assign_flag() {
    let s = SessionStrategy::new(Some("--session-id".into()), "--resume");
    assert!(s.is_ok());
}

#[test]
fn session_strategy_valid_without_assign_flag() {
    let s = SessionStrategy::new(None, "--continue").unwrap();
    assert_eq!(s.assign_flag, None);
    assert_eq!(s.resume_flag, "--continue");
}

#[test]
fn session_strategy_rejects_empty_resume_flag() {
    let err = SessionStrategy::new(Some("--session-id".into()), "").unwrap_err();
    assert!(matches!(err, DomainError::EmptyField { field } if field == "session.resumeFlag"));
}

#[test]
fn session_strategy_rejects_empty_assign_flag() {
    let err = SessionStrategy::new(Some(String::new()), "--resume").unwrap_err();
    assert!(matches!(err, DomainError::EmptyField { field } if field == "session.assignFlag"));
}

// ---------------------------------------------------------------------------
// RemoteRef invariants
// ---------------------------------------------------------------------------

#[test]
fn ssh_valid() {
    let r = RemoteRef::ssh("host", 22, "me", SshAuth::Agent, "/srv");
    assert!(r.is_ok());
    assert_eq!(r.unwrap().kind(), domain::RemoteKind::Ssh);
}

#[test]
fn ssh_port_zero_rejected() {
    let err = RemoteRef::ssh("host", 0, "me", SshAuth::Agent, "/srv").unwrap_err();
    assert!(matches!(err, DomainError::InvalidPort { port: 0 }));
}

#[test]
fn ssh_max_port_accepted() {
    // 65535 is the upper bound of the 1..=65535 range; u16 prevents anything higher.
    assert!(RemoteRef::ssh("h", 65535, "u", SshAuth::Password, "/r").is_ok());
}

#[test]
fn ssh_rejects_empty_host_user_root() {
    assert!(RemoteRef::ssh("", 22, "u", SshAuth::Agent, "/r").is_err());
    assert!(RemoteRef::ssh("h", 22, "", SshAuth::Agent, "/r").is_err());
    assert!(RemoteRef::ssh("h", 22, "u", SshAuth::Agent, "").is_err());
}

#[test]
fn wsl_valid() {
    assert!(RemoteRef::wsl("Ubuntu-22.04").is_ok());
}

#[test]
fn wsl_empty_distro_rejected() {
    let err = RemoteRef::wsl("").unwrap_err();
    assert!(matches!(err, DomainError::EmptyField { .. }));
}

// ---------------------------------------------------------------------------
// PtySize invariants
// ---------------------------------------------------------------------------

#[test]
fn pty_size_valid() {
    assert!(PtySize::new(24, 80).is_ok());
}

#[test]
fn pty_size_zero_rows_rejected() {
    assert!(matches!(
        PtySize::new(0, 80).unwrap_err(),
        DomainError::InvalidPtySize { rows: 0, cols: 80 }
    ));
}

#[test]
fn pty_size_zero_cols_rejected() {
    assert!(matches!(
        PtySize::new(24, 0).unwrap_err(),
        DomainError::InvalidPtySize { rows: 24, cols: 0 }
    ));
}

// ---------------------------------------------------------------------------
// AgentTemplate version monotonicity (via with_updated_content / next)
// ---------------------------------------------------------------------------

#[test]
fn template_starts_at_initial() {
    let t = AgentTemplate::new(template_id(), "T", MarkdownDoc::new("a"), profile_id()).unwrap();
    assert_eq!(t.version, TemplateVersion::INITIAL);
    assert_eq!(t.version.get(), 1);
}

#[test]
fn template_update_bumps_version_monotonically() {
    let t0 = AgentTemplate::new(template_id(), "T", MarkdownDoc::new("a"), profile_id()).unwrap();
    let t1 = t0.with_updated_content(MarkdownDoc::new("b"));
    let t2 = t1.with_updated_content(MarkdownDoc::new("c"));
    assert!(t1.version > t0.version);
    assert!(t2.version > t1.version);
    assert_eq!(t2.version.get(), 3);
    assert_eq!(t2.content_md.as_str(), "c");
    // id and profile preserved.
    assert_eq!(t2.id, t0.id);
    assert_eq!(t2.default_profile_id, t0.default_profile_id);
}

#[test]
fn template_version_next_increments() {
    assert_eq!(TemplateVersion(5).next(), TemplateVersion(6));
}

#[test]
fn template_rejects_empty_name() {
    let err =
        AgentTemplate::new(template_id(), "", MarkdownDoc::new(""), profile_id()).unwrap_err();
    assert!(matches!(err, DomainError::EmptyField { .. }));
}

// ---------------------------------------------------------------------------
// ManifestEntry / AgentManifest invariants
// ---------------------------------------------------------------------------

fn agent_id(n: u128) -> domain::AgentId {
    domain::AgentId::from_uuid(Uuid::from_u128(n))
}

#[test]
fn manifest_entry_synchronized_requires_template_metadata() {
    let err = ManifestEntry::new(
        agent_id(1),
        "A",
        "agents/a.md",
        profile_id(),
        None,
        true,
        None,
    )
    .unwrap_err();
    assert!(matches!(err, DomainError::InconsistentManifest { .. }));

    // template id present but version missing → still rejected.
    let err = ManifestEntry::new(
        agent_id(1),
        "A",
        "agents/a.md",
        profile_id(),
        Some(template_id()),
        true,
        None,
    )
    .unwrap_err();
    assert!(matches!(err, DomainError::InconsistentManifest { .. }));
}

#[test]
fn manifest_entry_rejects_empty_name() {
    let err = ManifestEntry::new(
        agent_id(1),
        "  ",
        "agents/a.md",
        profile_id(),
        None,
        false,
        None,
    )
    .unwrap_err();
    assert!(matches!(err, DomainError::EmptyField { .. }));
}

#[test]
fn manifest_entry_synchronized_with_metadata_ok() {
    assert!(ManifestEntry::new(
        agent_id(1),
        "A",
        "agents/a.md",
        profile_id(),
        Some(template_id()),
        true,
        Some(TemplateVersion::INITIAL)
    )
    .is_ok());
}

#[test]
fn manifest_entry_rejects_absolute_md_path() {
    assert!(matches!(
        ManifestEntry::new(agent_id(1), "A", "/abs.md", profile_id(), None, false, None)
            .unwrap_err(),
        DomainError::PathNotRelativeSafe { .. }
    ));
}

#[test]
fn manifest_entry_agent_roundtrip() {
    // from_agent ∘ to_agent preserves a template-backed, synchronized agent.
    let agent = Agent::new(
        agent_id(9),
        "Backend",
        "agents/backend.md",
        profile_id(),
        AgentOrigin::FromTemplate {
            template_id: template_id(),
            synced_template_version: TemplateVersion(4),
        },
        true,
    )
    .unwrap();
    let entry = ManifestEntry::from_agent(&agent);
    assert_eq!(entry.to_agent().unwrap(), agent);
}

#[test]
fn manifest_rejects_duplicate_md_path() {
    let e1 = ManifestEntry::new(
        agent_id(1),
        "A",
        "agents/a.md",
        profile_id(),
        None,
        false,
        None,
    )
    .unwrap();
    let e2 = ManifestEntry::new(
        agent_id(2),
        "B",
        "agents/a.md",
        profile_id(),
        None,
        false,
        None,
    )
    .unwrap();
    let err = AgentManifest::new(1, vec![e1, e2]).unwrap_err();
    assert!(matches!(err, DomainError::InconsistentManifest { .. }));
}

#[test]
fn manifest_unique_md_paths_ok() {
    let e1 = ManifestEntry::new(
        agent_id(1),
        "A",
        "agents/a.md",
        profile_id(),
        None,
        false,
        None,
    )
    .unwrap();
    let e2 = ManifestEntry::new(
        agent_id(2),
        "B",
        "agents/b.md",
        profile_id(),
        None,
        false,
        None,
    )
    .unwrap();
    assert!(AgentManifest::new(1, vec![e1, e2]).is_ok());
}

// ---------------------------------------------------------------------------
// Skill invariants (L12, ARCHITECTURE §14.2)
// ---------------------------------------------------------------------------

fn skill_id(n: u128) -> SkillId {
    SkillId::from_uuid(Uuid::from_u128(n))
}

#[test]
fn skill_valid_construction() {
    let s = Skill::new(
        skill_id(1),
        "code-review",
        MarkdownDoc::new("review the diff"),
        SkillScope::Global,
    );
    assert!(s.is_ok());
}

#[test]
fn skill_rejects_empty_name() {
    let err = Skill::new(
        skill_id(1),
        "",
        MarkdownDoc::new("body"),
        SkillScope::Project,
    )
    .unwrap_err();
    assert_eq!(
        err,
        DomainError::EmptyField {
            field: "skill.name"
        }
    );
}

#[test]
fn skill_rejects_empty_content() {
    let err = Skill::new(skill_id(1), "x", MarkdownDoc::new(""), SkillScope::Global).unwrap_err();
    assert_eq!(
        err,
        DomainError::EmptyField {
            field: "skill.content_md"
        }
    );
}

#[test]
fn skill_with_content_revalidates() {
    let s = Skill::new(skill_id(1), "x", MarkdownDoc::new("a"), SkillScope::Global).unwrap();
    assert!(s.with_content(MarkdownDoc::new("b")).is_ok());
    assert!(s.with_content(MarkdownDoc::new("")).is_err());
}

#[test]
fn agent_assign_skill_is_idempotent() {
    let mut a = Agent::new(
        agent_id(1),
        "dev",
        "agents/dev.md",
        profile_id(),
        AgentOrigin::Scratch,
        false,
    )
    .unwrap();
    let r = SkillRef::new(skill_id(7), SkillScope::Global);
    assert!(a.assign_skill(r)); // first assignment
    assert!(!a.assign_skill(r)); // duplicate ignored
    assert_eq!(a.skills, vec![r]);
}

#[test]
fn agent_unassign_skill() {
    let mut a = Agent::new(
        agent_id(1),
        "dev",
        "agents/dev.md",
        profile_id(),
        AgentOrigin::Scratch,
        false,
    )
    .unwrap();
    let r = SkillRef::new(skill_id(7), SkillScope::Project);
    a.assign_skill(r);
    assert!(a.unassign_skill(skill_id(7)));
    assert!(!a.unassign_skill(skill_id(7))); // already gone
    assert!(a.skills.is_empty());
}

#[test]
fn agent_with_skills_dedups() {
    let r = SkillRef::new(skill_id(7), SkillScope::Global);
    let a = Agent::new(
        agent_id(1),
        "dev",
        "agents/dev.md",
        profile_id(),
        AgentOrigin::Scratch,
        false,
    )
    .unwrap()
    .with_skills(vec![r, r]);
    assert_eq!(a.skills, vec![r]);
}

#[test]
fn manifest_entry_preserves_skills_through_agent_roundtrip() {
    let r = SkillRef::new(skill_id(7), SkillScope::Project);
    let agent = Agent::new(
        agent_id(1),
        "dev",
        "agents/dev.md",
        profile_id(),
        AgentOrigin::Scratch,
        false,
    )
    .unwrap()
    .with_skills(vec![r]);
    let entry = ManifestEntry::from_agent(&agent);
    assert_eq!(entry.skills, vec![r]);
    assert_eq!(entry.to_agent().unwrap(), agent);
}
