/**
 * Linear Task Sync Adapter
 *
 * Implements the TaskSyncAdapter interface for Linear issue operations.
 * Maps between Stoneforge ExternalTask/ExternalTaskInput and Linear's
 * GraphQL API using the LinearApiClient.
 *
 * Key design points:
 * - Caches workflow states per team (fetched on first use)
 * - Builds bidirectional lookup maps: stateTypeToStateId and stateIdToType
 * - Refreshes state cache when a stateId lookup fails
 * - Uses native Linear priority (0-4) instead of label-based priority
 * - Maps status via workflow state TYPE, not state name
 */
import { createLinearFieldMapConfig, linearPriorityToStoneforge, stoneforgePriorityToLinear, linearStateTypeToStatusLabel, } from './linear-field-map.js';
// ============================================================================
// Constants
// ============================================================================
/**
 * The label name used to indicate a blocked task on Linear.
 * Linear has no native blocked state — this label is added/removed
 * by the adapter to signal blocked status.
 *
 * Added to ExternalTaskInput.labels by buildExternalLabels() when a task
 * has status=blocked and the provider has no statusLabels config.
 */
const BLOCKED_LABEL = 'blocked';
// ============================================================================
// LinearTaskAdapter
// ============================================================================
/**
 * TaskSyncAdapter implementation for Linear.
 *
 * Provides bidirectional sync between Stoneforge tasks and Linear issues.
 * Uses workflow state caching for efficient status mapping and handles
 * Linear's native priority field directly (no label convention needed).
 *
 * @example
 * ```typescript
 * const client = new LinearApiClient({ apiKey: 'lin_api_...' });
 * const adapter = new LinearTaskAdapter(client);
 *
 * // Fetch a single issue
 * const issue = await adapter.getIssue('ENG', 'uuid-here');
 *
 * // List recent changes
 * const issues = await adapter.listIssuesSince('ENG', '2024-01-01T00:00:00Z');
 * ```
 */
