import type { Logger } from "pino";
import { JiraApiError } from "@/core/errors.js";
import { JIRA_FLOW_FIELD_ID } from "@/core/constants.js";
import type {
  ADFNode,
  IssueFlow,
  JiraAttachment,
  JiraComment,
  JiraIssue,
  JiraParent,
  JiraRawAttachment,
  JiraSearchResult,
  JiraSubtask,
  JiraTransition,
  JiraUser,
} from "@/types/index.js";
import {
  jiraIssueResponseSchema,
  jiraSearchResponseSchema,
  jiraChildrenResponseSchema,
  jiraProjectSearchResponseSchema,
  jiraSearchPageResponseSchema,
  jiraApproximateCountResponseSchema,
  type JiraSearchPageResponse,
} from "./schemas.js";

export class JiraClientImpl {
  private readonly baseUrl: string;
  private readonly authHeader: string;

  readonly email: string;

  constructor(
    domain: string,
    email: string,
    apiToken: string,
    protected readonly logger: Logger,
  ) {
    this.email = email;
    this.baseUrl = `https://${domain}.atlassian.net/rest/api/3`;
    this.authHeader = "Basic " + Buffer.from(`${email}:${apiToken}`).toString("base64");
  }

  async getIssue(issueKey: string): Promise<JiraIssue> {
    this.logger.info({ issueKey }, "Fetching Jira issue");

    const response = await fetch(
      `${this.baseUrl}/issue/${issueKey}?fields=summary,description,issuetype,status,created,updated,assignee,parent,subtasks,attachment,comment,components,customfield_10035,${JIRA_FLOW_FIELD_ID}`,
      {
        headers: {
          Authorization: this.authHeader,
          Accept: "application/json",
        },
      },
    );

    if (!response.ok) {
      throw new JiraApiError(
        `Failed to fetch issue ${issueKey}: ${response.status} ${response.statusText}`,
      );
    }

    const raw = await response.json();
    const parsed = jiraIssueResponseSchema.parse(raw);
    const flow = this.mapFlowField(raw as Record<string, unknown>);

    const description = this.normalizeDescription(parsed.fields.description);
    const { acceptanceCriteria, stacktrace } = this.extractSections(description);

    const parentKey = parsed.fields.parent?.key ?? null;
    const [childrenMap, parentDetailMap] = await Promise.all([
      this.fetchChildrenByParent([parsed.key]),
      parentKey ? this.fetchParentDetails([parentKey]) : Promise.resolve(new Map()),
    ]);

    let parent: JiraParent | null = null;
    if (parsed.fields.parent) {
      const p = parsed.fields.parent;
      const detail = parentDetailMap.get(p.key);
      parent = {
        id: p.id,
        key: p.key,
        summary: p.fields.summary,
        description: detail?.description ?? null,
        issueType: p.fields.issuetype.name,
        status: p.fields.status.name,
        flow: detail?.flow ?? null,
        attachments: detail?.attachments ?? [],
      };
    }

    return {
      id: parsed.id,
      key: parsed.key,
      summary: parsed.fields.summary,
      description: description || null,
      issueType: parsed.fields.issuetype.name,
      status: parsed.fields.status.name,
      created: parsed.fields.created ?? null,
      updated: parsed.fields.updated ?? null,
      acceptanceCriteria,
      stacktrace,
      flow: parent?.flow ?? flow ?? null,
      assignee: this.mapAssignee(parsed.fields.assignee),
      parent,
      subtasks: parsed.fields.subtasks.map((sub) => ({
        id: sub.id,
        key: sub.key,
        summary: sub.fields.summary,
        status: sub.fields.status.name,
        issueType: sub.fields.issuetype.name,
      })),
      children: childrenMap.get(parsed.key) ?? [],
      attachments: this.mapAttachments(parsed.fields.attachment),
      comments: this.mapComments(parsed.fields.comment),
      components: parsed.fields.components,
    };
  }

