import { Octokit } from "@octokit/rest";
import type { Logger } from "pino";
import { GitHubApiError } from "@/core/errors.js";
import type { IssueFlow } from "@/types/index.js";

export class GitHubClientImpl {
  private readonly octokit: Octokit;
  private readonly owner: string;
  private readonly repo: string;

  constructor(
    token: string,
    repoUrl: string,
    private readonly jiraDomain: string,
    protected readonly logger: Logger,
  ) {
    this.octokit = new Octokit({ auth: token });
    const { owner, repo } = this.parseRepoUrl(repoUrl);
    this.owner = owner;
    this.repo = repo;
  }

  /** List all open PRs (optionally filtered by branch). Returns first match when branch is set. */
  async getPrForBranch(branch: string): Promise<{ number: number; url: string } | null> {
    const list = await this.listOpenPrs(branch.trim() ? `${this.owner}:${branch}` : undefined);
    const pr = list[0];
    if (!pr) return null;
    return { number: pr.number, url: pr.url };
  }

  /** List all open PRs. Optionally filter by head (e.g. "owner:branch"). */
  async listOpenPrs(head?: string): Promise<
    Array<{
      number: number;
      url: string;
      title: string;
      author: string | null;
      headRef: string;
    }>
  > {
    try {
      const params: {
        owner: string;
        repo: string;
        head?: string;
        state: "open" | "closed" | "all";
        per_page: number;
      } = {
        owner: this.owner,
        repo: this.repo,
        state: "open" as const,
        per_page: 100,
      };
      if (head) params.head = head;
      const { data } = await this.octokit.pulls.list(params);
      return data.map((pr) => ({
        number: pr.number,
        url: pr.html_url ?? "",
        title: pr.title ?? "",
        author: pr.user?.login ?? null,
        headRef: pr.head.ref,
      }));
    } catch (err) {
      const status =
        (err as { response?: { status?: number }; status?: number })?.response?.status ??
        (err as { status?: number })?.status;
      let hint = "";
      if (status === 401) hint = " (401 Unauthorized – check token and repo access)";
      else if (status === 404)
        hint =
          " (404 – repo not found or App not installed on this repo; check Installation → Repository access)";
      throw new GitHubApiError("Failed to list PRs" + hint, { cause: err });
    }
  }