export class LinearTaskAdapter {
    api;
    /** Cached workflow states keyed by team ID */
    stateCacheByTeam = new Map();
    /** Cached team lookups keyed by team key (e.g., "ENG") */
    teamByKey = new Map();
    /** Cached label name→ID lookup (workspace-level) */
    labelsByName = new Map();
    /** Whether labels have been fetched from the API */
    labelsCached = false;
    constructor(api) {
        this.api = api;
    }
    // --------------------------------------------------------------------------
    // TaskSyncAdapter Implementation
    // --------------------------------------------------------------------------
    /**
     * Fetch a single Linear issue by external ID (UUID) and convert to ExternalTask.
     *
     * @param project - Team key (e.g., "ENG")
     * @param externalId - Linear issue UUID
     * @returns ExternalTask or null if not found
     */
    async getIssue(project, externalId) {
        const issue = await this.api.getIssue(externalId);
        if (!issue) {
            return null;
        }
        return this.linearIssueToExternalTask(issue, project);
    }
    /**
     * List Linear issues updated since a given timestamp.
     *
     * @param project - Team key (e.g., "ENG")
     * @param since - ISO 8601 timestamp
     * @returns Array of ExternalTask
     */
    async listIssuesSince(project, since) {
        const issues = await this.api.listIssuesSince(project, since);
        return issues.map((issue) => this.linearIssueToExternalTask(issue, project));
    }
    /**
     * Create a new issue in Linear.
     *
     * Resolves the team key to a team ID, maps ExternalTaskInput fields to
     * Linear mutation input (title, description, priority, stateId), and
     * calls the API to create the issue.
     *
     * @param project - Team key (e.g., "ENG")
     * @param issue - ExternalTaskInput with the fields to set
     * @returns The created ExternalTask
     */
    async createIssue(project, issue) {
        // Resolve team key to team ID
        const team = await this.resolveTeam(project);
        // Build create input (handles state type mapping including blocked→started)
        const input = await this.buildCreateInput(team, issue);
        // Resolve ALL labels to Linear label IDs:
        // - "blocked" label (special handling for blocked state)
        // - sf:type:* labels (task type labels synced as Linear labels)
        // - sf:priority:* labels (synced alongside native priority for lossless round-tripping)
        // - sf:status:* labels (synced alongside native workflow states for lossless round-tripping)
        // - User tags (non-prefixed labels synced as Linear labels)
        const labelIds = await this.resolveInputLabelIds(team.id, issue.labels);
        if (labelIds.length > 0) {
            input.labelIds = labelIds;
        }
        // Create issue via API
        const created = await this.api.createIssue(input);
        return this.linearIssueToExternalTask(created, project);
    }
    /**
     * Update an existing issue in Linear.
     *
     * Maps partial ExternalTaskInput to Linear mutation input and calls
     * the API to update the issue.
     *
     * @param project - Team key (e.g., "ENG")
     * @param externalId - Linear issue UUID
     * @param updates - Partial ExternalTaskInput with fields to update
     * @returns The updated ExternalTask
     */
    async updateIssue(project, externalId, updates) {
        // Resolve team for state mapping
        const team = await this.resolveTeam(project);
        // Build update input (handles state type mapping including blocked→started)
        const input = await this.buildUpdateInput(team, updates);
        // Compute the full set of label IDs for the update, comparing the desired
        // labels against the current issue's labels. Only sends labelIds if the
        // set actually changed (avoids unnecessary writes).
        if (updates.labels !== undefined) {
            const labelIds = await this.computeLabelIds(team.id, externalId, updates.labels);
            if (labelIds !== undefined) {
                input.labelIds = labelIds;
            }
        }
        // Update issue via API
        const updated = await this.api.updateIssue(externalId, input);
        return this.linearIssueToExternalTask(updated, project);
    }
    /**
     * Returns the Linear-specific TaskFieldMapConfig.
     */
    getFieldMapConfig() {
        return createLinearFieldMapConfig();
    }
    // --------------------------------------------------------------------------
    // Conversion: Linear Issue → ExternalTask
    // --------------------------------------------------------------------------
    /**
     * Converts a Linear issue API response to the normalized ExternalTask format.
     *
     * Injects a synthetic sf:status:* label based on the workflow state type.
     * This allows the generic field mapping system (stateToStatus) to extract
     * granular statuses without the sync engine needing Linear-specific logic.
     */
    linearIssueToExternalTask(issue, project) {
        // Map state type to open/closed for ExternalTask's binary state
        const stateType = issue.state.type;
        const isCompleted = stateType === 'completed' || stateType === 'canceled';
        // Collect labels as string names
        const labels = issue.labels.nodes.map((label) => label.name);
        // Inject a sf:status:* label based on workflow state type.
        // This communicates the granular Linear state through the generic label-based
        // field mapping system, keeping the sync engine provider-agnostic.
        const statusLabel = linearStateTypeToStatusLabel(stateType);
        labels.push(statusLabel);
        // Build assignees list (Linear supports single assignee)
        const assignees = [];
        if (issue.assignee) {
            assignees.push(issue.assignee.name);
        }
        // Store Linear-specific data in raw for lossless round-tripping
        const raw = {
            linearPriority: issue.priority,
            linearStateType: stateType,
            linearStateId: issue.state.id,
            linearStateName: issue.state.name,
            linearIdentifier: issue.identifier,
            linearTeamKey: issue.team.key,
            linearTeamId: issue.team.id,
        };
        // Include archived info if present
        if (issue.archivedAt) {
            raw.linearArchivedAt = issue.archivedAt;
        }
        return {
            externalId: issue.id,
            url: issue.url,
            provider: 'linear',
            project,
            title: issue.title,
            body: issue.description ?? undefined,
            state: isCompleted ? 'closed' : 'open',
            labels,
            assignees,
            // Convert Linear native priority (0-4) to Stoneforge priority (1-5)
            priority: linearPriorityToStoneforge(issue.priority),
            createdAt: issue.createdAt,
            updatedAt: issue.updatedAt,
            closedAt: isCompleted ? issue.updatedAt : undefined,
            raw,
        };
    }
    // --------------------------------------------------------------------------
    // Input Building: ExternalTaskInput → Linear API Input
    // --------------------------------------------------------------------------
    /**
     * Builds a CreateIssueInput from an ExternalTaskInput.
     */
    async buildCreateInput(team, issue) {
        const input = {
            teamId: team.id,
            title: issue.title,
        };
        // Map description
        if (issue.body) {
            input.description = issue.body;
        }
        // Map state to workflow state ID.
        // Use a richer mapping that accounts for blocked tasks: the "blocked"
        // label in the input signals that the task is blocked and should map
        // to the 'started' state type (not 'unstarted'), since a blocked task
        // was actively worked on and got stuck.
        if (issue.state) {
            const isBlocked = issue.labels?.includes(BLOCKED_LABEL) ?? false;
            let targetStateType;
            if (issue.state === 'closed') {
                targetStateType = 'completed';
            }
            else if (isBlocked) {
                targetStateType = 'started';
            }
            else {
                targetStateType = 'unstarted';
            }
            const stateId = await this.resolveStateId(team.id, targetStateType);
            if (stateId) {
                input.stateId = stateId;
            }
        }
        // Map priority: convert Stoneforge priority (1-5) to Linear native priority (0-4).
        // If no priority is provided, default to Linear 0 (No priority).
        if (issue.priority !== undefined) {
            input.priority = stoneforgePriorityToLinear(issue.priority);
        }
        else {
            input.priority = 0; // Linear "No priority"
        }
        return input;
    }
    /**
     * Builds an UpdateIssueInput from a partial ExternalTaskInput.
     */
    async buildUpdateInput(team, updates) {
        const input = {};
        if (updates.title !== undefined) {
            input.title = updates.title;
        }
        if (updates.body !== undefined) {
            input.description = updates.body;
        }
        // Map state to workflow state ID.
        // Use a richer mapping that accounts for blocked tasks: if the "blocked"
        // label is in the update's labels, the task should map to 'started' state
        // type (not 'unstarted'), since blocked tasks were in-progress and got stuck.
        if (updates.state !== undefined) {
            const isBlocked = updates.labels?.includes(BLOCKED_LABEL) ?? false;
            let targetStateType;
            if (updates.state === 'closed') {
                targetStateType = 'completed';
            }
            else if (isBlocked) {
                targetStateType = 'started';
            }
            else {
                targetStateType = 'unstarted';
            }
            const stateId = await this.resolveStateId(team.id, targetStateType);
            if (stateId) {
                input.stateId = stateId;
            }
        }
        // Map priority: convert Stoneforge priority (1-5) to Linear native priority (0-4)
        if (updates.priority !== undefined) {
            input.priority = stoneforgePriorityToLinear(updates.priority);
        }
        return input;
    }
    // --------------------------------------------------------------------------
    // Workflow State Caching
    // --------------------------------------------------------------------------
    /**
     * Resolves a team key (e.g., "ENG") to a LinearTeam object.
     * Caches the team lookup.
     */
    async resolveTeam(teamKey) {
        // Check cache first
        const cached = this.teamByKey.get(teamKey);
        if (cached) {
            return cached;
        }
        // Fetch all teams and find the matching one
        const teams = await this.api.getTeams();
        for (const team of teams) {
            this.teamByKey.set(team.key, team);
        }
        const team = this.teamByKey.get(teamKey);
        if (!team) {
            throw new Error(`Linear team with key "${teamKey}" not found. Available teams: ${teams.map((t) => t.key).join(', ')}`);
        }
        return team;
    }
    /**
     * Gets or creates the workflow state cache for a team.
     * Fetches workflow states from the API on first use.
     */
    async getStateCache(teamId) {
        const cached = this.stateCacheByTeam.get(teamId);
        if (cached) {
            return cached;
        }
        return this.refreshStateCache(teamId);
    }
    /**
     * Fetches workflow states from the API and rebuilds the cache.
     */
    async refreshStateCache(teamId) {
        const states = await this.api.getTeamWorkflowStates(teamId);
        const stateTypeToStateId = new Map();
        const stateIdToType = new Map();
        for (const state of states) {
            // For stateTypeToStateId, use the first state matching each type
            if (!stateTypeToStateId.has(state.type)) {
                stateTypeToStateId.set(state.type, state.id);
            }
            // For stateIdToType, map every state ID
            stateIdToType.set(state.id, state.type);
        }
        const cache = {
            teamId,
            stateTypeToStateId,
            stateIdToType,
            states,
        };
        this.stateCacheByTeam.set(teamId, cache);
        return cache;
    }
    /**
     * Resolves a Linear workflow state type to a state ID.
     * Uses the cached states, refreshing if the type is not found.
     */
    async resolveStateId(teamId, stateType) {
        // Try cached lookup
        let cache = await this.getStateCache(teamId);
        let stateId = cache.stateTypeToStateId.get(stateType);
        if (stateId) {
            return stateId;
        }
        // Cache miss — workflow states may have been modified. Refresh.
        cache = await this.refreshStateCache(teamId);
        stateId = cache.stateTypeToStateId.get(stateType);
        return stateId;
    }
    /**
     * Resolves a Linear state ID to a workflow state type.
     * Uses the cached states, refreshing if the ID is not found.
     */
    async resolveStateType(teamId, stateId) {
        // Try cached lookup
        let cache = await this.getStateCache(teamId);
        let stateType = cache.stateIdToType.get(stateId);
        if (stateType) {
            return stateType;
        }
        // Cache miss — workflow states may have been modified. Refresh.
        cache = await this.refreshStateCache(teamId);
        stateType = cache.stateIdToType.get(stateId);
        return stateType;
    }
    // --------------------------------------------------------------------------
    // Label Management
    // --------------------------------------------------------------------------
    /**
     * Filters input labels to only those that should be synced as Linear labels.
     *
     * All label types are now synced to Linear for lossless round-tripping:
     * - sf:priority:* labels (synced alongside native priority for round-tripping)
     * - sf:status:* labels (synced alongside native workflow states for round-tripping)
     * - "blocked" label (synced as a Linear label)
     * - sf:type:* labels (task type — Linear has no native type concept)
     * - User tags (non-prefixed labels)
     *
     * This method exists as an extension point for future label filtering needs.
     */
    filterSyncableLabels(labels) {
        return labels.filter(() => {
            return true;
        });
    }
    /**
     * Resolves a label name to a Linear label ID.
     * Uses the label cache, fetching from the API on first call.
     * Creates the label if it doesn't exist.
     *
     * @param labelName - The label name to resolve
     * @param teamId - Team ID to associate with a newly created label
     * @returns The label ID, or undefined if resolution fails
     */
    async resolveLabelId(labelName, teamId) {
        // Check cache first
        const cached = this.labelsByName.get(labelName);
        if (cached) {
            return cached;
        }
        // Fetch workspace labels if not already cached
        if (!this.labelsCached) {
            await this.refreshLabelCache();
        }
        // Check again after refresh
        const labelId = this.labelsByName.get(labelName);
        if (labelId) {
            return labelId;
        }
        // Label doesn't exist — create it
        try {
            const created = await this.api.createLabel(labelName, teamId);
            this.labelsByName.set(created.name, created.id);
            return created.id;
        }
        catch {
            // If label creation fails (e.g., permission issue), log but don't block
            console.warn(`[LinearTaskAdapter] Failed to create "${labelName}" label in Linear.`);
            return undefined;
        }
    }
    /**
     * Resolves all syncable labels from an ExternalTaskInput to Linear label IDs.
     * All labels (including sf:priority:* and sf:status:*) are synced to Linear
     * for lossless round-tripping alongside native fields.
     *
     * @param teamId - Team ID for label creation
     * @param labels - Input labels from ExternalTaskInput
     * @returns Array of resolved Linear label IDs
     */
    async resolveInputLabelIds(teamId, labels) {
        if (!labels || labels.length === 0) {
            return [];
        }
        const syncableLabels = this.filterSyncableLabels(labels);
        if (syncableLabels.length === 0) {
            return [];
        }
        const labelIds = [];
        for (const labelName of syncableLabels) {
            const labelId = await this.resolveLabelId(labelName, teamId);
            if (labelId) {
                labelIds.push(labelId);
            }
        }
        return labelIds;
    }
    /**
     * Computes the new labelIds for an issue update by comparing the desired
     * labels against the current issue's labels on Linear.
     *
     * Handles all label types:
     * - "blocked" label (added/removed based on blocked status)
     * - sf:type:* labels (task type synced as Linear labels)
     * - sf:priority:* labels (synced alongside native priority for lossless round-tripping)
     * - sf:status:* labels (synced alongside native workflow states for lossless round-tripping)
     * - User tags (synced as Linear labels)
     *
     * Returns undefined if no label change is needed (avoids unnecessary writes).
     *
     * @param teamId - Team ID (for label creation if needed)
     * @param externalId - Issue UUID to fetch current labels from
     * @param desiredLabels - The full set of desired labels from ExternalTaskInput
     * @returns New labelIds array, or undefined if no change needed
     */
    async computeLabelIds(teamId, externalId, desiredLabels) {
        // Resolve desired labels to Linear label IDs
        const desiredLabelIds = await this.resolveInputLabelIds(teamId, desiredLabels);
        // Fetch current issue to get existing label IDs
        const currentIssue = await this.api.getIssue(externalId);
        if (!currentIssue) {
            return desiredLabelIds.length > 0 ? desiredLabelIds : undefined;
        }
        const currentLabelIds = currentIssue.labels.nodes.map((l) => l.id);
        // Compare sets: check if they're identical
        const currentSet = new Set(currentLabelIds);
        const desiredSet = new Set(desiredLabelIds);
        if (currentSet.size === desiredSet.size &&
            [...currentSet].every((id) => desiredSet.has(id))) {
            // No change needed
            return undefined;
        }
        return desiredLabelIds;
    }
    /**
     * Fetches all workspace labels and populates the label cache.
     */
    async refreshLabelCache() {
        const labels = await this.api.getLabels();
        this.labelsByName.clear();
        for (const label of labels) {
            this.labelsByName.set(label.name, label.id);
        }
        this.labelsCached = true;
    }
}
//# sourceMappingURL=linear-task-adapter.js.map