  async searchIssues(jql: string, maxResults = 50): Promise<JiraSearchResult> {
    const response = await fetch(`${this.baseUrl}/search/jql`, {
      method: "POST",
      headers: {
        Authorization: this.authHeader,
        Accept: "application/json",
        "Content-Type": "application/json",
      },
      body: JSON.stringify({
        jql,
        maxResults,
        fields: [
          "summary",
          "description",
          "issuetype",
          "status",
          "created",
          "updated",
          "assignee",
          "parent",
          "subtasks",
          "attachment",
          "comment",
          JIRA_FLOW_FIELD_ID,
        ],
      }),
    });

    if (!response.ok) {
      const body = await response.text();
      this.logger.debug({ status: response.status, body }, "Jira search raw error");
      throw new JiraApiError(`Jira search failed: ${response.status} ${response.statusText}`);
    }

    const raw = await response.json();
    this.logger.debug({ keys: Object.keys(raw as object) }, "Jira search response keys");
    const parsed = jiraSearchResponseSchema.parse(raw);

    const issueKeys = parsed.issues.map((i) => i.key);
    const parentKeys = [
      ...new Set(
        parsed.issues.map((i) => i.fields.parent?.key).filter((k): k is string => k != null),
      ),
    ];

    const [childrenMap, parentDetailMap] = await Promise.all([
      this.fetchChildrenByParent(issueKeys),
      this.fetchParentDetails(parentKeys),
    ]);

    return {
      total: parsed.total || parsed.issues.length,
      issues: parsed.issues.map((issue) => {
        const description = this.normalizeDescription(issue.fields.description) || null;
        const { acceptanceCriteria, stacktrace } = this.extractSections(description ?? "");
        const ownFlow = this.mapFlowField(issue as unknown as Record<string, unknown>);

        let parent: JiraParent | null = null;
        if (issue.fields.parent) {
          const p = issue.fields.parent;
          const detail = parentDetailMap.get(p.key);
          parent = {
            id: p.id,
            key: p.key,
            summary: p.fields.summary,
            description: detail?.description ?? null,
            issueType: p.fields.issuetype.name,
            status: p.fields.status.name,
            flow: detail?.flow ?? null,
            attachments: detail?.attachments ?? [],
          };
        }

        return {
          id: issue.id,
          key: issue.key,
          summary: issue.fields.summary,
          description,
          issueType: issue.fields.issuetype.name,
          status: issue.fields.status.name,
          created: issue.fields.created ?? null,
          updated: issue.fields.updated ?? null,
          acceptanceCriteria,
          stacktrace,
          flow: parent?.flow ?? ownFlow ?? null,
          assignee: this.mapAssignee(issue.fields.assignee),
          parent,
          subtasks: issue.fields.subtasks.map((sub) => ({
            id: sub.id,
            key: sub.key,
            summary: sub.fields.summary,
            status: sub.fields.status.name,
            issueType: sub.fields.issuetype.name,
          })),
          children: childrenMap.get(issue.key) ?? [],
          attachments: this.mapAttachments(issue.fields.attachment),
          comments: this.mapComments(issue.fields.comment),
          components: issue.fields.components,
        };
      }),
    };
  }

  /**
   * Paginated, lightweight search against `POST /rest/api/3/search/jql`.
   *
   * Unlike {@link searchIssues}, this does not fan out to fetch parents/children/flow —
   * callers pick their own field list. Intended for analytics scans where we iterate
   * many issues and only need a handful of fields.
   */
  async searchIssuesPage(options: {
    jql: string;
    fields: string[];
    pageSize?: number;
    nextPageToken?: string;
  }): Promise<JiraSearchPageResponse> {
    const { jql, fields, pageSize = 100, nextPageToken } = options;
    const body: Record<string, unknown> = { jql, fields, maxResults: pageSize };
    if (nextPageToken) body.nextPageToken = nextPageToken;

    const response = await fetch(`${this.baseUrl}/search/jql`, {
      method: "POST",
      headers: {
        Authorization: this.authHeader,
        Accept: "application/json",
        "Content-Type": "application/json",
      },
      body: JSON.stringify(body),
    });

    if (!response.ok) {
      const text = await response.text();
      this.logger.debug({ status: response.status, body: text }, "jira_search_page_error");
      throw new JiraApiError(
        `Jira search (page) failed: ${response.status} ${response.statusText}`,
      );
    }

    const raw = await response.json();
    return jiraSearchPageResponseSchema.parse(raw);
  }