  /**
   * Lists PRs merged within `[sinceIso, untilIso)` for the repo bound to this client.
   *
   * Paginates `pulls.list({ state: "closed", sort: "updated", direction: "desc" })`
   * and stops as soon as a page's oldest `updated_at` falls before `sinceIso`
   * (merged_at <= updated_at, so anything older cannot be in range).
   *
   * Skips PRs that were closed without being merged (`merged_at === null`).
   */
  async listMergedPrs(options: {
    sinceIso: string;
    untilIso: string;
    perPage?: number;
    maxPages?: number;
  }): Promise<
    Array<{
      number: number;
      title: string;
      authorLogin: string | null;
      mergedAt: string;
      htmlUrl: string;
      headRef: string;
    }>
  > {
    const { sinceIso, untilIso, perPage = 100, maxPages = 20 } = options;
    const sinceMs = Date.parse(sinceIso);
    const untilMs = Date.parse(untilIso);
    if (!Number.isFinite(sinceMs) || !Number.isFinite(untilMs)) {
      throw new GitHubApiError(
        `listMergedPrs: invalid date range sinceIso="${sinceIso}" untilIso="${untilIso}"`,
      );
    }

    const out: Array<{
      number: number;
      title: string;
      authorLogin: string | null;
      mergedAt: string;
      htmlUrl: string;
      headRef: string;
    }> = [];

    /** Fetch one page with automatic retry on GitHub's secondary rate-limit (403). */
    const fetchPage = async (
      page: number,
    ): Promise<Awaited<ReturnType<typeof this.octokit.pulls.list>>["data"]> => {
      for (let attempt = 0; attempt < 4; attempt++) {
        try {
          const { data } = await this.octokit.pulls.list({
            owner: this.owner,
            repo: this.repo,
            state: "closed",
            sort: "updated",
            direction: "desc",
            per_page: perPage,
            page,
          });
          return data;
        } catch (err) {
          const status =
            (err as { response?: { status?: number }; status?: number })?.response?.status ??
            (err as { status?: number })?.status;
          const retryAfterHeader = (
            err as { response?: { headers?: Record<string, string | undefined> } }
          )?.response?.headers?.["retry-after"];
          const retryAfterSec = retryAfterHeader ? Number.parseInt(retryAfterHeader, 10) : NaN;
          if (status === 403 || status === 429) {
            const waitMs = Number.isFinite(retryAfterSec)
              ? Math.max(retryAfterSec * 1000, 1000)
              : (attempt + 1) * 10_000;
            this.logger.warn(
              { owner: this.owner, repo: this.repo, page, attempt, waitMs, status },
              "github_secondary_ratelimit_retrying",
            );
            await new Promise<void>((r) => setTimeout(r, waitMs));
            continue;
          }
          throw err;
        }
      }
      throw new GitHubApiError(
        `listMergedPrs: giving up after 4 retries on ${this.owner}/${this.repo} page ${page}`,
      );
    };

    try {
      for (let page = 1; page <= maxPages; page++) {
        const data = await fetchPage(page);

        if (data.length === 0) break;

        let oldestUpdatedMs = Infinity;
        for (const pr of data) {
          const updatedMs = Date.parse(pr.updated_at ?? "");
          if (Number.isFinite(updatedMs) && updatedMs < oldestUpdatedMs) {
            oldestUpdatedMs = updatedMs;
          }
          if (!pr.merged_at) continue;
          const mergedMs = Date.parse(pr.merged_at);
          if (!Number.isFinite(mergedMs)) continue;
          if (mergedMs < sinceMs || mergedMs >= untilMs) continue;
          out.push({
            number: pr.number,
            title: pr.title ?? "",
            authorLogin: pr.user?.login ?? null,
            mergedAt: pr.merged_at,
            htmlUrl: pr.html_url ?? "",
            headRef: pr.head?.ref ?? "",
          });
        }

        // Stop when the whole page predates our window — nothing older can match.
        if (oldestUpdatedMs < sinceMs) break;
        if (data.length < perPage) break;
      }
    } catch (err) {
      const status =
        (err as { response?: { status?: number }; status?: number })?.response?.status ??
        (err as { status?: number })?.status;
      let hint = "";
      if (status === 401) hint = " (401 Unauthorized – check token and repo access)";
      else if (status === 404)
        hint =
          " (404 – repo not found or App not installed on this repo; check Installation → Repository access)";
      throw new GitHubApiError("Failed to list merged PRs" + hint, { cause: err });
    }

    return out;
  }

  async getPrDiff(prNumber: number): Promise<string> {
    try {
      const { data } = await this.octokit.pulls.get({
        owner: this.owner,
        repo: this.repo,
        pull_number: prNumber,
        mediaType: { format: "diff" },
      });

      return data as unknown as string;
    } catch (err) {
      throw new GitHubApiError(`Failed to get diff for PR #${prNumber}`, {
        cause: err,
      });
    }
  }

  async updatePrBody(prNumber: number, body: string): Promise<void> {
    try {
      await this.octokit.pulls.update({
        owner: this.owner,
        repo: this.repo,
        pull_number: prNumber,
        body,
      });

      this.logger.info({ prNumber }, "pr_updated");
    } catch (err) {
      throw new GitHubApiError(`Failed to update PR #${prNumber}`, {
        cause: err,
      });
    }
  }

