public class CustomField {

    private final PropertyStorage storage = new PropertyStorage();

    public final static Property<Integer> DATABASE_ID = new Property<>(Integer.class, "id");
    public final static Property<String> NAME = new Property<>(String.class, "name");
    public final static Property<String> VALUE = new Property<>(String.class, "value");
    public final static Property<Boolean> MULTIPLE = new Property<>(Boolean.class, "multiple");
    public final static Property<List<String>> VALUES = (Property<List<String>>) new Property(List.class, "values");

    public CustomField() {
        initCollections(storage);
    }

    /**
     * @param id database ID.
     */
    public CustomField setId(Integer id) {
        storage.set(DATABASE_ID, id);
        return this;
    }

    private void initCollections(PropertyStorage storage) {
        storage.set(VALUES, new ArrayList<>());
    }

    public Integer getId() {
        return storage.get(DATABASE_ID);
    }

    public String getName() {
        return storage.get(NAME);
    }

    public CustomField setName(String name) {
        storage.set(NAME, name);
        return this;
    }

    public String getValue() {
		return storage.get(VALUE);
    }

    public CustomField setValue(String value) {
        storage.set(VALUE, value);
		storage.set(VALUES, new ArrayList<>());
		storage.set(MULTIPLE, false);
        return this;
    }
    
	/**
	 * @return values list if this is a multi-line field, NULL otherwise.
	 */
	public List<String> getValues() {
		return storage.get(VALUES);
	}

	/**
     * @param values the values for multi-line custom field.
     */
	public CustomField setValues(List<String> values) {
		storage.set(VALUES, values);
		storage.set(VALUE, null);
        storage.set(MULTIPLE, true);
        return this;
	}

	/**
	 * @return the multiple
	 */
	public boolean isMultiple() {
		return storage.get(MULTIPLE);
	}

    @Override
    public boolean equals(Object o) {
        if (this == o) return true;
        if (o == null || getClass() != o.getClass()) return false;

        CustomField that = (CustomField) o;

        if (Objects.equals(getId(), that.getId())) {
            return true;
        }

        return false;
    }

    @Override
    public int hashCode() {
        return getId() != null ? getId().hashCode() : 0;
    }