  /**
   * Returns the approximate number of issues matching `jql` via
   * `POST /rest/api/3/search/approximate-count`. Atlassian's official replacement
   * for the (now deprecated) `total` field in search responses. One round trip,
   * no pagination — ideal for weekly bucket counters.
   */
  async countIssues(jql: string): Promise<number> {
    const response = await fetch(`${this.baseUrl}/search/approximate-count`, {
      method: "POST",
      headers: {
        Authorization: this.authHeader,
        Accept: "application/json",
        "Content-Type": "application/json",
      },
      body: JSON.stringify({ jql }),
    });

    if (!response.ok) {
      const text = await response.text();
      this.logger.debug({ status: response.status, body: text }, "jira_count_error");
      throw new JiraApiError(
        `Jira approximate-count failed: ${response.status} ${response.statusText}`,
      );
    }

    const raw = await response.json();
    const parsed = jiraApproximateCountResponseSchema.parse(raw);
    return parsed.count;
  }

  /**
   * Search without per-issue children query or parent batch enrichment — fewer API calls than
   * {@link searchIssues}. Use for polling (e.g. ticket review); call
   * {@link fetchParentDetailsForKeys} after a match when parent context is needed.
   */
  async searchIssuesLite(jql: string, maxResults = 50): Promise<JiraSearchResult> {
    const response = await fetch(`${this.baseUrl}/search/jql`, {
      method: "POST",
      headers: {
        Authorization: this.authHeader,
        Accept: "application/json",
        "Content-Type": "application/json",
      },
      body: JSON.stringify({
        jql,
        maxResults,
        fields: ["summary", "issuetype", "status", "created", "assignee", "parent", "comment"],
      }),
    });

    if (!response.ok) {
      const body = await response.text();
      this.logger.debug({ status: response.status, body }, "Jira search lite raw error");
      throw new JiraApiError(`Jira search failed: ${response.status} ${response.statusText}`);
    }

    const raw = await response.json();
    const parsed = jiraSearchResponseSchema.parse(raw);

    return {
      total: parsed.total || parsed.issues.length,
      issues: parsed.issues.map((issue) => {
        let parent: JiraParent | null = null;
        if (issue.fields.parent) {
          const p = issue.fields.parent;
          parent = {
            id: p.id,
            key: p.key,
            summary: p.fields.summary,
            description: null,
            issueType: p.fields.issuetype.name,
            status: p.fields.status.name,
            flow: null,
            attachments: [],
          };
        }

        return {
          id: issue.id,
          key: issue.key,
          summary: issue.fields.summary,
          description: null,
          issueType: issue.fields.issuetype.name,
          status: issue.fields.status.name,
          created: issue.fields.created ?? null,
          updated: issue.fields.updated ?? null,
          acceptanceCriteria: null,
          stacktrace: null,
          flow: parent?.flow ?? null,
          assignee: this.mapAssignee(issue.fields.assignee),
          parent,
          subtasks: [],
          children: [],
          attachments: [],
          comments: this.mapComments(issue.fields.comment),
          components: issue.fields.components ?? [],
        };
      }),
    };
  }

  /**
   * Fetches parent epic/story description, attachments, and flow in one JQL batch.
   * Intended for use after a ticket-review trigger is detected (not for every search hit).
   */
  async fetchParentDetailsForKeys(
    parentKeys: string[],
  ): Promise<
    Map<
      string,
      { description: string | null; attachments: JiraAttachment[]; flow: IssueFlow | null }
    >
  > {
    const unique = [...new Set(parentKeys.filter(Boolean))];
    return this.fetchParentDetails(unique);
  }