  async createPr(
    branch: string,
    title: string,
    body: string,
    base = "develop",
    draft = false,
  ): Promise<{ number: number; url: string }> {
    const existing = await this.getPrForBranch(branch);
    if (existing) {
      this.logger.info({ prNumber: existing.number }, "pr_already_exists");
      return existing;
    }

    // Same-repo PR: use branch name only. "owner:branch" can trigger "not all refs are readable" with GitHub App.
    try {
      const { data } = await this.octokit.pulls.create({
        owner: this.owner,
        repo: this.repo,
        head: branch,
        base,
        title,
        body,
        draft,
      });

      this.logger.info({ prNumber: data.number, url: data.html_url }, "pr_created");
      return { number: data.number, url: data.html_url };
    } catch (err) {
      const status = (err as { response?: { status?: number; data?: unknown } })?.response?.status;
      const body = (
        err as {
          response?: { data?: { message?: string; errors?: unknown[] } };
        }
      )?.response?.data;
      const detail = body?.message
        ? body.errors?.length
          ? `${body.message} ${JSON.stringify(body.errors)}`
          : body.message
        : "";
      const headInvalid = body?.errors?.some?.((e: unknown) => {
        const err = e as { field?: string; code?: string };
        return err.field === "head" && err.code === "invalid";
      });
      const refsNotReadable =
        typeof detail === "string" && detail.includes("not all refs are readable");
      const permHint = refsNotReadable
        ? " – If App has Contents + Pull requests: go to Org → Settings → Integrations → GitHub Apps → Configure → accept new permissions; or use GITHUB_TOKEN (PAT) instead of App for same-repo PRs"
        : headInvalid
          ? " – ensure the branch exists on the remote (push it first)"
          : "";
      const hint = status === 422 ? ` (422 ${detail || "Validation Failed"}${permHint})` : "";
      throw new GitHubApiError("Failed to create PR" + hint, { cause: err });
    }
  }

  async listRepoLabels(): Promise<string[]> {
    try {
      const labels: string[] = [];
      for (let page = 1; ; page++) {
        const { data } = await this.octokit.issues.listLabelsForRepo({
          owner: this.owner,
          repo: this.repo,
          per_page: 100,
          page,
        });
        for (const l of data) labels.push(l.name);
        if (data.length < 100) break;
      }
      return labels;
    } catch (err) {
      this.logger.warn(
        { error: err instanceof Error ? err.message : String(err) },
        "list_repo_labels_failed",
      );
      return [];
    }
  }

  async addLabels(prNumber: number, labels: string[]): Promise<void> {
    if (labels.length === 0) return;
    try {
      await this.octokit.issues.addLabels({
        owner: this.owner,
        repo: this.repo,
        issue_number: prNumber,
        labels,
      });
      this.logger.info({ prNumber, labels }, "labels_added");
    } catch (err) {
      this.logger.warn(
        {
          prNumber,
          labels,
          error: err instanceof Error ? err.message : String(err),
        },
        "labels_add_failed",
      );
    }
  }

  async closePr(prNumber: number): Promise<void> {
    try {
      await this.octokit.pulls.update({
        owner: this.owner,
        repo: this.repo,
        pull_number: prNumber,
        state: "closed",
      });

      this.logger.info({ prNumber }, "pr_closed");
    } catch (err) {
      throw new GitHubApiError(`Failed to close PR #${prNumber}`, {
        cause: err,
      });
    }
  }

  /** Get the rendered HTML body of an issue comment (contains signed image URLs). */
  async getCommentBodyHtml(commentId: number): Promise<string | null> {
    try {
      const { data } = await this.octokit.issues.getComment({
        owner: this.owner,
        repo: this.repo,
        comment_id: commentId,
        mediaType: { format: "html" },
      });
      return (data as unknown as { body_html?: string }).body_html ?? null;
    } catch (err) {
      this.logger.warn(
        { commentId, error: err instanceof Error ? err.message : String(err) },
        "get_comment_body_html_failed",
      );
      return null;
    }
  }

