import { ServiceBubble } from '../../../types/service-bubble-class.js';
import { CredentialType, decodeCredentialPayload, } from '@bubblelab/shared-schemas';
import { JiraParamsSchema, JiraResultSchema, } from './jira.schema.js';
import { textToADF, adfToText, enhanceErrorMessage, normalizeDate, findTransitionByStatus, } from './jira.utils.js';
/**
 * Jira Service Bubble
 *
 * Agent-friendly Jira integration with minimal required parameters and smart defaults.
 *
 * Core Operations:
 * - search: Find issues using JQL queries
 * - get: Get details for a specific issue
 * - create: Create new issues with automatic ADF conversion
 * - update: Modify existing issues with flexible label operations
 * - transition: Change issue status by name (no ID lookup needed)
 *
 * Supporting Operations:
 * - list_transitions: Get available status transitions
 * - list_projects: Discover available projects
 * - list_issue_types: Get issue types for a project
 * - add_comment: Add a comment to an issue
 * - get_comments: Retrieve comments for an issue
 *
 * Features:
 * - Markdown or plain text descriptions/comments auto-converted to Atlassian Document Format (ADF)
 * - Supports markdown: **bold**, *italic*, `code`, [links](url), # headings, lists, > blockquotes, ``` code blocks ```, ~~strikethrough~~
 * - Status transitions by name (e.g., "Done") instead of IDs
 * - Flexible label operations (add/remove/set)
 * - Smart defaults for 90% of use cases
 *
 * Security Features:
 * - API token authentication via Jira Cloud
 * - Secure credential injection at runtime
 */