  /**
   * Lists Jira projects visible to the API user (paginated; Jira Cloud `GET /project/search`).
   * Project keys are the usual prefixes for ticket keys (e.g. SQDP-123 → SQDP).
   */
  async listProjects(options?: { startAt?: number; maxResults?: number }): Promise<{
    total: number;
    startAt: number;
    maxResults: number;
    isLast: boolean;
    projects: Array<{
      id: string;
      key: string;
      name: string;
      projectTypeKey: string | null;
    }>;
  }> {
    const startAt = options?.startAt ?? 0;
    const maxResults = Math.min(Math.max(options?.maxResults ?? 50, 1), 100);
    const url = new URL(`${this.baseUrl}/project/search`);
    url.searchParams.set("startAt", String(startAt));
    url.searchParams.set("maxResults", String(maxResults));

    const response = await fetch(url.toString(), {
      headers: {
        Authorization: this.authHeader,
        Accept: "application/json",
      },
    });

    if (!response.ok) {
      const body = await response.text();
      this.logger.debug({ status: response.status, body }, "Jira project search error");
      throw new JiraApiError(
        `Jira project search failed: ${response.status} ${response.statusText}`,
      );
    }

    const raw = await response.json();
    const parsed = jiraProjectSearchResponseSchema.parse(raw);

    return {
      total: parsed.total,
      startAt: parsed.startAt ?? 0,
      maxResults: parsed.maxResults ?? maxResults,
      isLast: parsed.isLast ?? true,
      projects: parsed.values.map((p) => ({
        id: p.id,
        key: p.key,
        name: p.name,
        projectTypeKey: p.projectTypeKey ?? null,
      })),
    };
  }

  private async fetchChildrenByParent(parentKeys: string[]): Promise<Map<string, JiraSubtask[]>> {
    const map = new Map<string, JiraSubtask[]>();
    if (parentKeys.length === 0) return map;

    const keysClause = parentKeys.join(", ");
    const jql = `parent in (${keysClause}) ORDER BY created ASC`;

    try {
      const response = await fetch(`${this.baseUrl}/search/jql`, {
        method: "POST",
        headers: {
          Authorization: this.authHeader,
          Accept: "application/json",
          "Content-Type": "application/json",
        },
        body: JSON.stringify({
          jql,
          maxResults: 200,
          fields: ["summary", "issuetype", "status", "parent"],
        }),
      });

      if (!response.ok) return map;

      const raw = await response.json();
      const parsed = jiraChildrenResponseSchema.parse(raw);

      for (const child of parsed.issues) {
        const parentKey = child.fields.parent?.key;
        if (!parentKey) continue;

        const list = map.get(parentKey) ?? [];
        list.push({
          id: child.id,
          key: child.key,
          summary: child.fields.summary,
          status: child.fields.status.name,
          issueType: child.fields.issuetype.name,
        });
        map.set(parentKey, list);
      }
    } catch (err) {
      this.logger.warn(
        { error: err instanceof Error ? err.message : String(err) },
        "Failed to fetch children, continuing without them",
      );
    }

    return map;
  }

  private async fetchParentDetails(
    parentKeys: string[],
  ): Promise<
    Map<
      string,
      { description: string | null; attachments: JiraAttachment[]; flow: IssueFlow | null }
    >
  > {
    const map = new Map<
      string,
      { description: string | null; attachments: JiraAttachment[]; flow: IssueFlow | null }
    >();
    if (parentKeys.length === 0) return map;

    const keysClause = parentKeys.map((k) => `"${k}"`).join(", ");
    const jql = `key in (${keysClause})`;

    try {
      const response = await fetch(`${this.baseUrl}/search/jql`, {
        method: "POST",
        headers: {
          Authorization: this.authHeader,
          Accept: "application/json",
          "Content-Type": "application/json",
        },
        body: JSON.stringify({
          jql,
          maxResults: parentKeys.length,
          fields: ["description", "attachment", JIRA_FLOW_FIELD_ID],
        }),
      });

      if (!response.ok) return map;

      const raw = (await response.json()) as {
        issues: Array<{
          key: string;
          fields: {
            description: unknown;
            attachment?: JiraRawAttachment[];
            [key: string]: unknown;
          };
        }>;
      };

      for (const issue of raw.issues) {
        const desc = this.normalizeDescription(issue.fields.description) || null;
        const flow = this.mapFlowField(issue as unknown as Record<string, unknown>);
        map.set(issue.key, {
          description: desc,
          attachments: this.mapAttachments(issue.fields.attachment ?? []),
          flow,
        });
      }
    } catch (err) {
      this.logger.warn(
        { error: err instanceof Error ? err.message : String(err) },
        "Failed to fetch parent details, continuing without them",
      );
    }

    return map;
  }