  /** List conversation comments on an issue/PR (PRs use the same issue_number as pull_number). */
  async listIssueComments(
    issueNumber: number,
    options?: { perPage?: number; page?: number },
  ): Promise<
    Array<{
      id: number;
      body: string | null;
      user: { login: string } | null;
      created_at: string;
      html_url: string;
    }>
  > {
    try {
      const per_page = options?.perPage ?? 100;
      const page = options?.page ?? 1;
      const { data } = await this.octokit.issues.listComments({
        owner: this.owner,
        repo: this.repo,
        issue_number: issueNumber,
        per_page,
        page,
      });
      return data.map((c) => ({
        id: c.id,
        body: c.body ?? null,
        user: c.user ? { login: c.user.login } : null,
        created_at: c.created_at,
        html_url: c.html_url ?? "",
      }));
    } catch (err) {
      throw new GitHubApiError(`Failed to list issue comments for #${issueNumber}`, {
        cause: err,
      });
    }
  }

  /** List inline review comments on a PR (comments on specific lines of code). */
  async listPullReviewComments(
    prNumber: number,
    options?: { perPage?: number; page?: number },
  ): Promise<
    Array<{
      id: number;
      pull_request_review_id: number | null;
      path: string;
      line: number | null;
      body: string;
      user: { login: string } | null;
      created_at: string;
      commit_id: string;
      in_reply_to_id: number | null;
    }>
  > {
    try {
      const per_page = options?.perPage ?? 100;
      const page = options?.page ?? 1;
      const { data } = await this.octokit.pulls.listReviewComments({
        owner: this.owner,
        repo: this.repo,
        pull_number: prNumber,
        per_page,
        page,
      });
      return data.map((c) => ({
        id: c.id,
        pull_request_review_id: c.pull_request_review_id ?? null,
        path: c.path,
        line: c.line ?? null,
        body: c.body,
        user: c.user ? { login: c.user.login } : null,
        created_at: c.created_at,
        commit_id: c.commit_id,
        in_reply_to_id: c.in_reply_to_id ?? null,
      }));
    } catch (err) {
      throw new GitHubApiError(`Failed to list review comments for PR #${prNumber}`, {
        cause: err,
      });
    }
  }

  /** Get a single review comment by ID. */
  async getPullReviewComment(commentId: number): Promise<{
    id: number;
    path: string;
    line: number | null;
    body: string;
    user: { login: string } | null;
    created_at: string;
    commit_id: string;
    in_reply_to_id: number | null;
  }> {
    try {
      const { data } = await this.octokit.pulls.getReviewComment({
        owner: this.owner,
        repo: this.repo,
        comment_id: commentId,
      });
      return {
        id: data.id,
        path: data.path,
        line: data.line ?? null,
        body: data.body,
        user: data.user ? { login: data.user.login } : null,
        created_at: data.created_at,
        commit_id: data.commit_id,
        in_reply_to_id: data.in_reply_to_id ?? null,
      };
    } catch (err) {
      throw new GitHubApiError(`Failed to get review comment ${commentId}`, { cause: err });
    }
  }

  /** List reviews on a PR (APPROVED / CHANGES_REQUESTED / COMMENTED). */
  async listPullReviews(
    prNumber: number,
    options?: { perPage?: number; page?: number },
  ): Promise<
    Array<{
      id: number;
      state: "APPROVED" | "CHANGES_REQUESTED" | "COMMENTED";
      user: { login: string } | null;
      body: string | null;
      submitted_at: string | null;
    }>
  > {
    try {
      const per_page = options?.perPage ?? 100;
      const page = options?.page ?? 1;
      const { data } = await this.octokit.pulls.listReviews({
        owner: this.owner,
        repo: this.repo,
        pull_number: prNumber,
        per_page,
        page,
      });
      return data.map((r) => ({
        id: r.id,
        state: r.state as "APPROVED" | "CHANGES_REQUESTED" | "COMMENTED",
        user: r.user ? { login: r.user.login } : null,
        body: r.body ?? null,
        submitted_at: r.submitted_at ?? null,
      }));
    } catch (err) {
      throw new GitHubApiError(`Failed to list reviews for PR #${prNumber}`, {
        cause: err,
      });
    }
  }