export class JiraBubble extends ServiceBubble {
    static type = 'service';
    static service = 'jira';
    static authType = 'oauth';
    static bubbleName = 'jira';
    static schema = JiraParamsSchema;
    static resultSchema = JiraResultSchema;
    static shortDescription = 'Jira integration for issue tracking and project management';
    static longDescription = `
    Agent-friendly Jira integration with minimal required parameters and smart defaults.

    Core Operations:
    - search: Find issues using JQL queries
    - get: Get details for a specific issue
    - create: Create new issues with automatic ADF conversion
    - update: Modify existing issues with flexible label operations
    - transition: Change issue status by name (no ID lookup needed)

    Supporting Operations:
    - list_transitions: Get available status transitions
    - list_projects: Discover available projects
    - list_issue_types: Get issue types for a project
    - add_comment: Add a comment to an issue
    - get_comments: Retrieve comments for an issue

    Features:
    - Markdown or plain text descriptions/comments auto-converted to Atlassian Document Format (ADF)
    - Status transitions by name (e.g., "Done") instead of IDs
    - Flexible label operations (add/remove/set)
    - Smart defaults for 90% of use cases

    Authentication:
    - OAuth 2.0 (recommended): Connect via Atlassian OAuth for secure access
    - API token (legacy): Use email + API token for Basic auth
    - Secure credential injection at runtime
  `;
    static alias = 'jira';
    constructor(params, context) {
        super(params, context);
    }
    async testCredential() {
        const apiToken = this.chooseCredential();
        if (!apiToken) {
            throw new Error('Jira credentials are required');
        }
        try {
            // Test credentials by fetching user info
            const response = await this.makeJiraApiRequest('/rest/api/3/myself', 'GET');
            return Boolean(response && response.accountId);
        }
        catch {
            return false;
        }
    }
    /**
     * Jira credential types:
     * 1. OAuth (recommended): { accessToken, cloudId, siteUrl } - Uses Bearer auth with Atlassian Cloud API
     * 2. API Token (legacy): { accessToken, baseUrl, email } - Uses Basic auth with Jira instance
     */
    parseCredentials() {
        const { credentials } = this.params;
        if (!credentials || typeof credentials !== 'object') {
            return null;
        }
        const jiraCredRaw = credentials[CredentialType.JIRA_CRED];
        if (!jiraCredRaw) {
            return null;
        }
        try {
            const parsed = decodeCredentialPayload(jiraCredRaw);
            if (parsed.accessToken && parsed.cloudId) {
                return {
                    accessToken: parsed.accessToken,
                    baseUrl: `https://api.atlassian.com/ex/jira/${parsed.cloudId}`,
                };
            }
        }
        catch {
            // Invalid credential format
        }
        return null;
    }
    async makeJiraApiRequest(endpoint, method = 'GET', body) {
        const creds = this.parseCredentials();
        if (!creds) {
            throw new Error('Invalid Jira credentials. Expected base64-encoded JSON with { accessToken, cloudId }.');
        }
        const url = `${creds.baseUrl}${endpoint}`;
        const requestHeaders = {
            Authorization: `Bearer ${creds.accessToken}`,
            'Content-Type': 'application/json',
            Accept: 'application/json',
        };
        const requestInit = {
            method,
            headers: requestHeaders,
        };
        if (body && method !== 'GET') {
            requestInit.body = JSON.stringify(body);
        }
        const response = await fetch(url, requestInit);
        if (!response.ok) {
            const errorText = await response.text();
            const enhancedError = enhanceErrorMessage(errorText, response.status, response.statusText);
            throw new Error(enhancedError);
        }
        // Handle empty responses (204 No Content)
        if (response.status === 204) {
            return {};
        }
        const contentType = response.headers.get('content-type');
        if (contentType && contentType.includes('application/json')) {
            return (await response.json());
        }
        return {};
    }
    /**
     * Resolves an assignee (email or accountId) to a Jira accountId.
     * If the assignee contains '@', it's treated as an email and looked up via the Jira API.
     * Otherwise, it's assumed to be an accountId and returned as-is.
     *
     * @param assignee - Email address or accountId
     * @returns The resolved accountId
     * @throws Error if assignee is an email but no user is found
     */
    async resolveAssigneeAccountId(assignee) {
        // If it doesn't contain '@', assume it's already an accountId
        if (!assignee.includes('@')) {
            return assignee;
        }
        // It's an email, look up the user
        const queryParams = new URLSearchParams({
            query: assignee,
        });
        try {
            const response = await this.makeJiraApiRequest(`/rest/api/3/user/search?${queryParams.toString()}`, 'GET');
            // Jira user search API returns an array directly
            // Handle both array response and object-wrapped response
            const usersArray = Array.isArray(response)
                ? response
                : response.values;
            if (!Array.isArray(usersArray) || usersArray.length === 0) {
                throw new Error(`No user found with email "${assignee}". Please verify the email address or use an accountId instead.`);
            }
            const users = usersArray;
            // Find exact email match (case-insensitive)
            const matchingUser = users.find((user) => user.emailAddress?.toLowerCase() === assignee.toLowerCase());
            if (!matchingUser || !matchingUser.accountId) {
                throw new Error(`No user found with email "${assignee}". Please verify the email address or use an accountId instead.`);
            }
            return matchingUser.accountId;
        }
        catch (error) {
            if (error instanceof Error) {
                throw error;
            }
            throw new Error(`Failed to lookup user with email "${assignee}": ${String(error)}`);
        }
    }
    async performAction(context) {
        void context;
        const { operation } = this.params;
        try {
            const result = await (async () => {
                const parsedParams = this.params;
                switch (operation) {
                    case 'search':
                        return await this.search(parsedParams);
                    case 'get':
                        return await this.get(parsedParams);
                    case 'create':
                        return await this.create(parsedParams);
                    case 'update':
                        return await this.update(parsedParams);
                    case 'transition':
                        return await this.transition(parsedParams);
                    case 'list_transitions':
                        return await this.listTransitions(parsedParams);
                    case 'list_projects':
                        return await this.listProjects(parsedParams);
                    case 'list_issue_types':
                        return await this.listIssueTypes(parsedParams);
                    case 'get_create_fields':
                        return await this.getCreateFields(parsedParams);
                    case 'add_comment':
                        return await this.addComment(parsedParams);
                    case 'get_comments':
                        return await this.getComments(parsedParams);
                    default:
                        throw new Error(`Unsupported operation: ${operation}`);
                }
            })();
            return result;
        }
        catch (error) {
            return {
                operation,
                success: false,
                error: error instanceof Error ? error.message : 'Unknown error occurred',
            };
        }
    }
    // -------------------------------------------------------------------------
    // CORE OPERATION 1: search
    // -------------------------------------------------------------------------
    async search(params) {
        const { jql, limit, offset, fields } = params;
        const queryParams = new URLSearchParams({
            jql,
            startAt: String(offset ?? 0),
            maxResults: String(limit ?? 50),
        });
        // Default fields to request if not specified
        // The /search/jql endpoint requires explicit field specification
        // Note: 'key' must be explicitly requested with this endpoint
        const defaultFields = [
            'key',
            'summary',
            'status',
            'priority',
            'assignee',
            'reporter',
            'issuetype',
            'project',
            'labels',
            'created',
            'updated',
            'duedate',
            'description',
            'parent',
            'comment',
        ];
        const fieldsToRequest = fields && fields.length > 0 ? fields : defaultFields;
        queryParams.set('fields', fieldsToRequest.join(','));
        // Use the new /search/jql endpoint (old /search was deprecated and returns 410)
        const response = await this.makeJiraApiRequest(`/rest/api/3/search/jql?${queryParams.toString()}`, 'GET');
        // Convert comment bodies from ADF to plain text if comments are present
        if (Array.isArray(response.issues)) {
            response.issues = response.issues.map((issue) => {
                if (issue &&
                    typeof issue === 'object' &&
                    'fields' in issue &&
                    issue.fields &&
                    typeof issue.fields === 'object' &&
                    'comment' in issue.fields &&
                    issue.fields.comment &&
                    typeof issue.fields.comment === 'object' &&
                    'comments' in issue.fields.comment &&
                    Array.isArray(issue.fields.comment.comments)) {
                    const comments = issue.fields.comment.comments;
                    issue.fields.comment.comments = comments.map((c) => ({
                        ...c,
                        body: adfToText(c.body),
                        renderedBody: adfToText(c.body),
                    }));
                }
                return issue;
            });
        }
        // The /search/jql endpoint returns different pagination fields:
        // - total: may not be present, use issues.length as fallback
        // - startAt: pagination offset
        // - maxResults: requested limit
        // - isLast: boolean indicating if this is the last page
        const issuesArray = Array.isArray(response.issues) ? response.issues : [];
        return {
            operation: 'search',
            success: true,
            issues: issuesArray,
            total: response.total ?? issuesArray.length,
            offset: response.startAt ?? offset ?? 0,
            limit: response.maxResults ?? limit ?? 50,
            error: '',
        };
    }
    // -------------------------------------------------------------------------
    // CORE OPERATION 2: get
    // -------------------------------------------------------------------------
    async get(params) {
        const { key, fields, expand } = params;
        const queryParams = new URLSearchParams();
        if (fields && fields.length > 0) {
            queryParams.set('fields', fields.join(','));
        }
        if (expand && expand.length > 0) {
            // Map expand options to Jira API expand values
            const expandValues = expand.map((e) => {
                switch (e) {
                    case 'comments':
                        return 'renderedFields';
                    case 'changelog':
                        return 'changelog';
                    case 'transitions':
                        return 'transitions';
                    default:
                        return e;
                }
            });
            queryParams.set('expand', expandValues.join(','));
        }
        const queryString = queryParams.toString();
        const endpoint = `/rest/api/3/issue/${encodeURIComponent(key)}${queryString ? `?${queryString}` : ''}`;
        const response = await this.makeJiraApiRequest(endpoint, 'GET');
        // Convert comment bodies from ADF to plain text if comments are present
        if (response.fields &&
            typeof response.fields === 'object' &&
            'comment' in response.fields &&
            response.fields.comment &&
            typeof response.fields.comment === 'object' &&
            'comments' in response.fields.comment &&
            Array.isArray(response.fields.comment.comments)) {
            const comments = response.fields.comment.comments;
            response.fields.comment.comments = comments.map((c) => ({
                ...c,
                body: adfToText(c.body),
                renderedBody: adfToText(c.body),
            }));
        }
        return {
            operation: 'get',
            success: true,
            issue: response,
            error: '',
        };
    }
    // -------------------------------------------------------------------------
    // CORE OPERATION 3: create
    // -------------------------------------------------------------------------
    async create(params) {
        const { project, summary, type, description, assignee, priority, labels, parent, due_date, custom_fields, } = params;
        const fields = {
            project: { key: project },
            summary,
            issuetype: { name: type ?? 'Task' },
        };
        if (description) {
            fields.description = textToADF(description);
        }
        if (assignee) {
            const accountId = await this.resolveAssigneeAccountId(assignee);
            fields.assignee = { accountId };
        }
        if (priority) {
            fields.priority = { name: priority };
        }
        if (labels && labels.length > 0) {
            fields.labels = labels;
        }
        if (parent) {
            fields.parent = { key: parent };
        }
        if (due_date) {
            const normalizedDate = normalizeDate(due_date);
            if (normalizedDate) {
                fields.duedate = normalizedDate;
            }
        }
        if (custom_fields) {
            for (const [fieldId, value] of Object.entries(custom_fields)) {
                fields[fieldId] = value;
            }
        }
        const response = await this.makeJiraApiRequest('/rest/api/3/issue', 'POST', { fields });
        return {
            operation: 'create',
            success: true,
            issue: {
                id: response.id,
                key: response.key,
                self: response.self,
            },
            error: '',
        };
    }
    // -------------------------------------------------------------------------
    // CORE OPERATION 4: update
    // -------------------------------------------------------------------------
    async update(params) {
        const { key, summary, description, assignee, priority, labels, due_date, comment, } = params;
        const fields = {};
        const update = {};
        if (summary !== undefined) {
            fields.summary = summary;
        }
        if (description !== undefined) {
            fields.description = textToADF(description);
        }
        if (assignee !== undefined) {
            if (assignee === null) {
                fields.assignee = null;
            }
            else {
                const accountId = await this.resolveAssigneeAccountId(assignee);
                fields.assignee = { accountId };
            }
        }
        if (priority !== undefined) {
            fields.priority = { name: priority };
        }
        if (due_date !== undefined) {
            if (due_date === null) {
                fields.duedate = null;
            }
            else {
                const normalizedDate = normalizeDate(due_date);
                if (normalizedDate) {
                    fields.duedate = normalizedDate;
                }
            }
        }
        // Handle labels modification
        if (labels !== undefined) {
            if (labels.set) {
                // Replace all labels
                fields.labels = labels.set;
            }
            else {
                // Use update operations for add/remove
                const labelOps = [];
                if (labels.add) {
                    labels.add.forEach((label) => labelOps.push({ add: label }));
                }
                if (labels.remove) {
                    labels.remove.forEach((label) => labelOps.push({ remove: label }));
                }
                if (labelOps.length > 0) {
                    update.labels = labelOps;
                }
            }
        }
        const body = {};
        if (Object.keys(fields).length > 0) {
            body.fields = fields;
        }
        if (Object.keys(update).length > 0) {
            body.update = update;
        }
        // Update the issue
        await this.makeJiraApiRequest(`/rest/api/3/issue/${encodeURIComponent(key)}`, 'PUT', body);
        // Add comment if provided
        if (comment) {
            await this.makeJiraApiRequest(`/rest/api/3/issue/${encodeURIComponent(key)}/comment`, 'POST', { body: textToADF(comment) });
        }
        return {
            operation: 'update',
            success: true,
            key,
            error: '',
        };
    }
    // -------------------------------------------------------------------------
    // CORE OPERATION 5: transition
    // -------------------------------------------------------------------------
    async transition(params) {
        const { key, status, transition_id, comment, resolution } = params;
        let targetTransitionId = transition_id;
        let targetStatusName;
        // If status name is provided, find the matching transition
        if (status && !transition_id) {
            const transitionsResponse = await this.makeJiraApiRequest(`/rest/api/3/issue/${encodeURIComponent(key)}/transitions`, 'GET');
            const transitions = transitionsResponse.transitions;
            const matchingTransition = findTransitionByStatus(transitions, status);
            if (!matchingTransition) {
                const availableTransitions = transitions
                    .map((t) => `"${t.name}" → ${t.to?.name || 'unknown'}`)
                    .join(', ');
                throw new Error(`No transition found to status "${status}". Available transitions: ${availableTransitions}`);
            }
            targetTransitionId = matchingTransition.id;
            targetStatusName = matchingTransition.to?.name || matchingTransition.name;
        }
        if (!targetTransitionId) {
            throw new Error('Either status or transition_id is required');
        }
        const body = {
            transition: { id: targetTransitionId },
        };
        // Add fields if resolution is specified
        if (resolution) {
            body.fields = {
                resolution: { name: resolution },
            };
        }
        // Add update with comment if provided
        if (comment) {
            body.update = {
                comment: [{ add: { body: textToADF(comment) } }],
            };
        }
        await this.makeJiraApiRequest(`/rest/api/3/issue/${encodeURIComponent(key)}/transitions`, 'POST', body);
        return {
            operation: 'transition',
            success: true,
            key,
            new_status: targetStatusName,
            error: '',
        };
    }
    // -------------------------------------------------------------------------
    // SUPPORTING OPERATION: list_transitions
    // -------------------------------------------------------------------------
    async listTransitions(params) {
        const { key } = params;
        const response = await this.makeJiraApiRequest(`/rest/api/3/issue/${encodeURIComponent(key)}/transitions`, 'GET');
        return {
            operation: 'list_transitions',
            success: true,
            transitions: response.transitions,
            error: '',
        };
    }
    // -------------------------------------------------------------------------
    // SUPPORTING OPERATION: list_projects
    // -------------------------------------------------------------------------
    async listProjects(params) {
        const { limit, offset } = params;
        const queryParams = new URLSearchParams({
            startAt: String(offset ?? 0),
            maxResults: String(limit ?? 50),
        });
        const response = await this.makeJiraApiRequest(`/rest/api/3/project/search?${queryParams.toString()}`, 'GET');
        return {
            operation: 'list_projects',
            success: true,
            projects: response.values,
            total: response.total,
            error: '',
        };
    }
    // -------------------------------------------------------------------------
    // SUPPORTING OPERATION: list_issue_types
    // -------------------------------------------------------------------------
    async listIssueTypes(params) {
        const { project } = params;
        const response = await this.makeJiraApiRequest(`/rest/api/3/project/${encodeURIComponent(project)}`, 'GET');
        // Issue types are nested in the project response
        const issueTypes = response.issueTypes;
        return {
            operation: 'list_issue_types',
            success: true,
            issue_types: issueTypes?.map((it) => ({
                id: it.id,
                name: it.name,
                description: it.description,
                subtask: it.subtask,
            })),
            error: '',
        };
    }
    // -------------------------------------------------------------------------
    // SUPPORTING OPERATION: get_create_fields
    // -------------------------------------------------------------------------
    async getCreateFields(params) {
        const { project, issue_type } = params;
        // Step 1: Fetch issue types for the project via the createmeta endpoint
        const issueTypesResponse = await this.makeJiraApiRequest(`/rest/api/3/issue/createmeta/${encodeURIComponent(project)}/issuetypes`, 'GET');
        const rawIssueTypes = (issueTypesResponse.values ??
            issueTypesResponse.issueTypes ??
            []);
        // Filter by issue type name if specified
        const filteredTypes = issue_type
            ? rawIssueTypes.filter((it) => it.name.toLowerCase() === issue_type.toLowerCase())
            : rawIssueTypes;
        if (issue_type && filteredTypes.length === 0) {
            const available = rawIssueTypes.map((it) => it.name).join(', ');
            throw new Error(`Issue type "${issue_type}" not found in project ${project}. Available types: ${available}`);
        }
        // Step 2: For each issue type, fetch its fields
        const issueTypesWithFields = [];
        for (const it of filteredTypes) {
            const fieldsResponse = await this.makeJiraApiRequest(`/rest/api/3/issue/createmeta/${encodeURIComponent(project)}/issuetypes/${encodeURIComponent(it.id)}`, 'GET');
            const rawFields = (fieldsResponse.values ??
                fieldsResponse.fields ??
                []);
            const fields = rawFields.map((f) => ({
                fieldId: f.fieldId,
                name: f.name,
                required: f.required,
                isCustom: f.fieldId.startsWith('customfield_'),
                schema: f.schema,
                allowedValues: f.allowedValues,
            }));
            issueTypesWithFields.push({
                id: it.id,
                name: it.name,
                fields,
            });
        }
        return {
            operation: 'get_create_fields',
            success: true,
            issue_types: issueTypesWithFields,
            error: '',
        };
    }
    // -------------------------------------------------------------------------
    // SUPPORTING OPERATION: add_comment
    // -------------------------------------------------------------------------
    async addComment(params) {
        const { key, body } = params;
        const response = await this.makeJiraApiRequest(`/rest/api/3/issue/${encodeURIComponent(key)}/comment`, 'POST', { body: textToADF(body) });
        return {
            operation: 'add_comment',
            success: true,
            comment: {
                id: response.id,
                body: body, // Return the original text for readability
                created: response.created,
                updated: response.updated,
                author: response.author,
            },
            error: '',
        };
    }
    // -------------------------------------------------------------------------
    // SUPPORTING OPERATION: get_comments
    // -------------------------------------------------------------------------
    async getComments(params) {
        const { key, limit, offset } = params;
        const queryParams = new URLSearchParams({
            startAt: String(offset ?? 0),
            maxResults: String(limit ?? 50),
        });
        const response = await this.makeJiraApiRequest(`/rest/api/3/issue/${encodeURIComponent(key)}/comment?${queryParams.toString()}`, 'GET');
        // Convert ADF body to plain text for readability
        const rawComments = response.comments;
        const comments = rawComments?.map((c) => ({
            id: c.id,
            author: c.author,
            body: adfToText(c.body), // Convert ADF to plain text
            renderedBody: adfToText(c.body), // Plain text version
            created: c.created,
            updated: c.updated,
        }));
        return {
            operation: 'get_comments',
            success: true,
            comments: comments,
            total: response.total,
            error: '',
        };
    }
    chooseCredential() {
        const { credentials } = this.params;
        if (!credentials || typeof credentials !== 'object') {
            return undefined;
        }
        // Return the raw credential value - will be parsed in parseCredentials()
        return credentials[CredentialType.JIRA_CRED];
    }
}
//# sourceMappingURL=jira.js.map