  /**
   * Fetches component ids of an issue. Used to copy parent's components when creating
   * a subtask so projects with required "Components" field succeed.
   */
  /**
   * Returns the date of the most recent status change for an issue by inspecting its changelog.
   * Returns `null` if no status change is found in the changelog.
   */
  async getLastStatusChangeDate(issueKey: string): Promise<string | null> {
    const entries = await this.getStatusChangeEntries(issueKey);
    return entries[0]?.created ?? null;
  }

  /**
   * Returns the author of the most recent status transition NOT made by the
   * bot itself.  This way, repeated bot retries (each of which transitions
   * the ticket to "In Progress") are skipped and we surface the human who
   * originally moved the ticket.
   */
  async getLastStatusChangeAuthor(
    issueKey: string,
  ): Promise<{ displayName: string; emailAddress?: string } | null> {
    const entries = await this.getStatusChangeEntries(issueKey);
    const human = entries.find((e) => e.author.emailAddress !== this.email);
    return human?.author ?? entries[0]?.author ?? null;
  }

  private async getStatusChangeEntries(
    issueKey: string,
  ): Promise<Array<{ created: string; author: { displayName: string; emailAddress?: string } }>> {
    const url = `${this.baseUrl}/issue/${issueKey}/changelog?maxResults=100`;
    const response = await fetch(url, {
      headers: {
        Authorization: this.authHeader,
        Accept: "application/json",
      },
    });

    if (!response.ok) {
      this.logger.warn({ issueKey, status: response.status }, "jira_changelog_fetch_failed");
      return [];
    }

    const data = (await response.json()) as {
      values: Array<{
        created: string;
        author: { displayName: string; emailAddress?: string };
        items: Array<{ field: string; fieldtype: string }>;
      }>;
    };

    const statusChanges: Array<{
      created: string;
      author: { displayName: string; emailAddress?: string };
    }> = [];

    for (let i = data.values.length - 1; i >= 0; i--) {
      const entry = data.values[i]!;
      if (entry.items.some((item) => item.field === "status")) {
        statusChanges.push({ created: entry.created, author: entry.author });
      }
    }

    return statusChanges;
  }

  /**
   * Fetches the last status change date for multiple issues in parallel.
   */
  async getLastStatusChangeDates(issueKeys: string[]): Promise<Map<string, string | null>> {
    const results = new Map<string, string | null>();
    if (issueKeys.length === 0) return results;

    const settled = await Promise.allSettled(
      issueKeys.map(async (key) => {
        const date = await this.getLastStatusChangeDate(key);
        return { key, date };
      }),
    );

    for (const result of settled) {
      if (result.status === "fulfilled") {
        results.set(result.value.key, result.value.date);
      }
    }

    return results;
  }

  async getIssueComponents(issueKey: string): Promise<Array<{ id: string }>> {
    const response = await fetch(`${this.baseUrl}/issue/${issueKey}?fields=components`, {
      headers: {
        Authorization: this.authHeader,
        Accept: "application/json",
      },
    });
    if (!response.ok) return [];
    const raw = (await response.json()) as {
      fields?: { components?: Array<{ id: string }> };
    };
    const components = raw.fields?.components ?? [];
    return components.map((c) => ({ id: c.id }));
  }

  async createSubtask(
    parentKey: string,
    summary: string,
    assigneeAccountId: string,
    parentComponentIds?: Array<{ id: string }>,
  ): Promise<{ id: string; key: string }> {
    const projectKey = parentKey.split("-")[0] ?? parentKey;

    const body: {
      fields: {
        project: { key: string };
        parent: { key: string };
        issuetype: { name: string };
        summary: string;
        assignee: { accountId: string };
        components?: Array<{ id: string }>;
      };
    } = {
      fields: {
        project: { key: projectKey },
        parent: { key: parentKey },
        issuetype: { name: "Dev Task" },
        summary,
        assignee: { accountId: assigneeAccountId },
      },
    };
    if (parentComponentIds?.length) {
      body.fields.components = parentComponentIds;
    }

    this.logger.info({ parentKey, summary }, "creating_subtask");

    const response = await fetch(`${this.baseUrl}/issue`, {
      method: "POST",
      headers: {
        Authorization: this.authHeader,
        Accept: "application/json",
        "Content-Type": "application/json",
      },
      body: JSON.stringify(body),
    });

    if (!response.ok) {
      const errorBody = await response.text();
      this.logger.error({ status: response.status, body: errorBody }, "create_subtask_failed");
      throw new JiraApiError(`Failed to create subtask: ${response.status} ${response.statusText}`);
    }

    const data = (await response.json()) as { id: string; key: string };
    this.logger.info({ key: data.key, parentKey }, "subtask_created");
    return data;
  }