  /** Create a comment on an issue/PR (PRs use issue_number = pull_number). */
  async createIssueComment(issueNumber: number, body: string): Promise<void> {
    try {
      await this.octokit.issues.createComment({
        owner: this.owner,
        repo: this.repo,
        issue_number: issueNumber,
        body,
      });
      this.logger.info({ issueNumber }, "issue_comment_created");
    } catch (err) {
      throw new GitHubApiError(`Failed to create comment on #${issueNumber}`, { cause: err });
    }
  }

  /** Reply to a review comment on a PR. */
  async createReviewCommentReply(prNumber: number, commentId: number, body: string): Promise<void> {
    try {
      await this.octokit.pulls.createReplyForReviewComment({
        owner: this.owner,
        repo: this.repo,
        pull_number: prNumber,
        comment_id: commentId,
        body,
      });
      this.logger.info({ prNumber, commentId }, "review_comment_reply_created");
    } catch (err) {
      throw new GitHubApiError(`Failed to reply to review comment ${commentId}`, { cause: err });
    }
  }

  buildPrTitle(
    issueKey: string,
    summary: string,
    flow: IssueFlow | null,
    template?: string,
  ): string {
    if (!template) return `[${issueKey}] | ${summary}`;

    let title = template
      .replace(/\{key}/g, issueKey)
      .replace(/\{summary}/g, summary)
      .replace(/\{type}/g, flow ?? "");

    // Clean up empty segments: "| |" or leading/trailing pipes
    title = title
      .replace(/\|\s*\|/g, "|")
      .replace(/^\s*\|\s*/, "")
      .replace(/\s*\|\s*$/, "")
      .trim();

    return title;
  }

  buildPrBody(
    issueKey: string,
    agentSummary: string,
    buildPassed: boolean,
    testPassed: boolean,
    flow?: IssueFlow,
  ): string {
    const jiraUrl = `https://${this.jiraDomain}.atlassian.net/browse/${issueKey}`;

    const flowSection = [
      "## Flow",
      `- [${flow === "regular" ? "x" : " "}] Regular`,
      `- [${flow === "hotfix" ? "x" : " "}] Hotfix`,
      `- [${flow === "bugfix" ? "x" : " "}] Bugfix`,
    ].join("\n");

    return [
      "## Summary",
      agentSummary || "See commit diff for details.",
      "",
      "### Jira card",
      `- [${issueKey}](${jiraUrl})`,
      "",
      flowSection,
      "",
      "## Test cases",
      `- Automated build: ${buildPassed ? "PASSED" : "N/A"}`,
      `- Automated tests: ${testPassed ? "PASSED" : "N/A"}`,
      "",
      "---",
      "*This PR was generated automatically by hu-developer-bot.*",
    ].join("\n");
  }

  private parseRepoUrl(url: string): { owner: string; repo: string } {
    const match = url.match(/github\.com[/:]([^/]+)\/([^/.]+)(?:\.git)?$/);
    if (!match?.[1] || !match[2]) {
      throw new GitHubApiError(`Cannot parse GitHub repo URL: ${url}`);
    }
    return { owner: match[1], repo: match[2] };
  }
}

/** GitHub client for the dev sandbox: PRs open as draft, squad labels are skipped. */
export class SandboxGitHubClient extends GitHubClientImpl {
  override async createPr(
    branch: string,
    title: string,
    body: string,
    base = "develop",
  ): Promise<{ number: number; url: string }> {
    return super.createPr(branch, title, body, base, true); // force draft
  }

  override async addLabels(prNumber: number, labels: string[]): Promise<void> {
    this.logger.info({ prNumber, labels }, "sandbox_skip_labels");
  }
}