    @Override
    public String toString() {
        return "CustomField{" + "id=" + getId() + ", name='" + getName() + '\''
				+ ", value='" + getValue() + '\'' + ", values=" + getValues() + '}';
    }
} public class CustomFieldDefinition {

    private Integer id;
    private String name;
    private String customizedType;
    private String fieldFormat;
    private String regexp;
    private Integer minLength;
    private Integer maxLength;
    private boolean required;
    private boolean filter;
    private boolean searchable;
    private boolean multiple;
    private String defaultValue;
    private boolean visible;
    private final List<String> possibleValues = new ArrayList<>();
    private final List<Tracker> trackers = new ArrayList<>();
    private final List<Role> roles = new ArrayList<>();

    public Integer getId() {
        return id;
    }

    /**
     * @param id database ID.
     */
    public CustomFieldDefinition setId(Integer id) {
        this.id = id;
        return this;
    }

    public String getName() {
        return name;
    }

    public void setName(String name) {
        this.name = name;
    }

    public String getCustomizedType() {
        return customizedType;
    }

    public void setCustomizedType(String customizedType) {
        this.customizedType = customizedType;
    }

    public String getFieldFormat() {
        return fieldFormat;
    }

    public void setFieldFormat(String fieldFormat) {
        this.fieldFormat = fieldFormat;
    }

    public String getRegexp() {
        return regexp;
    }

    public void setRegexp(String regexp) {
        this.regexp = regexp;
    }

    public Integer getMinLength() {
        return minLength;
    }

    public void setMinLength(Integer minLength) {
        this.minLength = minLength;
    }

    public Integer getMaxLength() {
        return maxLength;
    }

    public void setMaxLength(Integer maxLength) {
        this.maxLength = maxLength;
    }

    public boolean isRequired() {
        return required;
    }

    public void setRequired(boolean required) {
        this.required = required;
    }

    public boolean isFilter() {
        return filter;
    }

    public void setFilter(Boolean filter) {
        this.filter = filter;
    }

    public boolean isSearchable() {
        return searchable;
    }

    public void setSearchable(Boolean searchable) {
        this.searchable = searchable;
    }

    public boolean isMultiple() {
        return multiple;
    }

    public void setMultiple(Boolean multiple) {
        this.multiple = multiple;
    }

    public String getDefaultValue() {
        return defaultValue;
    }

    public void setDefaultValue(String defaultValue) {
        this.defaultValue = defaultValue;
    }

    public boolean isVisible() {
        return visible;
    }

    public void setVisible(Boolean visible) {
        this.visible = visible;
    }

    public List<String> getPossibleValues() {
        return possibleValues;
    }

    public List<Tracker> getTrackers() {
        return trackers;
    }

    public List<Role> getRoles() {
        return roles;
    }

    @Override
    public boolean equals(Object o) {
        if (this == o) return true;
        if (o == null || getClass() != o.getClass()) return false;

        CustomFieldDefinition that = (CustomFieldDefinition) o;

        if (id != null ? !id.equals(that.id) : that.id != null) return false;

        return true;
    }

    @Override
    public int hashCode() {
        return id != null ? id.hashCode() : 0;
    }

    @Override
    public String toString() {
        return "CustomFieldDefinition{" + "id=" + id + ", name=" + name +
                ", customizedType=" + customizedType + ", fieldFormat=" +
                fieldFormat + ", regexp=" + regexp + ", minLength=" + minLength +
                ", maxLength=" + maxLength + ", required=" + required +
                ", filter=" + filter + ", searchable=" + searchable +
                ", multiple=" + multiple + ", defaultValue=" + defaultValue +
                ", visible=" + visible + ", possibleValues=" + possibleValues +
                ", trackers=" + trackers + ", roles=" + roles + '}';
    }
} public class Group implements Identifiable, FluentStyle {

    private final PropertyStorage storage = new PropertyStorage();

    public final static Property<Integer> ID = new Property<Integer>(Integer.class, "id");
    public final static Property<String> NAME = new Property<String>(String.class, "name");
    private Transport transport;

    public Group(Transport transport) {
        setTransport(transport);
    }

    /**
     * @param id database ID of the group
     */
    public Group setId(Integer id) {
        storage.set(ID, id);
        return this;
    }

    @Override
    public Integer getId() {
        return storage.get(ID);
    }

    public String getName() {
        return storage.get(NAME);
    }

    public Group setName(String name) {
        storage.set(NAME, name);
        return this;
    }

    @Override
    public String toString() {
        return "Group{" +
                "id=" + getId() +
                ", name='" + getName() + '\'' +
                '}';
    }

    @Override
    public boolean equals(Object o) {
        if (this == o) return true;
        if (o == null || getClass() != o.getClass()) return false;

        Group group = (Group) o;

        if (getId() != null ? !getId().equals(group.getId()) : group.getId() != null) return false;
        return true;
    }

    @Override
    public int hashCode() {
        return getId() != null ? getId().hashCode() : 0;
    }

    public PropertyStorage getStorage() {
        return storage;
    }

    /**
     * Creates a new group.
     * <p><strong>This operation requires "Redmine Administrator" permission.</strong>
     *
     * @return created group.
     */
    public Group create() throws RedmineException {
        return transport.addObject(this);
    }

    public void update() throws RedmineException {
        transport.updateObject(this);
    }

    /**
     * Delete this group. The object must have Id set.
     * <p><strong>This operation requires "Redmine Administrator" permission.</strong>
     */
    public void delete() throws RedmineException {
        transport.deleteObject(Group.class, getId().toString());
    }

    @Override
    public void setTransport(Transport transport) {
        this.transport = transport;
    }
} public class Issue implements Identifiable, FluentStyle {

    private final PropertyStorage storage = new PropertyStorage();

    public final static Property<Integer> DATABASE_ID = new Property<>(Integer.class, "id");
    public final static Property<String> SUBJECT = new Property<>(String.class, "subject");
    public final static Property<Date> START_DATE = new Property<>(Date.class, "startDate");
    public final static Property<Date> DUE_DATE = new Property<>(Date.class, "dueDate");
    public final static Property<Date> CREATED_ON = new Property<>(Date.class, "createdOn");
    public final static Property<Date> UPDATED_ON = new Property<>(Date.class, "updatedOn");
    public final static Property<Integer> DONE_RATIO = new Property<>(Integer.class, "doneRatio");
    public final static Property<Integer> PARENT_ID = new Property<>(Integer.class, "parentId");
    public final static Property<Integer> PRIORITY_ID = new Property<>(Integer.class, "priorityId");
    public final static Property<Float> ESTIMATED_HOURS = new Property<>(Float.class, "estimatedHours");
    public final static Property<Float> SPENT_HOURS = new Property<>(Float.class, "spentHours");
    public final static Property<Integer> ASSIGNEE_ID = new Property<>(Integer.class, "assigneeId");
    public final static Property<String> ASSIGNEE_NAME = new Property<>(String.class, "assigneeName");

    /**
     * Some comment describing an issue update.
     */
    public final static Property<String> NOTES = new Property<String>(String.class, "notes");
    public final static Property<Boolean> PRIVATE_NOTES = new Property<>(Boolean.class, "notes");
    public final static Property<String> PRIORITY_TEXT = new Property<>(String.class, "priorityText");
    public final static Property<Integer> PROJECT_ID = new Property<>(Integer.class, "projectId");
    public final static Property<String> PROJECT_NAME = new Property<>(String.class, "projectName");
    public final static Property<Integer> AUTHOR_ID = new Property<>(Integer.class, "authorId");
    public final static Property<String> AUTHOR_NAME = new Property<>(String.class, "authorName");
    public final static Property<Tracker> TRACKER = new Property<>(Tracker.class, "tracker");
    public final static Property<String> DESCRIPTION = new Property<>(String.class, "description");
    public final static Property<Date> CLOSED_ON = new Property<>(Date.class, "closedOn");
    public final static Property<Integer> STATUS_ID = new Property<>(Integer.class, "statusId");
    public final static Property<String> STATUS_NAME = new Property<>(String.class, "statusName");
    public final static Property<Version> TARGET_VERSION = new Property<>(Version.class, "targetVersion");
    public final static Property<IssueCategory> ISSUE_CATEGORY = new Property<>(IssueCategory.class, "issueCategory");
    public final static Property<Boolean> PRIVATE_ISSUE = new Property<>(Boolean.class, "privateIssue");

    /**
     * can't have two custom fields with the same ID in the collection, that's why it is declared
     * as a Set, not a List.
     */
    public final static Property<Set<CustomField>> CUSTOM_FIELDS = (Property<Set<CustomField>>) new Property(Set.class, "customFields");
    public final static Property<Set<Journal>> JOURNALS = (Property<Set<Journal>>) new Property(Set.class, "journals");
    public final static Property<Set<IssueRelation>> RELATIONS = (Property<Set<IssueRelation>>) new Property(Set.class, "relations");
    public final static Property<Set<Attachment>> ATTACHMENTS = (Property<Set<Attachment>>) new Property(Set.class, "attachments");
    public final static Property<Set<Changeset>> CHANGESETS = (Property<Set<Changeset>>) new Property(Set.class, "changesets");
    public final static Property<Set<Watcher>> WATCHERS = (Property<Set<Watcher>>) new Property(Set.class, "watchers");
    public final static Property<Set<Issue>> CHILDREN = (Property<Set<Issue>>) new Property(Set.class, "children");

    private Transport transport;

    public Issue() {
        initCollections(storage);
    }

    public Issue(Transport transport) {
        this();
        setTransport(transport);
    }

    /**
     * Each Issue object must have project Id set in order for Redmine 3.x to accept it via REST API.
     */
    public Issue(Transport transport, int projectId) {
        this();
        this.transport = transport;
        setProjectId(projectId);
    }

    /**
     * @param projectId Each Issue object must have project Id set in order for Redmine 3.x to accept it via REST API.
     */
    public Issue(Transport transport, int projectId, String subject) {
        this();
        this.transport = transport;
        setSubject(subject);
        setProjectId(projectId);
    }

    private void initCollections(PropertyStorage storage) {
        storage.set(CUSTOM_FIELDS, new HashSet<>());
        storage.set(CHILDREN, new HashSet<>());
        storage.set(WATCHERS, new HashSet<>());
        storage.set(CHANGESETS, new HashSet<>());
        storage.set(ATTACHMENTS, new HashSet<>());
        storage.set(RELATIONS, new HashSet<>());
        storage.set(JOURNALS, new HashSet<>());
    }

    public Integer getProjectId() {
        return storage.get(PROJECT_ID);
    }

    public Issue setProjectId(Integer projectId) {
        storage.set(PROJECT_ID, projectId);
        return this;
    }

    public String getProjectName() {
        return storage.get(PROJECT_NAME);
    }


    public Issue setProjectName(String name) {
        storage.set(PROJECT_NAME, name);
        return this;
    }

    /**
     * @param id database ID.
     */
    public Issue setId(Integer id) {
        storage.set(DATABASE_ID, id);
        return this;
    }

    public Integer getDoneRatio() {
        return storage.get(DONE_RATIO);
    }

    public Issue setDoneRatio(Integer doneRatio) {
        storage.set(DONE_RATIO, doneRatio);
        return this;
    }

    public String getPriorityText() {
        return storage.get(PRIORITY_TEXT);
    }

    /**
     * @deprecated This method has no effect when creating issues on Redmine Server, so we might as well just delete it
     * in the future releases.
     */
    public void setPriorityText(String priority) {
        storage.set(PRIORITY_TEXT, priority);
    }

    /**
     * Redmine can be configured to allow group assignments for issues:
     * Configuration option: Settings -> Issue Tracking -> Allow issue assignment to groups
     *
     * <p>An assignee can be a user or a group</p>
     */
    public Integer getAssigneeId() {
        return storage.get(ASSIGNEE_ID);
    }

    public Issue setAssigneeId(Integer assigneeId) {
        storage.set(ASSIGNEE_ID, assigneeId);
        return this;
    }

    public String getAssigneeName() {
        return storage.get(ASSIGNEE_NAME);
    }

    public Issue setAssigneeName(String assigneeName) {
        storage.set(ASSIGNEE_NAME, assigneeName);
        return this;
    }

    public Float getEstimatedHours() {
        return storage.get(ESTIMATED_HOURS);
    }

    public Issue setEstimatedHours(Float estimatedTime) {
        storage.set(ESTIMATED_HOURS, estimatedTime);
        return this;
    }

    public Float getSpentHours() {
        return storage.get(SPENT_HOURS);
    }

    public Issue setSpentHours(Float spentHours) {
        storage.set(SPENT_HOURS, spentHours);
        return this;
    }

  /**
     * Parent Issue ID, or NULL for issues without a parent.
     *
     * @return NULL, if there's no parent
     */
    public Integer getParentId() {
        return storage.get(PARENT_ID);
    }

    public Issue setParentId(Integer parentId) {
        storage.set(PARENT_ID, parentId);
        return this;
    }

    /**
     * @return database id for this object. can be NULL for Issues not added to Redmine yet
     */
    @Override
    public Integer getId() {
        return storage.get(DATABASE_ID);
    }

    public String getSubject() {
        return storage.get(SUBJECT);
    }

    public Issue setSubject(String subject) {
        storage.set(SUBJECT, subject);
        return this;
    }

    public Date getStartDate() {
        return storage.get(START_DATE);
    }

    public Issue setStartDate(Date startDate) {
        storage.set(START_DATE, startDate);
        return this;
    }

    public Date getDueDate() {
        return storage.get(DUE_DATE);
    }

    public Issue setDueDate(Date dueDate) {
        storage.set(DUE_DATE, dueDate);
        return this;
    }

    public Integer getAuthorId() {
        return storage.get(AUTHOR_ID);
    }

    /**
     * Marking as "deprecated": according to Redmine REST API docs
     * https://www.redmine.org/projects/redmine/wiki/Rest_Issues#Creating-an-issue , this parameter is not used
     * when creating issues (January 2020).
     */
    @Deprecated
    public Issue setAuthorId(Integer id) {
        storage.set(AUTHOR_ID, id);
        return this;
    }

    public String getAuthorName() {
        return storage.get(AUTHOR_NAME);
    }

    /**
     * Marking as "deprecated": according to Redmine REST API docs
     * https://www.redmine.org/projects/redmine/wiki/Rest_Issues#Creating-an-issue , this parameter is not used
     * when creating issues (January 2020).
     */
    @Deprecated
    public Issue setAuthorName(String name) {
        storage.set(AUTHOR_NAME, name);
        return this;
    }

    public Tracker getTracker() {
        return storage.get(TRACKER);
    }

    public Issue setTracker(Tracker tracker) {
        storage.set(TRACKER, tracker);
        return this;
    }

    public String getDescription() {
        return storage.get(DESCRIPTION);
    }

    public Issue setDescription(String description) {
        storage.set(DESCRIPTION, description);
        return this;
    }

    public Date getCreatedOn() {
        return storage.get(CREATED_ON);
    }

    public Issue setCreatedOn(Date createdOn) {
        storage.set(CREATED_ON, createdOn);
        return this;
    }

    public Date getUpdatedOn() {
        return storage.get(UPDATED_ON);
    }

    public Issue setUpdatedOn(Date updatedOn) {
        storage.set(UPDATED_ON, updatedOn);
        return this;
    }

    public Date getClosedOn() {
        return storage.get(CLOSED_ON);
    }

    public Issue setClosedOn(Date closedOn) {
        storage.set(CLOSED_ON, closedOn);
        return this;
    }

    public Integer getStatusId() {
        return storage.get(STATUS_ID);
    }

    public Issue setStatusId(Integer statusId) {
        storage.set(STATUS_ID, statusId);
        return this;
    }

    public String getStatusName() {
        return storage.get(STATUS_NAME);
    }

    public Issue setStatusName(String statusName) {
        storage.set(STATUS_NAME, statusName);
        return this;
    }

    /**
     * @return unmodifiable collection of Custom Field objects. the collection may be empty, but it is never NULL.
     */
    public Collection<CustomField> getCustomFields() {
        return Collections.unmodifiableCollection(storage.get(CUSTOM_FIELDS));
    }

    public Issue clearCustomFields() {
        storage.set(CUSTOM_FIELDS, new HashSet<>());
        return this;
    }

    /**
     * NOTE: The custom field(s) <strong>must have correct database ID set</strong> to be saved to Redmine.
     * This is Redmine REST API's requirement.
     */
    public Issue addCustomFields(Collection<CustomField> customFields) {
        storage.get(CUSTOM_FIELDS).addAll(customFields);
        return this;
    }

    /**
     * If there is a custom field with the same ID already present in the Issue,
     * the new field replaces the old one.
     *
     * @param customField the field to add to the issue.
     */
    public Issue addCustomField(CustomField customField) {
        storage.get(CUSTOM_FIELDS).add(customField);
        return this;
    }

    @Deprecated
    /**
     * This method should not be used by clients. "notes" only makes sense when creating/updating an issue - that is the
     * string value added along with the update.
     * <p>
     * use {@link #getJournals()} if you want to access previously saved notes. feel free to submit an enhancement
     * request to Redmine developers if you think this "notes - journals" separation looks weird...
     */
    public String getNotes() {
        return storage.get(NOTES);
    }

    /**
     * @param notes Some comment describing the issue update
     */
    public Issue setNotes(String notes) {
        storage.set(NOTES, notes);
        return this;
    }

    public boolean isPrivateNotes() {
        return storage.get(PRIVATE_NOTES);
    }

    /**
     * @param privateNotes mark note as private
     */
    public Issue setPrivateNotes(boolean privateNotes) {
        storage.set(PRIVATE_NOTES, privateNotes);
        return this;
    }

    /**
     * Don't forget to use Include.journals flag when loading issue from Redmine server:
     * <pre>
     *     Issue issue = issueManager.getIssueById(3205, Include.journals);
     * </pre>
     * @return unmodifiable collection of Journal entries or empty collection if no objects found. Never NULL.
     * @see com.taskadapter.redmineapi.Include#journals
     */
    public Collection<Journal> getJournals() {
        return Collections.unmodifiableCollection(storage.get(JOURNALS));
    }

    /**
     * Issue journals are created automatically when you update existing issues.
     * journal entries are essentially log records for changes you make.
     * you cannot just add log records without making actual changes.
     * this API method is misleading and it should only be used internally by Redmine Json parser
     * when parsing response from server. we should hide it from public.
     *
     * TODO hide this method. https://github.com/taskadapter/redmine-java-api/issues/199
     */
    public void addJournals(Collection<Journal> journals) {
        storage.get(JOURNALS).addAll(journals);
    }

    /**
     * Don't forget to use Include.changesets flag when loading issue from Redmine server:
     * <pre>
     *     Issue issue = issueManager.getIssueById(3205, Include.changesets);
     * </pre>
     * @return unmodifiable collection of entries or empty collection if no objects found.
     * @see com.taskadapter.redmineapi.Include#changesets
     */
    public Collection<Changeset> getChangesets() {
        return Collections.unmodifiableCollection(storage.get(CHANGESETS));
    }

    public Issue addChangesets(Collection<Changeset> changesets) {
        storage.get(CHANGESETS).addAll(changesets);
        return this;
    }

    /**
     * Don't forget to use Include.watchers flag when loading issue from Redmine server:
     * <pre>
     *     Issue issue = issueManager.getIssueById(3205, Include.watchers);
     * </pre>
     * @return unmodifiable collection of entries or empty collection if no objects found.
     * @see com.taskadapter.redmineapi.Include#watchers
     */
    public Collection<Watcher> getWatchers() {
        return Collections.unmodifiableCollection(storage.get(WATCHERS));
    }

    public Issue addWatchers(Collection<Watcher> watchers) {
        storage.get(WATCHERS).addAll(watchers);
        return this;
    }

    /**
      * Don't forget to use Include.children flag when loading issue from Redmine server:
      * <pre>
      *     Issue issue = issueManager.getIssueById(3205, Include.children);
      * </pre>
      * @return Collection of entries or empty collection if no objects found.
      * @see com.taskadapter.redmineapi.Include#children
      */
    public Collection<Issue> getChildren() {
        return Collections.unmodifiableCollection(storage.get(CHILDREN));
    }

    public Issue addChildren(Collection<Issue> children) {
        storage.get(CHILDREN).addAll(children);
        return this;
    }

    /**
     * Issues are considered equal if their IDs are equal. what about two issues with null ids?
     */
    @Override
    public boolean equals(Object o) {
        if (this == o) return true;
        if (o == null || getClass() != o.getClass()) return false;

        Issue issue = (Issue) o;

        if (getId() != null ? !getId().equals(issue.getId()) : issue.getId() != null) return false;

        return true;
    }

    @Override
    public int hashCode() {
        return getId() != null ? getId().hashCode() : 0;
    }

    /**
     * @return the custom field with given Id or NULL if the field is not found
     */
    public CustomField getCustomFieldById(int customFieldId) {
        for (CustomField customField : storage.get(CUSTOM_FIELDS)) {
            if (customFieldId == customField.getId()) {
                return customField;
            }
        }
        return null;
    }

    /**
     * @return the custom field with given name or NULL if the field is not found
     */
    public CustomField getCustomFieldByName(String customFieldName) {
        for (CustomField customField : storage.get(CUSTOM_FIELDS)) {
            if (customFieldName.equals(customField.getName())) {
                return customField;
            }
        }
        return null;
    }


    @Override
    public String toString() {
        return "Issue [id=" + getId() + ", subject=" + getSubject() + "]";
    }

    /**
     * Relations are only loaded if you include Include.relations when loading the Issue.
     * <pre>
     *     Issue issue = issueManager.getIssueById(3205, Include.relations);
     * </pre>
     * <p>Since the returned collection is not modifiable, you need to use addRelations() method
     * if you want to add elements, e.g.:
     * <pre>
     *     issue.addRelations(Collections.singletonList(relation));
     * </pre>
     * @return unmodifiable collection of Relations or EMPTY collection if none found. Never returns NULL.
     * @see com.taskadapter.redmineapi.Include#relations
     */
    public Collection<IssueRelation> getRelations() {
        return Collections.unmodifiableCollection(storage.get(RELATIONS));
    }

    public Issue addRelations(Collection<IssueRelation> collection) {
        storage.get(RELATIONS).addAll(collection);
        return this;
    }

    public Integer getPriorityId() {
        return storage.get(PRIORITY_ID);
    }

    public Issue setPriorityId(Integer priorityId) {
        storage.set(PRIORITY_ID, priorityId);
        return this;
    }

    public Version getTargetVersion() {
        return storage.get(TARGET_VERSION);
    }

    /**
     * Don't forget to use <i>Include.attachments</i> flag when loading issue from Redmine server:
     * <pre>
     *     Issue issue = issueManager.getIssueById(3205, Include.attachments);
     * </pre>
     * @return unmodifiable collection of entries or empty collection if no objects found.
     * @see com.taskadapter.redmineapi.Include#attachments
     */
    public Collection<Attachment> getAttachments() {
        return Collections.unmodifiableCollection(storage.get(ATTACHMENTS));
    }

    public Issue addAttachments(Collection<Attachment> collection) {
        storage.get(ATTACHMENTS).addAll(collection);
        return this;
    }

    public Issue addAttachment(Attachment attachment) {
        storage.get(ATTACHMENTS).add(attachment);
        return this;
    }

    public Issue setTargetVersion(Version version) {
        storage.set(TARGET_VERSION, version);
        return this;
    }

    public IssueCategory getCategory() {
        return storage.get(ISSUE_CATEGORY);
    }

    public Issue setCategory(IssueCategory category) {
        storage.set(ISSUE_CATEGORY, category);
        return this;
    }

    /**
     * Default value is not determines. it's up to the server what it thinks the default value is if not set.
     */
    public boolean isPrivateIssue() {
        return storage.get(PRIVATE_ISSUE);
    }

    public Issue setPrivateIssue(boolean privateIssue) {
        storage.set(PRIVATE_ISSUE, privateIssue);
        return this;
    }

    public PropertyStorage getStorage() {
        return storage;
    }

    /**
     * @return the newly created Issue.
     *
     * @throws RedmineAuthenticationException invalid or no API access key is used with the server, which
     *                                 requires authorization. Check the constructor arguments.
     * @throws NotFoundException       the required project is not found
     * @throws RedmineException
     */
    public Issue create(RequestParam... params) throws RedmineException {
        RequestParam[] enrichParams = Arrays.copyOf(params, params.length + 1);
        enrichParams[params.length] = new RequestParam("include",
                Include.attachments.toString());
        return transport.addObject(this, enrichParams);
    }

    public void update(RequestParam... params) throws RedmineException {
        transport.updateObject(this, params);
    }

    public void delete() throws RedmineException {
        transport.deleteObject(Issue.class, Integer.toString(this.getId()));
    }

    public void addWatcher(int watcherId) throws RedmineException {
        transport.addWatcherToIssue(watcherId, getId());
    }

    public void deleteWatcher(int watcherId) throws RedmineException {
        transport.deleteChildId(Issue.class, Integer.toString(getId()), new Watcher(), watcherId);
    }

    @Override
    public void setTransport(Transport transport) {
        this.transport = transport;
        PropertyStorageUtil.updateCollections(storage, transport);
    }
} public class IssueCategory implements Identifiable, Serializable, FluentStyle {
	private static final long serialVersionUID = -109010410391968475L;

    private final PropertyStorage storage = new PropertyStorage();

    /**
     * database numeric ID.
     */
    public final static Property<Integer> DATABASE_ID = new Property<>(Integer.class, "id");
    public final static Property<String> NAME = new Property<>(String.class, "name");
    public final static Property<Integer> PROJECT_ID = new Property<>(Integer.class, "projectId");
    public final static Property<Integer> ASSIGNEE_ID = new Property<>(Integer.class, "assigneeId");
    public final static Property<String> ASSIGNEE_NAME = new Property<>(String.class, "assigneeName");
    private Transport transport;

    public IssueCategory(Transport transport) {
        setTransport(transport);
    }

    public IssueCategory(Transport transport, Integer projectId, String name) {
        setTransport(transport);
        setProjectId(projectId);
        setName(name);
    }

    public Integer getId() {
        return storage.get(DATABASE_ID);
    }

    public String getName() {
        return storage.get(NAME);
    }

    public IssueCategory setName(String name) {
        storage.set(NAME, name);
        return this;
    }

    /**
     * @param id database Id of the issue category.
     */
    public IssueCategory setId(Integer id) {
        storage.set(DATABASE_ID, id);
        return this;
    }

    public Integer getProjectId() {
        return storage.get(PROJECT_ID);
    }

    public IssueCategory setProjectId(Integer projectId) {
        storage.set(PROJECT_ID, projectId);
        return this;
    }

    /**
     * Redmine can be configured to allow group assignments for issues:
     * Configuration option: Settings -> Issue Tracking -> Allow issue assignment to groups
     *
     * <p>An assignee can be a user or a group</p>
     */
    public Integer getAssigneeId() {
        return storage.get(ASSIGNEE_ID);
    }

    public IssueCategory setAssigneeId(Integer assigneeId) {
        storage.set(ASSIGNEE_ID, assigneeId);
        return this;
    }

    public String getAssigneeName() {
        return storage.get(ASSIGNEE_NAME);
    }

    public void setAssigneeName(String assigneeName) {
        storage.set(ASSIGNEE_NAME, assigneeName);
    }

    @Override
    public boolean equals(Object o) {
        if (this == o) return true;
        if (o == null || getClass() != o.getClass()) return false;

        IssueCategory that = (IssueCategory) o;

        if (getId() != null ? !getId().equals(that.getId()) : that.getId() != null) return false;

        return true;
    }

    @Override
    public int hashCode() {
        return getId() != null ? getId().hashCode() : 0;
    }

    @Override
    public String toString() {
        return "IssueCategory{" +
                "id=" + getId() +
                ", name='" + getName() + '\'' +
                ", projectId=" + getProjectId() +
                ", assigneeId=" + getAssigneeId() +
                ", assigneeName='" + getAssigneeName() + '\'' +
                '}';
    }

    public PropertyStorage getStorage() {
        return storage;
    }

    @Override
    public void setTransport(Transport transport) {
        this.transport = transport;
    }

    /**
     * creates a new {@link IssueCategory} for the {@link Project} contained. <br>
     * Pre-condition: the attribute {@link Project} for the {@link IssueCategory} must
     * not be null!
     *
     * @return the new {@link IssueCategory} created by Redmine
     * @throws IllegalArgumentException       thrown in case the category does not contain a project.
     * @throws RedmineAuthenticationException thrown in case something went wrong while trying to login
     * @throws RedmineException               thrown in case something went wrong in Redmine
     * @throws NotFoundException              thrown in case an object can not be found
     */
    public IssueCategory create() throws RedmineException {
        if (getProjectId() == null) {
            throw new IllegalArgumentException(
                    "IssueCategory must contain projectId");
        }

        return transport.addChildEntry(Project.class, getProjectId().toString(), this);
    }

    public void delete() throws RedmineException {
        transport.deleteObject(IssueCategory.class, Integer.toString(this.getId()));
    }
} public class IssueRelation implements Identifiable, FluentStyle {
    private Transport transport;

    public enum TYPE {
        precedes
    }

    /*
     GET /relations/1819.xml
     Response:

     <?xml version="1.0" encoding="UTF-8"?>
     <relation>
       <id>1819</id>
       <issue_id>8470</issue_id>
       <issue_to_id>8469</issue_to_id>
       <relation_type>relates</relation_type>
       <delay/>
     </relation>
     */

    private final PropertyStorage storage = new PropertyStorage();

    /**
     * database numeric Id
     */
    public final static Property<Integer> DATABASE_ID = new Property<>(Integer.class, "id");
    public final static Property<Integer> ISSUE_ID = new Property<>(Integer.class, "issueId");
    public final static Property<List<Integer>> ISSUE_TO_ID = new Property(List.class, "issueToId");

    public final static Property<String> RELATION_TYPE = new Property<>(String.class, "relationType");
    public final static Property<Integer> DELAY = new Property<>(Integer.class, "delay");

    private IssueRelation() {
        storage.set(ISSUE_TO_ID, new ArrayList<>());
    }

    public IssueRelation(Transport transport) {
        this();
        setTransport(transport);
    }

    public IssueRelation(Transport transport, Integer issueId, Integer issueToId, String type) {
        this();
        setTransport(transport);
        setIssueId(issueId);
        addIssueToId(issueToId);
        setType(type);
    }

    public IssueRelation setId(Integer id) {
        storage.set(DATABASE_ID, id);
        return this;
    }

    @Override
    public Integer getId() {
        return storage.get(DATABASE_ID);
    }

    public Integer getIssueId() {
        return storage.get(ISSUE_ID);
    }

    public IssueRelation setIssueId(Integer issueId) {
        storage.set(ISSUE_ID, issueId);
        return this;
    }

    public Integer getIssueToId() {
        return storage.get(ISSUE_TO_ID)
                .stream()
                .findFirst().orElse(null);
    }

    public IssueRelation addIssueToId(Integer issueToId) {
        storage.get(ISSUE_TO_ID).add(issueToId);
        return this;
    }

    public Integer getDelay() {
        return storage.get(DELAY);
    }

    public IssueRelation setDelay(Integer delay) {
        storage.set(DELAY, delay);
        return this;
    }

    public String getType() {
        return storage.get(RELATION_TYPE);
    }

    public IssueRelation setType(String type) {
        storage.set(RELATION_TYPE, type);
        return this;
    }

    @Override
    public boolean equals(Object o) {
        if (this == o) return true;
        if (o == null || getClass() != o.getClass()) return false;

        IssueRelation that = (IssueRelation) o;

        if (getId() != null ? !getId().equals(that.getId()) : that.getId() != null) return false;

        return true;
    }

    @Override
    public int hashCode() {
        return getId() != null ? getId().hashCode() : 0;
    }

    @Override
    public String toString() {
        return "IssueRelation [getId()=" + getId() + ", issueId=" + getIssueId()
                + ", issueToId=" + getIssueToId() + ", type=" + getType() + ", delay="
                + getDelay() + "]";
    }

    public PropertyStorage getStorage() {
        return storage;
    }

    @Override
    public void setTransport(Transport transport) {
        this.transport = transport;
    }

    /**
     * Each relation must have issueId, issueToId and type set.
     */
    public IssueRelation create() throws RedmineException {
        return transport.addChildEntry(Issue.class, getIssueId().toString(), this);
    }

    public void delete() throws RedmineException {
        transport.deleteObject(IssueRelation.class, Integer.toString(getId()));
    }

} public class IssueStatus implements Identifiable, Serializable {
	private static final long serialVersionUID = -2221390098554222099L;

	private Integer id;
    private String name;
    private boolean defaultStatus = false;
    private boolean closed = false;

    public IssueStatus setId(Integer id) {
        this.id = id;
        return this;
    }

    public Integer getId() {
        return id;
    }

    public String getName() {
        return name;
    }

    public IssueStatus setName(String name) {
        this.name = name;
        return this;
    }

    public boolean isDefaultStatus() {
        return defaultStatus;
    }

    public void setDefaultStatus(boolean defaultStatus) {
        this.defaultStatus = defaultStatus;
    }

    public boolean isClosed() {
        return closed;
    }

    public void setClosed(boolean closed) {
        this.closed = closed;
    }

    @Override
    public boolean equals(Object o) {
        if (this == o) return true;
        if (o == null || getClass() != o.getClass()) return false;

        IssueStatus that = (IssueStatus) o;

        if (id != null ? !id.equals(that.id) : that.id != null) return false;

        return true;
    }

    @Override
    public int hashCode() {
        return id != null ? id.hashCode() : 0;
    }

    @Override
    public String toString() {
        return "Status [id=" + id + ", name=" + name + ", isDefault=" + defaultStatus + ", closed=" + closed + "]";
    }

} public class Journal {

    private final PropertyStorage storage = new PropertyStorage();

    /**
     * database numeric ID.
     */
    public final static Property<Integer> DATABASE_ID = new Property<>(Integer.class, "id");
    public final static Property<String> NOTES = new Property<String>(String.class, "notes");
    public final static Property<User> USER = new Property<>(User.class, "user");
    public final static Property<Date> CREATED_ON = new Property<>(Date.class, "createdOn");
    public final static Property<List<JournalDetail>> DETAILS = (Property<List<JournalDetail>>) new Property(List.class, "details");

    public Journal() {
        storage.set(DETAILS, new ArrayList<>());
    }

    /**
     * @param id database ID.
     */
    public Journal setId(Integer id) {
        storage.set(DATABASE_ID, id);
        return this;
    }

    public Date getCreatedOn() {
        return storage.get(CREATED_ON);
    }

    public void setCreatedOn(Date createdOn) {
        storage.set(CREATED_ON, createdOn);
    }

    public Integer getId() {
        return storage.get(DATABASE_ID);
    }

    public String getNotes() {
        return storage.get(NOTES);
    }

    public void setNotes(String notes) {
        storage.set(NOTES, notes);
    }

    public User getUser() {
        return storage.get(USER);
    }

    public void setUser(User user) {
        storage.set(USER, user);
    }

    public List<JournalDetail> getDetails() {
        return Collections.unmodifiableList(storage.get(DETAILS));
    }

    public void addDetails(Collection<JournalDetail> details) {
        storage.get(DETAILS).addAll(details);
    }

    @Override
    public boolean equals(Object o) {
        if (this == o) return true;
        if (o == null || getClass() != o.getClass()) return false;

        Journal journal = (Journal) o;

        return getId() != null ? getId().equals(journal.getId()) : journal.getId() == null;

    }

    @Override
    public int hashCode() {
        return getId() != null ? getId().hashCode() : 0;
    }

    @Override
    public String toString() {
        return "Journal{" + "id=" + getId() + " notes=" + getNotes() + " user=" + getUser() + " createdOn=" + getCreatedOn() + " details=" + getDetails() + '}';
    }

} public class JournalDetail implements Serializable {
    private static final long serialVersionUID = -9170064829669555245L;

    private String newValue;
    private String name;
    private String property;
    private String oldValue;

    public String getNewValue() {
        return newValue;
    }

    public void setNewValue(String newValue) {
        this.newValue = newValue;
    }

    public String getName() {
        return name;
    }

    public void setName(String name) {
        this.name = name;
    }

    public String getProperty() {
        return property;
    }

    public void setProperty(String property) {
        this.property = property;
    }

    public String getOldValue() {
        return oldValue;
    }

    public void setOldValue(String oldValue) {
        this.oldValue = oldValue;
    }

    @Override
    public boolean equals(Object o) {
        if (this == o) return true;
        if (o == null || getClass() != o.getClass()) return false;

        JournalDetail that = (JournalDetail) o;

        if (newValue != null ? !newValue.equals(that.newValue) : that.newValue != null) return false;

        return true;
    }

    @Override
    public int hashCode() {
        return newValue != null ? newValue.hashCode() : 0;
    }

    @Override
    public String toString() {
        return "JournalDetail [newValue=" + newValue + ", name=" + name
                + ", property=" + property + ", oldValue=" + oldValue + "]";
    }
} public class Membership implements Identifiable, FluentStyle {

	private final PropertyStorage storage= new PropertyStorage();

	/**
	 * database numeric ID.
	 */
	public final static Property<Integer> DATABASE_ID = new Property<>(Integer.class, "id");
	public final static Property<Project> PROJECT = new Property<>(Project.class, "project");
	/**
	 * User. Not set for "group" membership.
	 */
	public final static Property<Integer> USER_ID = new Property<>(Integer.class, "userId");
        public final static Property<String> USER_NAME = new Property<>(String.class, "userName");
        /**
         * Group. Not set for "user" membership.
         */
	public final static Property<Integer> GROUP_ID = new Property<>(Integer.class, "groupId");
        public final static Property<String> GROUP_NAME = new Property<>(String.class, "groupName");
	public final static Property<Set<Role>> ROLES = (Property<Set<Role>>) new Property(Set.class, "roles");
	private Transport transport;

	public Membership(Transport transport) {
		storage.set(ROLES, new HashSet<>());
		setTransport(transport);
	}

	public Membership(Transport transport, Project project, int userId) {
		this(transport);
		setProject(project);
		setUserId(userId);
	}

    /**
     * @param id database ID.
     */
    public Membership setId(Integer id) {
        storage.set(DATABASE_ID, id);
        return this;
    }

    @Override
	public Integer getId() {
		return storage.get(DATABASE_ID);
	}

	public Project getProject() {
		return storage.get(PROJECT);
	}

	public Membership setProject(Project project) {
		storage.set(PROJECT, project);
		return this;
	}

	public Integer getUserId() {
		return storage.get(USER_ID);
	}

	public Membership setUserId(Integer id) {
		storage.set(USER_ID, id);
		return this;
	}

    public Integer getGroupId() {
        return storage.get(GROUP_ID);
    }

    public void setGroupId(Integer id) {
		storage.set(GROUP_ID, id);
    }
    
	public String getUserName() {
		return storage.get(USER_NAME);
	}

	public void setUserName(String id) {
		storage.set(USER_NAME, id);
	}

    public String getGroupName() {
        return storage.get(GROUP_NAME);
    }

    public void setGroupName(String id) {
		storage.set(GROUP_NAME, id);
    }

    public Collection<Role> getRoles() {
		return Collections.unmodifiableCollection(storage.get(ROLES));
	}

	public Membership addRoles(Collection<Role> roles) {
		storage.get(ROLES).addAll(roles);
		return this;
	}

    @Override
    public boolean equals(Object o) {
        if (this == o) return true;
        if (o == null || getClass() != o.getClass()) return false;

        Membership that = (Membership) o;

        if (getId() != null ? !getId().equals(that.getId()) : that.getId() != null) return false;

        return true;
    }

    @Override
    public int hashCode() {
        return getId() != null ? getId().hashCode() : 0;
    }

    @Override
	public String toString() {
		return "Membership [id=" + getId() + ", project=" + getProject() + ", userId="
				+ getUserId() + ", groupId=" + getGroupId() + ", roles=" + getRoles() + "]";
	}

	public PropertyStorage getStorage() {
		return storage;
	}

	/**
	 * Required attributes: 1) project    2) either userId/groupId or roles collection
	 */
	public Membership create() throws RedmineException {
		if (getProject() == null) {
			throw new IllegalArgumentException("Project must be set");
		}
		if (getUserId() == null && getRoles().isEmpty()) {
			throw new IllegalArgumentException("Either User or Roles field must be set");
		}
		return transport.addChildEntry(Project.class, getProject().getId() + "", this);
	}

	public void update() throws RedmineException {
		transport.updateObject(this);
	}

	/**
	 * this object must have ID property set
	 */
	public void delete() throws RedmineException {
		transport.deleteObject(Membership.class, Integer.toString(getId()));
	}

	@Override
	public void setTransport(Transport transport) {
		this.transport = transport;
		PropertyStorageUtil.updateCollections(storage, transport);
	}
} public class Project implements Identifiable, Serializable, FluentStyle {
	private static final long serialVersionUID = 4529305420978716446L;

    private final PropertyStorage storage = new PropertyStorage();

    /**
     * database ID
     */
    public final static Property<Integer> DATABASE_ID = new Property<>(Integer.class, "id");

    /**
     * String "identifier" (human-readable name without spaces and other extra stuff)
     */
    public final static Property<String> STRING_IDENTIFIER = new Property<>(String.class, "identifier");

    /**
     * Can contain any symbols
     */
    public final static Property<String> NAME = new Property<>(String.class, "name");
    public final static Property<String> DESCRIPTION = new Property<>(String.class, "description");
    public final static Property<String> HOMEPAGE = new Property<>(String.class, "homepage");
    public final static Property<Date> CREATED_ON = new Property<>(Date.class, "createdOn");
    public final static Property<Date> UPDATED_ON = new Property<>(Date.class, "updatedOn");

    /**
     * This is the *database ID*, not a String-based key.
     */
    public final static Property<Integer> PARENT_DATABASE_ID = new Property<>(Integer.class, "parentId");
    public final static Property<Integer> STATUS = new Property<>(Integer.class, "status");
    public final static Property<Boolean> PUBLIC = new Property<>(Boolean.class, "public");
    public final static Property<Boolean> INHERIT_MEMBERS = new Property<>(Boolean.class, "inheritMembers");
    public final static Property<Set<CustomField>> CUSTOM_FIELDS = (Property<Set<CustomField>>) new Property(Set.class, "customFields");

    /**
     * Trackers available for this project
     */
    public final static Property<Set<Tracker>> TRACKERS = (Property<Set<Tracker>>) new Property(Set.class, "trackers");
    private Transport transport;

    public Project(Transport transport) {
        this.transport = transport;
        storage.set(CUSTOM_FIELDS, new HashSet<>());
    }

    public Project(Transport transport, String name, String key) {
        this(transport);
        setName(name);
        setIdentifier(key);
    }

    public Project setId(Integer id) {
        storage.set(DATABASE_ID, id);
        return this;
    }

    public String getHomepage() {
        return storage.get(HOMEPAGE);
    }

    public Project setHomepage(String homepage) {
        storage.set(HOMEPAGE, homepage);
        return this;
    }

    /**
     * @return project's string "key" (not a numeric database id!). Example: "project_ABC"
     */
    public String getIdentifier() {
        return storage.get(STRING_IDENTIFIER);
    }

    public Project setIdentifier(String identifier) {
        storage.set(STRING_IDENTIFIER, identifier);
        return this;
    }

    /**
     * @return numeric database ID
     */
    @Override
    public Integer getId() {
        return storage.get(DATABASE_ID);
    }

    /**
     * @return project name
     */
    public String getName() {
        return storage.get(NAME);
    }

    /**
     * @param name the project name
     */
    public Project setName(String name) {
        storage.set(NAME, name);
        return this;
    }

    /**
     * @return Trackers allowed in this project (e.g.: Bug, Feature, Support, Task, ...)
     */
    public Collection<Tracker> getTrackers() {
    	if (!storage.isPropertySet(TRACKERS)) //checks because trackers storage is not created for new projects
    		return Collections.unmodifiableCollection(new HashSet<Tracker>());
        return Collections.unmodifiableCollection(storage.get(TRACKERS));
    }

    /**
     * Adds the specified trackers to this project.
     * If this project is created or updated on the redmine server, 
     * each tracker id must be a valid tracker on the server.
     */
    public Project addTrackers(Collection<Tracker> trackers) {
    	if (!storage.isPropertySet(TRACKERS)) //checks because trackers storage is not created for new projects
    		storage.set(TRACKERS, new HashSet<>());
        storage.get(TRACKERS).addAll(trackers);
        return this;
    }

    /**
     * Removes all of the trackers from this project.
     */
    public Project clearTrackers() {
    	storage.set(TRACKERS, new HashSet<>());
    	return this;
    }

    public Tracker getTrackerByName(String trackerName) {
        for (Tracker t : getTrackers()) {
            if (t.getName().equals(trackerName)) return t;
        }
        return null;
    }

    @Override
    public String toString() {
        return "Project{" +
                "id=" + getId() +
                ", identifier='" + getIdentifier() + '\'' +
                ", name='" + getName() + '\'' +
                '}';
    }

    public String getDescription() {
        return storage.get(DESCRIPTION);
    }

    public Project setDescription(String description) {
        storage.set(DESCRIPTION, description);
        return this;
    }

    public Date getCreatedOn() {
        return storage.get(CREATED_ON);
    }

    public Project setCreatedOn(Date createdOn) {
        storage.set(CREATED_ON, createdOn);
        return this;
    }

    public Date getUpdatedOn() {
        return storage.get(UPDATED_ON);
    }

    public Project setUpdatedOn(Date updatedOn) {
        storage.set(UPDATED_ON, updatedOn);
        return this;
    }

    /**
     * Redmine's REST API "get project" operation does NOT return the 
     * parent project ID in redmine 1.1.2 (and maybe earlier). Which means 
     * calling getParentId() of the project loaded from Redmine server will
     * return <strong>NULL</strong> with that redmine. This bug was fixed in redmine 1.2.1.
     * See bug http://www.redmine.org/issues/8229
     * 
     *
     * @return the parent project Id if it was set programmatically or NULL (!!!) if the project was loaded from the server.
     */
    public Integer getParentId() {
        return storage.get(PARENT_DATABASE_ID);
    }

    public Project setParentId(Integer parentId) {
        storage.set(PARENT_DATABASE_ID, parentId);
        return this;
    }

    /**
     * Returns the project status. This number can theoretically be different for different Redmine versions,
     * But the **current Redmine version in 2018** defines these numbers as:
     * <ul>
     *   <li>1: status active</li>
     *   <li>5: status closed</li>
     *   <li>9: status archived</li>
     * </ul>
     * 
     * <p>see http://www.redmine.org/projects/redmine/repository/entry/trunk/app/models/project.rb
     *
     * @return possibly Redmine-version-specific number that represents project status (active/closed/archived)
     * @since Redmine REST API 2.5.0
     */
    public Integer getStatus() {
    	return storage.get(STATUS);
    }
    /**
     * Sets the project status (Note that this will not take effect when updating a project as
     * the **current Redmine version in 2018** does not allow reopen, close or archive projects,
     * see https://www.redmine.org/issues/13725)
     */
    public Project setStatus(Integer status) {
    	storage.set(STATUS, status);
    	return this;
    }

    /**
     *
     * @return true if the project is public, false if the project is private. 
     * Returns <code>null</code> if the project visibility was not specified or if the project was just retrieved from server.
     *
     * @since Redmine 2.6.0. see http://www.redmine.org/issues/17628 . this property is for writing only before Redmine 2.6.0.
     * The value is not returned by older Redmine versions.
     */
    @Deprecated
    public Boolean getProjectPublic() {
        return storage.get(PUBLIC);
    }
    
    public Project setInheritMembers(Boolean inheritMembers) {
        storage.set(INHERIT_MEMBERS, inheritMembers);
        return this;
    }

    public Boolean getInheritMembers() {
        return storage.get(INHERIT_MEMBERS);
    }

    public Project setProjectPublic(Boolean projectPublic) {
        storage.set(PUBLIC, projectPublic);
        return this;
    }

    public Collection<CustomField> getCustomFields() {
        return storage.get(CUSTOM_FIELDS);
    }

    public Project addCustomFields(Collection<CustomField> customFields) {
        storage.get(CUSTOM_FIELDS).addAll(customFields);
        return this;
    }

    public CustomField getCustomFieldById(int customFieldId) {
        for (CustomField customField : getCustomFields()) {
            if (customFieldId == customField.getId()) {
                return customField;
            }
        }
        return null;
    }

    @Override
    public boolean equals(Object o) {
        if (this == o) return true;
        if (o == null || getClass() != o.getClass()) return false;

        Project project = (Project) o;

        if (getId() != null ? !getId().equals(project.getId()) : project.getId() != null) return false;

        return true;
    }

    @Override
    public int hashCode() {
        return getId() != null ? getId().hashCode() : 0;
    }

    public PropertyStorage getStorage() {
        return storage;
    }

    @Override
    public void setTransport(Transport transport) {
        this.transport = transport;
        PropertyStorageUtil.updateCollections(storage, transport);
    }

    /**
     * Sample usage:
     *
     * <pre>
     * {@code
     * 	Project project = new Project(transport);
     * 	Long timeStamp = Calendar.getInstance().getTimeInMillis();
     * 	String key = "projkey" + timeStamp;
     * 	String name = &quot;project number &quot; + timeStamp;
     * 	String description = &quot;some description for the project&quot;;
     * 	project.setIdentifier(key)
     * 	  .setName(name)
     * 	  .setDescription(description)
     * 	  .create();
     * }
     * </pre>
     * <p> 
     * Note: if {@code project} trackers have not been set with {@link Project#addTrackers} 
     * and if they have been cleared with {@link Project#clearTrackers},
     * the created project will get the server default trackers (if any).
     * Otherwise, the {@code Project} trackers will override the server default settings. 
     * </p>
     *
     * @return the newly created Project object.
     * @throws RedmineAuthenticationException invalid or no API access key is used with the server, which
     *                                 requires authorization. Check the constructor arguments.
     * @throws RedmineException
     */
    public Project create() throws RedmineException {
        return transport.addObject(this, new RequestParam("include",
                "trackers"));
    }

    public void update() throws RedmineException {
        transport.updateObject(this);
    }

    /**
     * The project object must have projectKey set (String-based identifier like "project-ABC",
     * NOT a database numeric ID!)
     *
     * @throws RedmineAuthenticationException invalid or no API access key is used with the server, which
     *                                 requires authorization. Check the constructor arguments.
     * @throws NotFoundException       if the project with this key is not found
     */
    public void delete() throws RedmineException {
        transport.deleteObject(Project.class, getIdentifier());
    }
} public class Role {

    /**
     * database ID.
     */
    private Integer id;
	private String name;
	private Boolean inherited;

	private final Collection<String> permissions = new HashSet<>();

    /**
     * @param id database ID.
     */
    public Role setId(Integer id) {
        this.id = id;
        return this;
    }

    public Integer getId() {
		return id;
	}

	public String getName() {
		return name;
	}

	public Role setName(String name) {
		this.name = name;
		return this;
	}

	public Boolean getInherited() {
		return inherited;
	}

	public void setInherited(Boolean inherited) {
		this.inherited = inherited;
	}
	
	public Collection<String> getPermissions() {
        return Collections.unmodifiableCollection(permissions);
    }

    public void addPermissions(Collection<String> permissions) {
        this.permissions.addAll(permissions);
    }

    @Override
    public boolean equals(Object o) {
        if (this == o) return true;
        if (o == null || getClass() != o.getClass()) return false;

        Role role = (Role) o;

        if (id != null ? !id.equals(role.id) : role.id != null) return false;

        return true;
    }

    @Override
    public int hashCode() {
        return id != null ? id.hashCode() : 0;
    }

    @Override
	public String toString() {
		return "Role [id=" + id + ", name=" + name + ", inherited=" + inherited
				+ "]";
	}
} public class SavedQuery {
    private Integer id;

    private String name;
    private boolean publicQuery;
    private Integer projectId;

    public SavedQuery setId(Integer id) {
        this.id = id;
        return this;
    }

    @Override
    public boolean equals(Object o) {
        if (this == o) return true;
        if (o == null || getClass() != o.getClass()) return false;

        SavedQuery that = (SavedQuery) o;

        if (id != null ? !id.equals(that.id) : that.id != null) return false;

        return true;
    }

    @Override
    public int hashCode() {
        return id != null ? id.hashCode() : 0;
    }

    public Integer getId() {
        return id;
    }

    public String getName() {
        return name;
    }

    public SavedQuery setName(String name) {
        this.name = name;
        return this;
    }

    public boolean isPublicQuery() {
        return publicQuery;
    }

    public SavedQuery setPublicQuery(boolean isPublic) {
        this.publicQuery = isPublic;
        return this;
    }

    public Integer getProjectId() {
        return projectId;
    }

    public SavedQuery setProjectId(Integer value) {
        this.projectId = value;
        return this;
    }

    @Override
    public String toString() {
        return "SavedQuery [id=" + id + ", name=" + name + ", publicQuery="
                + publicQuery + ", projectId=" + projectId + "]";
    }

} public class Tracker implements Identifiable, Serializable {

    /**
     * database ID.
     */
    private Integer id;
    private String name;

    /**
     * @param id database ID.
     */
    public Tracker setId(Integer id) {
        this.id = id;
        return this;
    }

    public Integer getId() {
        return id;
    }

    public String getName() {
        return name;
    }

    public Tracker setName(String name) {
        this.name = name;
        return this;
    }

    @Override
    public boolean equals(Object o) {
        if (this == o) return true;
        if (o == null || getClass() != o.getClass()) return false;

        Tracker tracker = (Tracker) o;

        if (id != null ? !id.equals(tracker.id) : tracker.id != null) return false;

        return true;
    }

    @Override
    public int hashCode() {
        return id != null ? id.hashCode() : 0;
    }
} public class User implements Identifiable, FluentStyle {

    public static final Integer STATUS_ANONYMOUS = 0;

    public static final Integer STATUS_ACTIVE = 1;

    public static final Integer STATUS_REGISTERED = 2;

    public static final Integer STATUS_LOCKED = 3;

    private final PropertyStorage storage = new PropertyStorage();

    public final static Property<Integer> ID = new Property<>(Integer.class, "id");
    public final static Property<String> LOGIN = new Property<>(String.class, "login");
    public final static Property<String> PASSWORD = new Property<>(String.class, "password");
    public final static Property<String> FIRST_NAME = new Property<>(String.class, "firstName");
    public final static Property<String> LAST_NAME = new Property<>(String.class, "lastName");
    public final static Property<String> MAIL = new Property<>(String.class, "mail");
    public final static Property<String> API_KEY = new Property<>(String.class, "apiKey");
    public final static Property<Date> CREATED_ON = new Property<>(Date.class, "createdOn");
    public final static Property<Date> LAST_LOGIN_ON = new Property<>(Date.class, "lastLoginOn");
    public final static Property<Integer> AUTH_SOURCE_ID = new Property<>(Integer.class, "authSourceId");
    public final static Property<Integer> STATUS = new Property<>(Integer.class, "status");
    public final static Property<String> MAIL_NOTIFICATION = new Property<>(String.class, "mail_notification");
    public final static Property<Boolean> MUST_CHANGE_PASSWD = new Property<>(Boolean.class, "must_change_passwd");
    public final static Property<Boolean> GENERATE_PASSWORD = new Property<>(Boolean.class, "generate_password");

    public final static Property<Set<CustomField>> CUSTOM_FIELDS = (Property<Set<CustomField>>) new Property(Set.class, "customFields");
    public final static Property<Set<Membership>> MEMBERSHIP = (Property<Set<Membership>>) new Property(Set.class, "membership");
    public final static Property<Set<Group>> GROUPS = (Property<Set<Group>>) new Property(Set.class, "groups");

    private Transport transport;

    public User(Transport transport) {
        initCollections();
        setTransport(transport);
    }

    private void initCollections() {
        this.storage.set(CUSTOM_FIELDS, new HashSet<>());
        this.storage.set(MEMBERSHIP, new HashSet<>());
        this.storage.set(GROUPS, new HashSet<>());
    }

    public User setId(int id) {
        storage.set(ID, id);
        return this;
    }

    @Override
    public Integer getId() {
        return storage.get(ID);
    }

    @Override
    public String toString() {
        return getLogin();
    }

    public String getLogin() {
        return storage.get(LOGIN);
    }

    public User setLogin(String login) {
        storage.set(LOGIN, login);
        return this;
    }

    public String getFirstName() {
        return storage.get(FIRST_NAME);
    }

    public User setFirstName(String firstName) {
        storage.set(FIRST_NAME, firstName);
        return this;
    }

    public String getLastName() {
        return storage.get(LAST_NAME);
    }

    public User setLastName(String lastName) {
        storage.set(LAST_NAME, lastName);
        return this;
    }

    /**
     * This field is empty when using issues.get(i).getAssignee().getMail()
     */
    public String getMail() {
        return storage.get(MAIL);
    }

    public User setMail(String mail) {
        storage.set(MAIL, mail);
        return this;
    }

    public Date getCreatedOn() {
        return storage.get(CREATED_ON);
    }

    public User setCreatedOn(Date createdOn) {
        storage.set(CREATED_ON, createdOn);
        return this;
    }

    public Date getLastLoginOn() {
        return storage.get(LAST_LOGIN_ON);
    }

    public User setLastLoginOn(Date lastLoginOn) {
        storage.set(LAST_LOGIN_ON, lastLoginOn);
        return this;
    }

    public String getApiKey() {
        return storage.get(API_KEY);
    }

    /**
     * APIKey property is read-only. This setter is only for serialization JSon.
     * The value you set using this method will be ignored by the server.
     */
    @Deprecated
    public User setApiKey(String apiKey) {
        storage.set(API_KEY, apiKey);
        return this;
    }

    /**
     * This value is not returned by redmine on existing users.
     * 
     * @return - The Authentication Source ID, if you set it on your own.
     */
    @Deprecated
    public Integer getAuthSourceId() {
		return storage.get(AUTH_SOURCE_ID);
	}

	public User setAuthSourceId(Integer authSource) {
        storage.set(AUTH_SOURCE_ID, authSource);
        return this;
	}

    @Override
    public boolean equals(Object o) {
        if (this == o) return true;
        if (o == null || getClass() != o.getClass()) return false;

        User user = (User) o;

        return getId() != null ? getId().equals(user.getId()) : user.getId() == null;
    }

    @Override
    public int hashCode() {
        return getId() != null ? getId().hashCode() : 0;
    }

    /**
     * @return `firstName` + space + `lastName` if `lastName` is not null. otherwise returns value of `firstName`
     */
    public String getFullName() {
        String lastNameOrEmpty = getLastName() == null ? "" : " " + getLastName();
        return getFirstName() + lastNameOrEmpty;
    }

    /**
     * This is a BIG HACK just to workaround Redmine REST API limitation.
     * see http://www.redmine.org/issues/7487
     */
    public User setFullName(String fullName) {
        int ind = fullName.indexOf(' ');
        if (ind != -1) {
            setFirstName(fullName.substring(0, ind));
            setLastName(fullName.substring(ind + 1));
        } else {
            setFirstName(fullName);
        }
        return this;
    }

    public String getPassword() {
        return storage.get(PASSWORD);
    }

    public User setPassword(String password) {
        storage.set(PASSWORD, password);
        return this;
    }

    /**
     * @return the value or NULL if the field is not found
     */
    public String getCustomField(String fieldName) {
        for (CustomField f : getCustomFields()) {
            if (f.getName().equals(fieldName)) {
                return f.getValue();
            }
        }
        return null;
    }

    /**
     * @return Custom Fields, NEVER NULL.
     */
    public Collection<CustomField> getCustomFields() {
        return Collections.unmodifiableCollection(storage.get(CUSTOM_FIELDS));
    }

    public void clearCustomFields() {
        storage.set(CUSTOM_FIELDS, new HashSet<>());
    }

    /**
     * NOTE: The custom field(s) <strong>must have correct database ID set</strong> to be saved to Redmine. This is Redmine REST API's limitation.
     * ID can be seen in database or in Redmine administration when editing the custom field (number is part of the URL!).
     */
    public User addCustomFields(Collection<CustomField> customFields) {
        storage.get(CUSTOM_FIELDS).addAll(customFields);
        return this;
    }

    /**
     * If there is a custom field with the same ID already present,
     * the new field replaces the old one.
     *
     * @param customField the field to add.
     */
    public User addCustomField(CustomField customField) {
        storage.get(CUSTOM_FIELDS).add(customField);
        return this;
    }

	public Collection<Membership> getMemberships() {
		return Collections.unmodifiableCollection(storage.get(MEMBERSHIP));
	}

	public User addMemberships(Collection<Membership> memberships) {
		storage.get(MEMBERSHIP).addAll(memberships);
        return this;
	}

	public Collection<Group> getGroups() {
        return Collections.unmodifiableCollection(storage.get(GROUPS));
    }

	public User addGroups(Collection<Group> groups) {
        if (!storage.isPropertySet(GROUPS)) {
            storage.set(GROUPS, new HashSet<Group>());
        }
        storage.get(GROUPS).addAll(groups);
        return this;
	}

    /**
     * This field is visible to Admin only.
     * <p>
     * Returns the user status. This number can theoretically be different for different Redmine versions,
     * But the **current Redmine version in 2018** defines these numbers as:
     * <ul>
     *   <li>0: status anonymous</li>
     *   <li>1: status active</li>
     *   <li>2: status registered</li>
     *   <li>3: status locked</li>
     * </ul>
     *
     * <p>see http://www.redmine.org/projects/redmine/repository/entry/trunk/app/models/principal.rb#L22-25
     * 
     * @return possibly Redmine-version-specific number that represents user status (active/locked/etc)
     * @since Redmine REST API 2.4.0
     */
    public Integer getStatus() {
        return storage.get(STATUS);
    }

    /**
     * Sets the user status.
     * 
     * @param status {@link #STATUS_ACTIVE}, {@link #STATUS_LOCKED}, etc...
     */
    public User setStatus(Integer status) {
        storage.set(STATUS, status);
        return this;
    }

    public User setMailNotification(String mailNotification) {
        storage.set(MAIL_NOTIFICATION, mailNotification);
        return this;
    }

    public User setMustChangePasswd(Boolean mustChangePasswd) {
        storage.set(MUST_CHANGE_PASSWD, mustChangePasswd);
        return this;
    }

    public User setGeneratePassword(Boolean generatePassword) {
        storage.set(GENERATE_PASSWORD, generatePassword);
        return this;
    }

    public PropertyStorage getStorage() {
        return storage;
    }

    @Override
    public void setTransport(Transport transport) {
        this.transport = transport;
        PropertyStorageUtil.updateCollections(storage, transport);
    }

    public User create() throws RedmineException {
        return transport.addObject(this);
    }

    public void update() throws RedmineException {
        transport.updateObject(this);
    }

    /**
     * Adds this user (ID must be set!) to the given group.
     * <p>
     * Note: "add to group" operation used to be safe (idempotent) for Redmine 2.6.x, but FAILS for Redmine 3.0.0 when
     * executed twice on the same user. I submitted a bug: http://www.redmine.org/issues/19363 which was closed as
     * "invalid"...
     *
     * @param groupId - id of the group to add to.
     * @throws RedmineException
     * @since Redmine 2.1
     */
    public void addToGroup(int groupId) throws RedmineException {
        transport.addUserToGroup(getId(), groupId);
    }

    /**
     * The user object must have ID set.
     */
    public void delete() throws RedmineException {
        transport.deleteObject(User.class, Integer.toString(getId()));
    }
} public class Version implements Identifiable, FluentStyle {
    /*
      * <id>1</id> <project name="Redmine" id="1"/> <name>0.7</name>
      * <description/> <status>closed</status> <due_date>2008-04-28</due_date>
      * <sharing>descendants</sharing>
      * <created_on>2008-03-09T12:52:06+01:00</created_on>
      * <updated_on>2009-11-15T12:22:12+01:00</updated_on>
      */

    public static final String STATUS_OPEN = "open";
    public static final String STATUS_LOCKED = "locked";
    public static final String STATUS_CLOSED = "closed";

    public static final String SHARING_NONE = "none";
    public static final String SHARING_DESCENDANTS = "descendants";
    public static final String SHARING_HIERARCHY = "hierarchy";
    public static final String SHARING_TREE = "tree";
    public static final String SHARING_SYSTEM = "system";

    private final PropertyStorage storage = new PropertyStorage();

    /**
     * database numeric Id
     */
    public final static Property<Integer> DATABASE_ID = new Property<>(Integer.class, "id");

    public final static Property<Integer> PROJECT_ID = new Property<>(Integer.class, "projectId");
    public final static Property<String> PROJECT_NAME = new Property<>(String.class, "projectName");
    public final static Property<String> NAME = new Property<>(String.class, "name");
    public final static Property<String> DESCRIPTION = new Property<>(String.class, "description");
    public final static Property<String> STATUS = new Property<>(String.class, "status");
    public final static Property<String> SHARING = new Property<>(String.class, "sharing");
    public final static Property<Date> DUE_DATE = new Property<>(Date.class, "dueDate");
    public final static Property<Date> CREATED_ON = new Property<>(Date.class, "createdOn");
    public final static Property<Date> UPDATED_ON = new Property<>(Date.class, "updatedOn");
    public final static Property<Set<CustomField>> CUSTOM_FIELDS = (Property<Set<CustomField>>) new Property(Set.class, "customFields");
    private Transport transport;

    public Version() {
        initCollections(storage);
    }

    public Version(Transport transport, int projectId, String name) {
        this();
        setTransport(transport);
        setProjectId(projectId);
        setName(name);
    }

    public Version setId(Integer id) {
        storage.set(DATABASE_ID, id);
        return this;
    }

    private void initCollections(PropertyStorage storage) {
        storage.set(CUSTOM_FIELDS, new HashSet<>());
    }

    /**
     * Version objects are considered to be equal if their IDs are not null and equal.
     */
    @Override
    public boolean equals(Object o) {
        if (this == o) return true;
        if (o == null || getClass() != o.getClass()) return false;

        Version version = (Version) o;

        if (getId() != null ? !getId().equals(version.getId()) : version.getId() != null) return false;

        return true;
    }

    @Override
    public int hashCode() {
        return getId() != null ? getId().hashCode() : 0;
    }

    public Date getCreatedOn() {
        return storage.get(CREATED_ON);
    }

    public String getDescription() {
        return storage.get(DESCRIPTION);
    }

    public Date getDueDate() {
        return storage.get(DUE_DATE);
    }

    @Override
    public Integer getId() {
        return storage.get(DATABASE_ID);
    }

    public Integer getProjectId() {
        return storage.get(PROJECT_ID);
    }

    public Version setProjectId(Integer projectId) {
        storage.set(PROJECT_ID, projectId);
        return this;
    }

    public String getProjectName() {
        return storage.get(PROJECT_NAME);
    }

    public Version setProjectName(String name) {
        storage.set(PROJECT_NAME, name);
        return this;
    }

    public String getName() {
        return storage.get(NAME);
    }

    public String getSharing() {
        return storage.get(SHARING);
    }

    public String getStatus() {
        return storage.get(STATUS);
    }

    public Date getUpdatedOn() {
        return storage.get(UPDATED_ON);
    }

    public Version setCreatedOn(Date createdOn) {
        storage.set(CREATED_ON, createdOn);
        return this;
    }

    public Version setDescription(String description) {
        storage.set(DESCRIPTION, description);
        return this;
    }

    public Version setDueDate(Date dueDate) {
        storage.set(DUE_DATE, dueDate);
        return this;
    }

    public Version setName(String name) {
        storage.set(NAME, name);
        return this;
    }

    public Version setSharing(String sharing) {
        storage.set(SHARING, sharing);
        return this;
    }

    public Version setStatus(String status) {
        storage.set(STATUS, status);
        return this;
    }

    public void setUpdatedOn(Date updatedOn) {
        storage.set(UPDATED_ON, updatedOn);
    }

    public Collection<CustomField> getCustomFields() {
        return Collections.unmodifiableCollection(storage.get(CUSTOM_FIELDS));
    }

    public Version addCustomFields(Collection<CustomField> customFields) {
        storage.get(CUSTOM_FIELDS).addAll(customFields);
        return this;
    }

    /**
     * @return the field with the given ID or NULL if the field is not found.
     */
    public CustomField getCustomFieldById(int customFieldId) {
        for (CustomField customField : getCustomFields()) {
            if (customFieldId == customField.getId()) {
                return customField;
            }
        }
        return null;
    }

    @Override
    public String toString() {
        return "Version [id=" + getId() + ", name=" + getName() + "]";
    }

    public PropertyStorage getStorage() {
        return storage;
    }


    /**
     * creates a new {@link com.taskadapter.redmineapi.bean.Version} for the {@link Project} contained. <br>
     * Pre-condition: attribute projectId must be set.
     *
     * @return the new {@link com.taskadapter.redmineapi.bean.Version} created by Redmine
     * @throws IllegalArgumentException thrown in case the version does not contain a project.
     * @throws RedmineAuthenticationException  thrown in case something went wrong while trying to login
     * @throws RedmineException         thrown in case something went wrong in Redmine
     * @throws NotFoundException        thrown in case an object can not be found
     */
    public Version create() throws RedmineException {
        // check project
        if (getProjectId() == null) {
            throw new IllegalArgumentException(
                    "Version must contain projectId");
        }
        return transport.addChildEntry(Project.class, getProjectId().toString(), this);
    }

    /**
     * update this object on the server
     */
    public void update() throws RedmineException {
        transport.updateObject(this);
    }

    /**
     * deletes this {@link Version} from the referenced {@link Project}. <br>
     *
     * @throws RedmineAuthenticationException thrown in case something went wrong while trying to login
     * @throws RedmineException        thrown in case something went wrong in Redmine
     * @throws NotFoundException       thrown in case an object can not be found
     */
    public void delete() throws RedmineException {
        transport.deleteObject(Version.class, Integer.toString(getId()));
    }

    @Override
    public void setTransport(Transport transport) {
        this.transport = transport;
        PropertyStorageUtil.updateCollections(storage, transport);
    }
} public class Watcher {

    private Integer id;
    private String name;

    /**
     * @param id database Id
     */
    public Watcher setId(Integer id) {
        this.id = id;
        return this;
    }

    public Integer getId() {
        return id;
    }

    public String getName() {
        return name;
    }

    public void setName(String name) {
        this.name = name;
    }

    @Override
    public boolean equals(Object o) {
        if (this == o) return true;
        if (o == null || getClass() != o.getClass()) return false;

        Watcher watcher = (Watcher) o;

        if (id != null ? !id.equals(watcher.id) : watcher.id != null) return false;

        return true;
    }

    @Override
    public int hashCode() {
        return id != null ? id.hashCode() : 0;
    }

    @Override
    public String toString() {
        return "Watcher{" +
                "id=" + id +
                ", name='" + name + '\'' +
                '}';
    }

} public class RequestParam {

    private final String name;
    private final String value;

    public RequestParam(final String name, final String value) {
        this.name = Objects.requireNonNull(name, "Name may not be null");
        this.value = Objects.requireNonNull(value, "Value may not be null");
    }

    public String getName() {
        return name;
    }

    public String getValue() {
        return value;
    }

    @Override
    public boolean equals(Object o) {
        if (this == o) return true;
        if (o == null || getClass() != o.getClass()) return false;
        RequestParam that = (RequestParam) o;
        return name.equals(that.name) &&
                value.equals(that.value);
    }

    @Override
    public int hashCode() {
        return Objects.hash(name, value);
    }

    @Override
    public String toString() {
        return "RequestParam{" +
                "name='" + name + '\'' +
                ", value='" + value + '\'' +
                '}';
    }
} public class Transport {
	private static final Map<Class<?>, EntityConfig<?>> OBJECT_CONFIGS = new HashMap<>();
	private static final String CONTENT_TYPE = "application/json; charset=utf-8";
	private static final int DEFAULT_OBJECTS_PER_PAGE = 25;
	private static final String KEY_TOTAL_COUNT = "total_count";
	private static final String KEY_LIMIT = "limit";
	private static final String KEY_OFFSET = "offset";

	private final Logger logger = LoggerFactory.getLogger(RedmineManager.class);
	private SimpleCommunicator<String> communicator;
	private Communicator<BasicHttpResponse> errorCheckingCommunicator;
	private Communicator<HttpResponse> authenticator;

    private String onBehalfOfUser = null;

    static {
		OBJECT_CONFIGS.put(
				Project.class,
				config("project", "projects",
						RedmineJSONBuilder::writeProject,
						RedmineJSONParser::parseProject));
		OBJECT_CONFIGS.put(
				Issue.class,
				config("issue", "issues", RedmineJSONBuilder::writeIssue,
						RedmineJSONParser::parseIssue));
		OBJECT_CONFIGS.put(
				User.class,
				config("user", "users", RedmineJSONBuilder::writeUser,
						RedmineJSONParser::parseUser));
		OBJECT_CONFIGS.put(
				Group.class,
				config("group", "groups", RedmineJSONBuilder::writeGroup,
						RedmineJSONParser::parseGroup));
		OBJECT_CONFIGS.put(
				IssueCategory.class,
				config("issue_category", "issue_categories",
						RedmineJSONBuilder::writeCategory,
						RedmineJSONParser::parseCategory));
		OBJECT_CONFIGS.put(
				Version.class,
				config("version", "versions",
						RedmineJSONBuilder::writeVersion,
						RedmineJSONParser::parseVersion));
		OBJECT_CONFIGS.put(
				TimeEntry.class,
				config("time_entry", "time_entries",
						RedmineJSONBuilder::writeTimeEntry,
						RedmineJSONParser::parseTimeEntry));
		OBJECT_CONFIGS.put(News.class,
				config("news", "news", null, RedmineJSONParser::parseNews));
		OBJECT_CONFIGS.put(
				IssueRelation.class,
				config("relation", "relations",
						RedmineJSONBuilder::writeRelation,
						RedmineJSONParser::parseRelation));
		OBJECT_CONFIGS.put(
				Tracker.class,
				config("tracker", "trackers", null,
						RedmineJSONParser::parseTracker));
		OBJECT_CONFIGS.put(
				IssueStatus.class,
				config("status", "issue_statuses", null,
						RedmineJSONParser::parseStatus));
		OBJECT_CONFIGS
				.put(SavedQuery.class,
						config("query", "queries", null,
								RedmineJSONParser::parseSavedQuery));
		OBJECT_CONFIGS.put(Role.class,
				config("role", "roles", null, RedmineJSONParser::parseRole));
		OBJECT_CONFIGS.put(
				Membership.class,
				config("membership", "memberships",
						RedmineJSONBuilder::writeMembership,
						RedmineJSONParser::parseMembership));
        OBJECT_CONFIGS.put(
                IssuePriority.class,
                config("issue_priority", "issue_priorities", null,
                        RedmineJSONParser::parseIssuePriority));
        OBJECT_CONFIGS.put(
                TimeEntryActivity.class,
                config("time_entry_activity", "time_entry_activities", null,
                        RedmineJSONParser::parseTimeEntryActivity));

        OBJECT_CONFIGS.put(
                Watcher.class,
                config("watcher", "watchers", null,
                        RedmineJSONParser::parseWatcher));

        OBJECT_CONFIGS.put(
                WikiPage.class,
                config("wiki_page", "wiki_pages", null, RedmineJSONParser::parseWikiPage)
        );

        OBJECT_CONFIGS.put(
                WikiPageDetail.class,
                config("wiki_page", null, RedmineJSONBuilder::writeWikiPageDetail, RedmineJSONParser::parseWikiPageDetail)
        );
        OBJECT_CONFIGS.put(
                CustomFieldDefinition.class,
                config("custom_field", "custom_fields", null,
                        RedmineJSONParser::parseCustomFieldDefinition));
		OBJECT_CONFIGS.put(
				Attachment.class,
				config("attachment", "attachments", null,
						RedmineJSONParser::parseAttachments));
		OBJECT_CONFIGS.put(
				File.class,
				config("file", "files", null, RedmineJSONParser::parseFiles));
    }

	private URIConfigurator configurator;
	private int objectsPerPage = DEFAULT_OBJECTS_PER_PAGE;
	private static final String CHARSET = "UTF-8";

	public Transport(URIConfigurator configurator, HttpClient client) {
		var baseCommunicator = new BaseCommunicator(client);
		var redmineAuthenticator = new RedmineAuthenticator<>(baseCommunicator, CHARSET);
		configure(configurator, redmineAuthenticator);
	}

	public Transport(URIConfigurator configurator, Communicator communicator) {
		configure(configurator, communicator);
	}

	private void configure(URIConfigurator configurator, Communicator communicator) {
		this.configurator = configurator;
		this.authenticator = communicator;
		final ContentHandler<BasicHttpResponse, BasicHttpResponse> errorProcessor = new RedmineErrorHandler();
		errorCheckingCommunicator = Communicators.fmap(
				authenticator,
				Communicators.compose(errorProcessor,
						Communicators.transportDecoder()));
		Communicator<String> coreCommunicator = Communicators.fmap(errorCheckingCommunicator,
				Communicators.contentReader());
		this.communicator = Communicators.simplify(coreCommunicator,
				Communicators.<String>identityHandler());
	}

	public User getCurrentUser(RequestParam... params) throws RedmineException {
		URI uri = getURIConfigurator().createURI("users/current.json", params);
		HttpGet http = new HttpGet(uri);
		String response = send(http);
		return parseResponse(response, "user", RedmineJSONParser::parseUser);
	}

	/**
	 * Performs an "add object" request.
	 * 
	 * @param object
	 *            object to use.
	 * @param params
	 *            name params.
	 * @return object to use.
	 * @throws RedmineException
	 *             if something goes wrong.
	 */
	public <T> T addObject(T object, RequestParam... params)
			throws RedmineException {
		final EntityConfig<T> config = getConfig(object.getClass());
        if (config.writer == null) {
            throw new RuntimeException("can't create object: writer is not implemented or is not registered in RedmineJSONBuilder for object " + object);
        }
		URI uri = getURIConfigurator().getObjectsURI(object.getClass(), params);
		HttpPost httpPost = new HttpPost(uri);
		String body = RedmineJSONBuilder.toSimpleJSON(config.singleObjectName, object, config.writer);
		setEntity(httpPost, body);
		String response = send(httpPost);
		logger.debug(response);
		return parseResponse(response, config.singleObjectName, config.parser);
	}

	/**
	 * Performs an "add child object" request.
	 * 
	 * @param parentClass
	 *            parent object id.
	 * @param object
	 *            object to use.
	 * @param params
	 *            name params.
	 * @return object to use.
	 * @throws RedmineException
	 *             if something goes wrong.
	 */
	public <T> T addChildEntry(Class<?> parentClass, String parentId, T object,
			RequestParam... params) throws RedmineException {
		final EntityConfig<T> config = getConfig(object.getClass());
		URI uri = getURIConfigurator().getChildObjectsURI(parentClass,
				parentId, object.getClass(), params);
		HttpPost httpPost = new HttpPost(uri);
		String body = RedmineJSONBuilder.toSimpleJSON(config.singleObjectName,
				object, config.writer);
		setEntity(httpPost, body);
		String response = send(httpPost);
		logger.debug(response);
		return parseResponse(response, config.singleObjectName, config.parser);
	}

	/*
	 * note: This method cannot return the updated object from Redmine because
	 * the server does not provide any XML in response.
	 * 
	 * @since 1.8.0
	 */
	public <T extends Identifiable> void updateObject(T obj,
			RequestParam... params) throws RedmineException {
		final EntityConfig<T> config = getConfig(obj.getClass());
		final Integer id = obj.getId();
		if (id == null) {
			throw new RuntimeException("'id' field cannot be NULL in the given object:" +
					" it is required to identify the object in the target system");
		}
		final URI uri = getURIConfigurator().getObjectURI(obj.getClass(),
				Integer.toString(id), params);
		final HttpPut http = new HttpPut(uri);
		final String body = RedmineJSONBuilder.toSimpleJSON(
				config.singleObjectName, obj, config.writer);
		setEntity(http, body);
		send(http);
	}

	/*
	 * note: This method cannot return the updated object from Redmine because
	 * the server does not provide anything in response.
	 */
	public <T> void updateChildEntry(Class<?> parentClass, String parentId,
			T obj, String objId, RequestParam... params) throws RedmineException {
		final EntityConfig<T> config = getConfig(obj.getClass());
		URI uri = getURIConfigurator().getChildIdURI(parentClass, parentId, obj.getClass(), objId, params);
		final HttpPut http = new HttpPut(uri);
		final String body = RedmineJSONBuilder.toSimpleJSON(config.singleObjectName, obj, config.writer);
		setEntity(http, body);
		send(http);
	}

	/**
	 * Performs "delete child Id" request.
	 * 
	 * @param parentClass
	 *            parent object id.
	 * @param object
	 *            object to use.
	 * @param value
	 *            child object id.
	 * @throws RedmineException
	 *             if something goes wrong.
	 */
    public <T> void deleteChildId(Class<?> parentClass, String parentId, T object, Integer value) throws RedmineException {
        URI uri = getURIConfigurator().getChildIdURI(parentClass, parentId, object.getClass(), value);
        HttpDelete httpDelete = new HttpDelete(uri);
        String response = send(httpDelete);
        logger.debug(response);
    }

	/**
	 * Deletes an object.
	 * 
	 * @param classs
	 *            object class.
	 * @param id
	 *            object id.
	 * @throws RedmineException
	 *             if something goes wrong.
	 */
	public <T extends Identifiable> void deleteObject(Class<T> classs, String id)
			throws RedmineException {
		final URI uri = getURIConfigurator().getObjectURI(classs, id);
		final HttpDelete http = new HttpDelete(uri);
		send(http);
	}

	/**
	 * @param classs
	 *            target class
	 * @param key
	 *            item key
	 * @param params
	 *            extra arguments.
	 * @throws RedmineAuthenticationException
	 *             invalid or no API access key is used with the server, which
	 *             requires authorization. Check the constructor arguments.
	 * @throws NotFoundException
	 *             the object with the given key is not found
	 * @throws RedmineException
	 */
	public <T> T getObject(Class<T> classs, String key, RequestParam... params)
			throws RedmineException {
		final EntityConfig<T> config = getConfig(classs);
		final URI uri = getURIConfigurator().getObjectURI(classs, key, params);
		final HttpGet http = new HttpGet(uri);
		String response = send(http);
		logger.debug(response);
		return parseResponse(response, config.singleObjectName, config.parser);
	}

	/**
	 * Downloads redmine content.
	 * 
	 * @param uri
	 *            target uri.
	 * @param handler
	 *            content handler.
	 * @return handler result.
	 * @throws RedmineException
	 *             if something goes wrong.
	 */
	public <R> R download(String uri,
			ContentHandler<BasicHttpResponse, R> handler)
			throws RedmineException {
		final HttpGet request = new HttpGet(uri);
        if (onBehalfOfUser != null) {
            request.addHeader("X-Redmine-Switch-User", onBehalfOfUser);
        }
        return errorCheckingCommunicator.sendRequest(request, handler);
    }

	/**
	 * Deprecated because Redmine server can return invalid string depending on its configuration.
	 * See https://github.com/taskadapter/redmine-java-api/issues/78 .
	 * <p>Use {@link #upload(InputStream, long)} instead.
	 *
	 * <p>
	 * Uploads content on a server. This method calls {@link #upload(InputStream, long)} with -1 as content length.
	 * 
	 * @param content the content stream.
	 * @return uploaded item token.
	 * @throws RedmineException if something goes wrong.
	 */
	@Deprecated
	public String upload(InputStream content) throws RedmineException {
		return upload(content, -1);
	}

	/**
	 * @param content the content
	 * @param contentLength the length of the content in bytes. you can provide -1 but be aware that some
	 *                      users reported Redmine configuration problems that prevent it from processing -1 correctly.
	 *                      See https://github.com/taskadapter/redmine-java-api/issues/78 for details.
	 * @return the string token of the uploaded item. see {@link Attachment#getToken()}
	 */
	public String upload(InputStream content, long contentLength) throws RedmineException {
		final URI uploadURI = getURIConfigurator().getUploadURI();
		final HttpPost request = new HttpPost(uploadURI);
		final AbstractHttpEntity entity = new InputStreamEntity(content, contentLength);
		/* Content type required by a Redmine */
		entity.setContentType("application/octet-stream");
		request.setEntity(entity);

		final String result = send(request);
		return parseResponse(result, "upload", input -> JsonInput.getStringNotNull(input, "token"));
	}

	/**
	 * @param classs
	 *            target class
	 * @param key
	 *            item key
	 * @param params
	 *            extra arguments.
	 * @throws RedmineAuthenticationException
	 *             invalid or no API access key is used with the server, which
	 *             requires authorization. Check the constructor arguments.
	 * @throws NotFoundException
	 *             the object with the given key is not found
	 * @throws RedmineException
	 */
	public <T> T getObject(Class<T> classs, Integer key, RequestParam... params) throws RedmineException {
		return getObject(classs, key.toString(), params);
	}

	public <T> List<T> getObjectsList(Class<T> objectClass, RequestParam... params) throws RedmineException {
		return getObjectsList(objectClass, Arrays.asList(params));
	}

	/**
	 * Returns all objects found using the provided parameters.
	 * This method IGNORES "limit" and "offset" parameters and handles paging AUTOMATICALLY for you.
	 * Please use getObjectsListNoPaging() method if you want to control paging yourself with "limit" and "offset" parameters.
	 * 
	 * @return objects list, never NULL
	 *
	 * @see #getObjectsListNoPaging(Class, Collection)
	 */
	public <T> List<T> getObjectsList(Class<T> objectClass,
									  Collection<? extends RequestParam> params) throws RedmineException {
		final List<T> result = new ArrayList<>();
		int offset = 0;

		Integer totalObjectsFoundOnServer;
		do {
			final List<RequestParam> newParams = new ArrayList<>(params);
			newParams.add(new RequestParam("limit", String.valueOf(objectsPerPage)));
			newParams.add(new RequestParam("offset", String.valueOf(offset)));

			final ResultsWrapper<T> wrapper = getObjectsListNoPaging(objectClass, newParams);
			result.addAll(wrapper.getResults());

			totalObjectsFoundOnServer = wrapper.getTotalFoundOnServer();
			// Necessary for trackers.
			// TODO Alexey: is this still necessary for Redmine 2.x?
			if (totalObjectsFoundOnServer == null) {
				break;
			}
			if (!wrapper.hasSomeResults()) {
				break;
			}
			offset += wrapper.getResultsNumber();
		} while (offset < totalObjectsFoundOnServer);
		return result;
	}

	/**
	 * Returns an object list. Provide your own "limit" and "offset" parameters if you need those, otherwise
	 * this method will return the first page of some default size only (this default is controlled by
	 * your Redmine configuration).
	 *
	 * @return objects list, never NULL
	 */
	public <T> ResultsWrapper<T> getObjectsListNoPaging(Class<T> objectClass,
											  Collection<? extends RequestParam> params) throws RedmineException {
		final EntityConfig<T> config = getConfig(objectClass);
		try {
			final JSONObject responseObject = getJsonResponseFromGet(objectClass, params);
			List<T> results = JsonInput.getListOrNull(responseObject, config.multiObjectName, config.parser);
			Integer totalFoundOnServer = JsonInput.getIntOrNull(responseObject, KEY_TOTAL_COUNT);
			Integer limitOnServer = JsonInput.getIntOrNull(responseObject, KEY_LIMIT);
			Integer offsetOnServer = JsonInput.getIntOrNull(responseObject, KEY_OFFSET);
			return new ResultsWrapper<>(totalFoundOnServer, limitOnServer, offsetOnServer, results);
		} catch (JSONException e) {
			throw new RedmineFormatException(e);
		}
	}

	/**
	 * Use this method if you need direct access to Json results.
	 <pre>
	 Params params = new Params()
	   .add(...)
     getJsonResponseFromGet(Issue.class, params);
	 </pre>
	 */
	public <T> JSONObject getJsonResponseFromGet(Class<T> objectClass,
												 Collection<? extends RequestParam> params) throws RedmineException, JSONException {
		final List<RequestParam> newParams = new ArrayList<>(params);
		List<RequestParam> paramsList = new ArrayList<>(newParams);
		final URI uri = getURIConfigurator().getObjectsURI(objectClass, paramsList);
		final HttpGet http = new HttpGet(uri);
		final String response = send(http);
		return RedmineJSONParser.getResponse(response);
	}

	public <T> List<T> getChildEntries(Class<?> parentClass, int parentId, Class<T> classs) throws RedmineException {
		return getChildEntries(parentClass, parentId + "", classs);
	}

	/**
	 * Delivers a list of a child entries.
	 * 
	 * @param classs
	 *            target class.
	 */
	public <T> List<T> getChildEntries(Class<?> parentClass, String parentKey, Class<T> classs) throws RedmineException {
		final EntityConfig<T> config = getConfig(classs);
		final URI uri = getURIConfigurator().getChildObjectsURI(parentClass,
				parentKey, classs, new RequestParam("limit", String.valueOf(objectsPerPage)));

		HttpGet http = new HttpGet(uri);
		String response = send(http);
		final JSONObject responseObject;
		try {
			responseObject = RedmineJSONParser.getResponse(response);
			return JsonInput.getListNotNull(responseObject, config.multiObjectName, config.parser);
		} catch (JSONException e) {
			throw new RedmineFormatException("Bad categories response " + response, e);
		}
	}

    /**
     * Delivers a single child entry by its identifier.
     */
    public <T> T getChildEntry(Class<?> parentClass, String parentId,
                               Class<T> classs, String childId, RequestParam... params) throws RedmineException {
        final EntityConfig<T> config = getConfig(classs);
		final URI uri = getURIConfigurator().getChildIdURI(parentClass, parentId, classs, childId, params);
        HttpGet http = new HttpGet(uri);
        String response = send(http);

        return parseResponse(response, config.singleObjectName, config.parser);
    }

    /**
	 * This number of objects (tasks, projects, users) will be requested from
	 * Redmine server in 1 request.
	 */
	public void setObjectsPerPage(int pageSize) {
		if (pageSize <= 0) {
			throw new IllegalArgumentException("Page size must be >= 0. You provided: " + pageSize);
		}
		this.objectsPerPage = pageSize;
	}
	
	public void addUserToGroup(int userId, int groupId) throws RedmineException {
		logger.debug("adding user " + userId + " to group " + groupId + "...");
		URI uri = getURIConfigurator().getChildObjectsURI(Group.class, Integer.toString(groupId), User.class);
		HttpPost httpPost = new HttpPost(uri);
		final StringWriter writer = new StringWriter();
		final JSONWriter jsonWriter = new JSONWriter(writer);
		try {
			jsonWriter.object().key("user_id").value(userId).endObject();
		} catch (JSONException e) {
			throw new RedmineInternalError("Unexpected exception", e);
		}
		String body = writer.toString();
		setEntity(httpPost, body);
		String response = send(httpPost);
		logger.debug(response);
	}

	public void addWatcherToIssue(int watcherId, int issueId) throws RedmineException {
		logger.debug("adding watcher " + watcherId + " to issue " + issueId + "...");
		URI uri = getURIConfigurator().getChildObjectsURI(Issue.class, Integer.toString(issueId), Watcher.class);
		HttpPost httpPost = new HttpPost(uri);
		final StringWriter writer = new StringWriter();
		final JSONWriter jsonWriter = new JSONWriter(writer);
		try {
			jsonWriter.object().key("user_id").value(watcherId).endObject();
		} catch (JSONException e) {
			throw new RedmineInternalError("Unexpected exception", e);
		}
		String body = writer.toString();
		setEntity(httpPost, body);
		String response = send(httpPost);
		logger.debug(response);
	}

    private String send(HttpRequestBase http) throws RedmineException {
        if (onBehalfOfUser != null) {
            http.addHeader("X-Redmine-Switch-User", onBehalfOfUser);
        }
        return communicator.sendRequest(http);
    }

	private <T> T parseResponse(String response, String tag,
                                     JsonObjectParser<T> parser) throws RedmineFormatException {
		try {
			T parse = parser.parse(RedmineJSONParser.getResponseSingleObject(response, tag));
			if (parse instanceof FluentStyle) {
				((FluentStyle) parse).setTransport(this);
			}
			return parse;
		} catch (JSONException e) {
			throw new RedmineFormatException(e);
		}
	}

	private static void setEntity(HttpEntityEnclosingRequest request, String body) {
		setEntity(request, body, CONTENT_TYPE);
	}

	private static void setEntity(HttpEntityEnclosingRequest request, String body, String contentType) {
		StringEntity entity;
		try {
			entity = new StringEntity(body, CHARSET);
		} catch (UnsupportedCharsetException e) {
			throw new RedmineInternalError("Required charset " + CHARSET
					+ " is not supported", e);
		}
		entity.setContentType(contentType);
		request.setEntity(entity);
	}

	@SuppressWarnings("unchecked")
	private <T> EntityConfig<T> getConfig(Class<?> class1) {
		final EntityConfig<?> guess = OBJECT_CONFIGS.get(class1);
		if (guess == null)
			throw new RedmineInternalError("Unsupported class " + class1);
		return (EntityConfig<T>) guess;
	}

	private URIConfigurator getURIConfigurator() {
		return configurator;
	}

	private static <T> EntityConfig<T> config(String objectField,
			String urlPrefix, JsonObjectWriter<T> writer,
			JsonObjectParser<T> parser) {
		return new EntityConfig<>(objectField, urlPrefix, writer, parser);
	}

    /**
     * This works only when the main authentication has led to Redmine Admin level user.
     * The given user name will be sent to the server in "X-Redmine-Switch-User" HTTP Header
     * to indicate that the action (create issue, delete issue, etc) must be done
     * on behalf of the given user name.
     *
     * @param loginName Redmine user login name to provide to the server
     *
     * @see <a href="http://www.redmine.org/issues/11755">Redmine issue 11755</a>
     */
    public void setOnBehalfOfUser(String loginName) {
        this.onBehalfOfUser = loginName;
    }

	/**
	 * Entity config.
	 */
	static class EntityConfig<T> {
		final String singleObjectName;
		final String multiObjectName;
		final JsonObjectWriter<T> writer;
		final JsonObjectParser<T> parser;

		public EntityConfig(String objectField, String urlPrefix,
				JsonObjectWriter<T> writer, JsonObjectParser<T> parser) {
			super();
			this.singleObjectName = objectField;
			this.multiObjectName = urlPrefix;
			this.writer = writer;
			this.parser = parser;
		}
	}

} static CustomFieldDefinition getCustomFieldByName(List<CustomFieldDefinition> customFieldDefinitions, String fieldName) {
    for (CustomFieldDefinition customFieldDefinition : customFieldDefinitions) {
        if (customFieldDefinition.getName().equals(fieldName)) {
            return customFieldDefinition;
        }
    }
    throw new RuntimeException("Custom Field definition '" + fieldName + "' is not found on server.");
} public static Issue createIssue(Transport transport, int projectId) throws RedmineException {
    return generateRandomIssue(transport, projectId).create();
} public static List<Issue> createIssues(Transport transport, int projectId, int issuesNumber) throws RedmineException {
    List<Issue> issues = new ArrayList<>(issuesNumber);
    for (int i = 0; i < issuesNumber; i++) {
        Issue issue = new Issue(transport, projectId).setSubject("some issue " + i + " " + new Date())
                .create();
        issues.add(issue);
    }
    return issues;
}
import com.taskadapter.redmineapi.bean.Changeset;
import com.taskadapter.redmineapi.bean.CustomField;
import com.taskadapter.redmineapi.bean.CustomFieldDefinition;
import com.taskadapter.redmineapi.bean.Group;
import com.taskadapter.redmineapi.bean.Issue;
import com.taskadapter.redmineapi.bean.IssueCategory;
import com.taskadapter.redmineapi.bean.IssueRelation;
import com.taskadapter.redmineapi.bean.IssueStatus;
import com.taskadapter.redmineapi.bean.Journal;
import com.taskadapter.redmineapi.bean.JournalDetail;
import com.taskadapter.redmineapi.bean.Membership;
import com.taskadapter.redmineapi.bean.Project;
import com.taskadapter.redmineapi.bean.Role;
import com.taskadapter.redmineapi.bean.SavedQuery;
import com.taskadapter.redmineapi.bean.Tracker;
import com.taskadapter.redmineapi.bean.User;
import com.taskadapter.redmineapi.bean.Version;
import com.taskadapter.redmineapi.bean.Watcher;
import com.taskadapter.redmineapi.internal.RequestParam;
import com.taskadapter.redmineapi.internal.ResultsWrapper;
import com.taskadapter.redmineapi.internal.Transport;
import org.apache.http.client.HttpClient;
import org.junit.AfterClass;
import org.junit.BeforeClass;
import org.junit.Ignore;
import org.junit.Test;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Calendar;
import java.util.Collection;
import java.util.Collections;
import java.util.Date;
import java.util.HashMap;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Random;
import java.util.Set;
import java.util.UUID;
import static com.taskadapter.redmineapi.CustomFieldResolver.getCustomFieldByName;
import static com.taskadapter.redmineapi.IssueHelper.createIssue;
import static com.taskadapter.redmineapi.IssueHelper.createIssues;
import static org.assertj.core.api.Assertions.assertThat;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertFalse;
import static org.junit.Assert.assertNotNull;
import static org.junit.Assert.assertNull;
import static org.junit.Assert.assertTrue;
import static org.junit.Assert.fail;
        Issue loadedIssue1 = RedmineTestUtils.findIssueInList(foundIssues,
                issue.getId());
        assertNotNull(loadedIssue1);
        assertEquals(summary, loadedIssue1.getSubject());
        issue.delete();
    }

    @Test
    public void findByNonExistingSummaryReturnsEmptyList() throws RedmineException {
        String summary = "some summary here for issue which does not exist";
        List<Issue> foundIssues = issueManager.getIssuesBySummary(projectKey, summary);
        assertNotNull("Search result must be not null", foundIssues);
        assertTrue("Search result list must be empty", foundIssues.isEmpty());
    }

    @Test(expected = RedmineAuthenticationException.class)
    public void noAPIKeyOnCreateIssueThrowsAE() throws Exception {
        TestConfig testConfig = new TestConfig();
        HttpClient httpClient = IntegrationTestHelper.getHttpClientForTestServer();

        RedmineManager redmineMgrEmpty = RedmineManagerFactory.createUnauthenticated(testConfig.getURI(),
                httpClient);
        new Issue(redmineMgrEmpty.getTransport(), projectId).setSubject( "test zzx")
                .create();
    }

    @Test(expected = RedmineAuthenticationException.class)
    public void wrongAPIKeyOnCreateIssueThrowsAE() throws Exception {
        TestConfig testConfig = new TestConfig();
        final HttpClient httpClient = IntegrationTestHelper.getHttpClientForTestServer();

        RedmineManager redmineMgrInvalidKey = RedmineManagerFactory.createWithApiKey(
                testConfig.getURI(), "wrong_key", httpClient);
        new Issue(redmineMgrInvalidKey.getTransport(), projectId).setSubject( "test zzx")
                .create();
    }

    @Test
    public void testIssueDoneRatio() throws RedmineException {
        Issue createdIssue = new Issue(transport, projectId).setSubject( "Issue " + new Date())
            .create();
        assertEquals("Initial 'done ratio' must be 0", (Integer) 0,
                createdIssue.getDoneRatio());
        Integer doneRatio = 50;
        createdIssue.setDoneRatio(doneRatio)
                .update();

        Integer issueId = createdIssue.getId();
        Issue reloadedFromRedmineIssue = issueManager.getIssueById(issueId);
        assertEquals(
                "Checking if 'update issue' operation changed 'done ratio' field",
                doneRatio, reloadedFromRedmineIssue.getDoneRatio());

        Integer invalidDoneRatio = 130;
        try {
            reloadedFromRedmineIssue.setDoneRatio(invalidDoneRatio)
                    .update();
        } catch (RedmineProcessingException e) {
            assertEquals("Must be 1 error", 1, e.getErrors().size());
            assertEquals("Checking error text",
                    "% Done is not included in the list", e.getErrors()
                            .get(0));
        }

        Issue reloadedFromRedmineIssueUnchanged = issueManager.getIssueById(issueId);
        assertEquals(
                "'done ratio' must have remained unchanged after invalid value",
                doneRatio, reloadedFromRedmineIssueUnchanged.getDoneRatio());
    }

    @Test
    public void nullDescriptionErasesItOnServer() throws RedmineException {
        Issue issue = new Issue(transport, projectId)
                .setSubject("Issue " + new Date())
                .setDescription("Some description")
                .create();

        assertThat(issue.getDescription()).isEqualTo("Some description");

        issue.setDescription(null);
        issueManager.update(issue);

        Integer issueId = issue.getId();
        Issue reloadedFromRedmineIssue = issueManager.getIssueById(issueId);
        assertThat(reloadedFromRedmineIssue.getDescription()).isNull();
        issue.delete();
    }

    @Test
    public void testIssueJournals() throws RedmineException {
        // create at least 1 issue
        Issue newIssue = new Issue(transport, projectId)
                .setSubject("testGetIssues: " + new Date())
                .create();

        Issue loadedIssueWithJournals = issueManager.getIssueById(newIssue.getId(),
                Include.journals);
        assertTrue(loadedIssueWithJournals.getJournals().isEmpty());

        String commentDescribingTheUpdate = "some comment describing the issue update";
        loadedIssueWithJournals.setSubject("new subject");
        loadedIssueWithJournals.setNotes(commentDescribingTheUpdate);
        issueManager.update(loadedIssueWithJournals);

        Issue loadedIssueWithJournals2 = issueManager.getIssueById(newIssue.getId(),
                Include.journals);
        assertEquals(1, loadedIssueWithJournals2.getJournals()
                .size());

        Journal journalItem = loadedIssueWithJournals2.getJournals().iterator().next();
        assertEquals(commentDescribingTheUpdate, journalItem.getNotes());
        User ourUser = IntegrationTestHelper.getOurUser(transport);
        // can't compare User objects because either of them is not
        // completely filled
        assertEquals(ourUser.getId(), journalItem.getUser().getId());
        assertEquals(ourUser.getFirstName(), journalItem.getUser()
                .getFirstName());
        assertEquals(ourUser.getLastName(), journalItem.getUser()
                .getLastName());
        assertEquals(1, journalItem.getDetails().size());
        final JournalDetail journalDetail = journalItem.getDetails().get(0);