  async downloadAttachment(contentUrl: string): Promise<Buffer> {
    const response = await fetch(contentUrl, {
      headers: { Authorization: this.authHeader },
    });

    if (!response.ok) {
      throw new JiraApiError(
        `Failed to download attachment: ${response.status} ${response.statusText}`,
      );
    }

    return Buffer.from(await response.arrayBuffer());
  }

  async getTransitions(issueKey: string): Promise<JiraTransition[]> {
    const response = await fetch(`${this.baseUrl}/issue/${issueKey}/transitions`, {
      headers: {
        Authorization: this.authHeader,
        Accept: "application/json",
      },
    });

    if (!response.ok) {
      throw new JiraApiError(`Failed to fetch transitions for ${issueKey}: ${response.status}`);
    }

    const { transitions } = (await response.json()) as {
      transitions: JiraTransition[];
    };

    return transitions;
  }

  async addComment(issueKey: string, text: string): Promise<{ id: string }> {
    const response = await fetch(`${this.baseUrl}/issue/${issueKey}/comment`, {
      method: "POST",
      headers: {
        Authorization: this.authHeader,
        Accept: "application/json",
        "Content-Type": "application/json",
      },
      body: JSON.stringify({
        body: {
          type: "doc",
          version: 1,
          content: text.split("\n\n").map((paragraph) => ({
            type: "paragraph",
            content: [{ type: "text", text: paragraph }],
          })),
        },
      }),
    });

    if (!response.ok) {
      const detail = await response.text().catch(() => "");
      throw new JiraApiError(
        `Failed to add comment to ${issueKey}: ${response.status} ${detail.slice(0, 200)}`,
      );
    }

    const data = (await response.json()) as { id: string };
    this.logger.info({ issueKey, commentId: data.id }, "comment_added");
    return { id: data.id };
  }

  async transitionIssue(issueKey: string, targetStatus: string): Promise<void> {
    const transitionsResponse = await fetch(`${this.baseUrl}/issue/${issueKey}/transitions`, {
      headers: {
        Authorization: this.authHeader,
        Accept: "application/json",
      },
    });

    if (!transitionsResponse.ok) {
      throw new JiraApiError(
        `Failed to fetch transitions for ${issueKey}: ${transitionsResponse.status}`,
      );
    }

    const { transitions } = (await transitionsResponse.json()) as {
      transitions: JiraTransition[];
    };

    const target = transitions.find((t) => t.name.toLowerCase() === targetStatus.toLowerCase());

    if (!target) {
      const available = transitions.map((t) => t.name).join(", ");
      this.logger.warn({ issueKey, targetStatus, available }, "transition_not_found");
      throw new JiraApiError(
        `Transition "${targetStatus}" not found for ${issueKey}. Available: ${available}`,
      );
    }

    const response = await fetch(`${this.baseUrl}/issue/${issueKey}/transitions`, {
      method: "POST",
      headers: {
        Authorization: this.authHeader,
        Accept: "application/json",
        "Content-Type": "application/json",
      },
      body: JSON.stringify({ transition: { id: target.id } }),
    });

    if (!response.ok) {
      throw new JiraApiError(
        `Failed to transition ${issueKey} to "${targetStatus}": ${response.status}`,
      );
    }

    this.logger.info({ issueKey, targetStatus, transitionId: target.id }, "issue_transitioned");
  }

  async getMyself(): Promise<JiraUser> {
    const response = await fetch(`${this.baseUrl}/myself`, {
      headers: {
        Authorization: this.authHeader,
        Accept: "application/json",
      },
    });

    if (!response.ok) {
      throw new JiraApiError(
        `Failed to fetch current user: ${response.status} ${response.statusText}`,
      );
    }

    const data = (await response.json()) as {
      accountId: string;
      displayName: string;
      emailAddress?: string;
    };

    return {
      accountId: data.accountId,
      displayName: data.displayName,
      emailAddress: data.emailAddress,
    };
  }

  /**
   * Resolves the Atlassian accountId for the Jira user authenticated by this client.
   * Prefer GET /myself over /user/search: search often returns no rows or omits `emailAddress`
   * (privacy), so {@link findUserByEmail} fails even when the bot account exists.
   */
  async resolveAccountIdForBotEmail(botEmail: string): Promise<string | null> {
    try {
      const myself = await this.getMyself();
      if (myself.emailAddress) {
        if (myself.emailAddress.toLowerCase() !== botEmail.toLowerCase()) {
          this.logger.warn(
            { botEmail, credentialEmail: myself.emailAddress },
            "jira_bot_email_mismatch_using_api_credential_user",
          );
        }
      }
      return myself.accountId;
    } catch (err) {
      const message = err instanceof Error ? err.message : String(err);
      const unauthorized = /\b401\b/.test(message);
      this.logger.warn(
        {
          botEmail,
          error: message,
          ...(unauthorized
            ? {
                hint: "Jira rejected the API token (401). Create a token at https://id.atlassian.com/manage-profile/security/api-tokens — JIRA_EMAIL must be the Atlassian account that owns the token; JIRA_DOMAIN is only the Cloud site subdomain (e.g. humand, not the full URL).",
              }
            : {}),
        },
        "jira_get_myself_failed_falling_back_user_search",
      );
      const user = await this.findUserByEmail(botEmail);
      if (!user) {
        this.logger.warn({ botEmail }, "jira_bot_account_not_resolved");
        return null;
      }
      return user.accountId;
    }
  }

  async findUserByEmail(email: string): Promise<JiraUser | null> {
    const url = `${this.baseUrl}/user/search?query=${encodeURIComponent(email)}`;
    try {
      const response = await fetch(url, {
        headers: {
          Authorization: this.authHeader,
          Accept: "application/json",
        },
      });

      if (!response.ok) {
        this.logger.warn({ email, status: response.status }, "jira_user_search_failed");
        return null;
      }

      const users = (await response.json()) as JiraUser[];

      const exact = users.find((u) => u.emailAddress?.toLowerCase() === email.toLowerCase());

      const match = exact ?? users[0];
      if (!match) return null;

      this.logger.debug(
        { email, accountId: match.accountId, displayName: match.displayName },
        "jira_user_resolved",
      );

      return { accountId: match.accountId, displayName: match.displayName };
    } catch (err) {
      this.logger.warn(
        { email, error: err instanceof Error ? err.message : String(err) },
        "jira_user_search_error",
      );
      return null;
    }
  }

  private mapFlowField(raw: Record<string, unknown>): IssueFlow | null {
    const fields = raw.fields as Record<string, unknown> | undefined;
    if (!fields) return null;

    const fieldValue = fields[JIRA_FLOW_FIELD_ID];
    let raw_value: string | null = null;

    if (typeof fieldValue === "string") {
      raw_value = fieldValue;
    } else if (fieldValue && typeof fieldValue === "object" && "value" in fieldValue) {
      raw_value = String((fieldValue as Record<string, unknown>).value);
    }

    if (!raw_value) return null;

    const normalized = raw_value.toLowerCase().trim();
    if (normalized === "hotfix") return "hotfix";
    if (normalized === "bugfix") return "bugfix";
    if (normalized === "regular") return "regular";
    return null;
  }

  private mapAttachments(raw: JiraRawAttachment[]): JiraAttachment[] {
    return raw.map((a) => ({
      id: a.id,
      filename: a.filename,
      mimeType: a.mimeType,
      size: a.size,
      content: a.content,
      thumbnail: a.thumbnail ?? null,
      created: a.created,
    }));
  }

  private mapAssignee(
    raw: { accountId: string; displayName?: string; emailAddress?: string } | null | undefined,
  ): JiraUser | null {
    if (!raw) return null;
    return {
      accountId: raw.accountId,
      displayName: raw.displayName ?? "Unknown",
      emailAddress: raw.emailAddress,
    };
  }

  // eslint-disable-next-line @typescript-eslint/no-explicit-any
  private mapComments(raw: any): JiraComment[] {
    const comments = raw?.comments as
      | Array<{
          id: string;
          author?: { accountId: string; displayName?: string; emailAddress?: string };
          body: unknown;
          created: string;
        }>
      | undefined;
    if (!comments) return [];
    return comments.map((c) => ({
      id: c.id,
      author: {
        accountId: c.author?.accountId ?? "unknown",
        displayName: c.author?.displayName ?? "Unknown",
        emailAddress: c.author?.emailAddress,
      },
      body: this.normalizeDescription(c.body),
      created: c.created,
    }));
  }

  private normalizeDescription(doc: unknown): string {
    if (!doc || typeof doc !== "object") return "";
    return this.adfToMarkdown(doc as ADFNode);
  }

  private adfToMarkdown(node: ADFNode, depth = 0): string {
    if (!node) return "";

    if (node.type === "text") {
      let text = node.text ?? "";
      if (node.marks) {
        for (const mark of node.marks) {
          if (mark.type === "strong") text = `**${text}**`;
          else if (mark.type === "em") text = `*${text}*`;
          else if (mark.type === "code") text = `\`${text}\``;
          else if (mark.type === "link" && mark.attrs?.href) text = `[${text}](${mark.attrs.href})`;
        }
      }
      return text;
    }

    if (node.type === "inlineCard") {
      const url = node.attrs?.url as string | undefined;
      return url ? `<${url}>` : "";
    }

    const children = (node.content ?? [])
      .map((child) => this.adfToMarkdown(child, depth + 1))
      .join("");

    switch (node.type) {
      case "doc":
        return children;
      case "mention": {
        const id = String((node.attrs as { id?: string } | undefined)?.id ?? "");
        const text = String((node.attrs as { text?: string } | undefined)?.text ?? "");
        return `[mention:${id}]${text}`;
      }
      case "paragraph":
        return `${children}\n\n`;
      case "heading": {
        const level = (node.attrs?.level as number) ?? 1;
        return `${"#".repeat(level)} ${children}\n\n`;
      }
      case "bulletList":
        return children;
      case "orderedList":
        return children;
      case "listItem":
        return `- ${children.trim()}\n`;
      case "codeBlock": {
        const lang = (node.attrs?.language as string) ?? "";
        return `\`\`\`${lang}\n${children}\`\`\`\n\n`;
      }
      case "blockquote":
        return (
          children
            .split("\n")
            .map((line) => `> ${line}`)
            .join("\n") + "\n\n"
        );
      case "hardBreak":
        return "\n";
      default:
        return children;
    }
  }

  private extractSections(markdown: string): {
    acceptanceCriteria: string | null;
    stacktrace: string | null;
  } {
    const acMatch = markdown.match(
      /(?:acceptance\s+criteria|ac)[:\s]*\n([\s\S]*?)(?=\n#{1,3}\s|\n```|$)/i,
    );
    const stackMatch = markdown.match(
      /(?:stacktrace|stack\s+trace|error)[:\s]*\n([\s\S]*?)(?=\n#{1,3}\s|$)/i,
    );

    return {
      acceptanceCriteria: acMatch?.[1]?.trim() ?? null,
      stacktrace: stackMatch?.[1]?.trim() ?? null,
    };
  }
}

/**
 * Jira client for the dev sandbox: reads are inherited from JiraClientImpl;
 * every write is a no-op so triggering a ticket never mutates the real board.
 * createSubtask returns the parent key so downstream branch/PR logic still works.
 */
export class SandboxJiraClient extends JiraClientImpl {
  override async transitionIssue(issueKey: string, targetStatus: string): Promise<void> {
    this.logger.info({ issueKey, targetStatus }, "sandbox_skip_transition");
  }

  override async addComment(issueKey: string, _text: string): Promise<{ id: string }> {
    this.logger.info({ issueKey }, "sandbox_skip_comment");
    return { id: "sandbox-stub" };
  }

  override async createSubtask(
    parentKey: string,
    _summary: string,
    _assigneeAccountId: string,
    _parentComponentIds?: Array<{ id: string }>,
  ): Promise<{ id: string; key: string }> {
    this.logger.info({ parentKey }, "sandbox_skip_subtask");
    return { id: "sandbox-stub", key: parentKey };
  }
}
