import { join, resolve, dirname } from "node:path";
import { existsSync, readFileSync, mkdirSync, writeFileSync, rmSync } from "node:fs";
import type { Logger } from "pino";
import { ATTACHMENTS, AGENT, DAILY_REPORT, MEMORY, RULES_LOADER } from "@/core/constants.js";
import {
  buildPrMentionPrompt,
  parsePrCommentReply,
  type ParentThreadComment,
} from "@/agent/prompts.js";
import { runWithAuthRetry } from "@/github/auth.js";
import { GitHubClientImpl } from "@/github/client.js";
import {
  appendPrMemory,
  buildPrMemoryKey,
  loadPrMemory,
  type PrMemoryEntry,
} from "@/pr-memory/store.js";
import { RepoManagerImpl, configureGitAuth } from "@/repo/manager.js";
import { syncRemoteRulesRepos } from "@/repo/rules-sync.js";
import type { RepoRegistry } from "@/repo/registry.js";
import type { SlackNotifier } from "@/slack/client.js";
import type { Metrics } from "@/metrics/types.js";
import { METRIC } from "@/metrics/names.js";
import type {
  AgentRunner,
  PipelineResult,
  PrMentionJobPayload,
  RepoConfig,
} from "@/types/index.js";

const HU_BOT_DIR = ".hu-bot";

type ConversationComment = {
  id: number;
  author: string;
  body: string;
  createdAt: string;
  kind: "issue" | "review";
};

interface PipelineDeps {
  agentCli: AgentRunner;
  repoRegistry: RepoRegistry;
  getGitHubToken: () => Promise<string>;
  workdirPath: string;
  slackNotifier?: SlackNotifier;
  slackPrCommentChannelId?: string;
  getPendingCount: () => number;
  logger: Logger;
  metrics: Metrics;
}

export class PrMentionPipeline {
  constructor(private readonly deps: PipelineDeps) {}

  async execute(job: PrMentionJobPayload, signal?: AbortSignal): Promise<PipelineResult> {
    const { logger } = this.deps;
    const jobKey = `mention:${job.repoName}#${job.prNumber}#${job.commentId}`;
    const __t = Date.now();

    try {
      logger.info(
        {
          jobKey,
          repoName: job.repoName,
          prNumber: job.prNumber,
          commentId: job.commentId,
          mentionAuthor: job.mentionAuthor,
          pendingJobs: this.deps.getPendingCount(),
        },
        "pr_mention_pipeline_started",
      );

      const repo = this.deps.repoRegistry.getByName(job.repoName);
      if (!repo) {
        return {
          success: false,
          issueKey: jobKey,
          abortReason: `Repo not found: ${job.repoName}`,
          outcome: "completed",
        };
      }

      const localPath = join(this.deps.workdirPath, repo.name);
      const repoLogger = logger.child({ repo: repo.name });

      await configureGitAuth(this.deps.getGitHubToken, repoLogger);

      const manager = new RepoManagerImpl(repo.url, localPath, repoLogger);
      await manager.ensureCloned();
      await manager.checkoutBranch(job.branch);

      await syncRemoteRulesRepos(
        [repo],
        this.deps.workdirPath,
        repoLogger.child({ module: "rules-sync" }),
      );

      try {
        await manager.installDependencies();
      } catch (err) {
        repoLogger.warn(
          { error: err instanceof Error ? err.message : String(err) },
          "install_failed_non_blocking",
        );
      }

      const buildGhClient = async (): Promise<GitHubClientImpl> =>
        new GitHubClientImpl(
          await this.deps.getGitHubToken(),
          repo.url,
          "",
          repoLogger.child({ module: "github" }),
        );
      const githubClient = await buildGhClient();
      const memoryKey = buildPrMemoryKey(job.repoName, job.prNumber);

      let prDiff: string | undefined;
      try {
        prDiff = await githubClient.getPrDiff(job.prNumber);
      } catch (err) {
        repoLogger.debug(
          { error: err instanceof Error ? err.message : String(err) },
          "get_pr_diff_failed_continuing",
        );
      }

      const repoRules = this.loadRepoRules(repo, localPath);
      const { conversationHistory, currentCommentAuthor } = await this.loadConversationContext(
        githubClient,
        job,
        memoryKey,
        repoLogger,
      );

      const parentThreadComment = await this.loadParentThreadComment(githubClient, job, repoLogger);

      const savedImages = await this.downloadCommentImages(
        job.commentBody,
        job.commentId,
        githubClient,
        localPath,
        repoLogger,
      );

      const prompt = buildPrMentionPrompt(
        job.commentBody,
        job.repoName,
        job.branch,
        job.mentionAuthor,
        prDiff,
        repoRules,
        conversationHistory,
        savedImages,
        parentThreadComment,
      );

      const __t_agent = Date.now();
      const cliResult = await this.deps.agentCli.runAgent(
        prompt,
        localPath,
        AGENT.FOLLOWUP_TIMEOUT_MS,
        { signal },
      );
      this.deps.metrics.increment(METRIC.agentInvocations, { phase: "pr_mention" });
      this.deps.metrics.timing(METRIC.agentDuration, Date.now() - __t_agent, {
        phase: "pr_mention",
      });

      this.cleanupImages(localPath);

      const responseText = parsePrCommentReply(cliResult.output);
      if (!responseText) {
        logger.warn({ jobKey }, "pr_mention_pipeline_no_reply_text");
        return {
          success: false,
          issueKey: jobKey,
          abortReason: "Agent produced no reply text",
          outcome: "completed",
        };
      }

      const baseBranch = repo.defaultBranch ?? "develop";
      const filesChanged = await manager.getChangedFiles(baseBranch);

      try {
        appendPrMemory(memoryKey, {
          commentId: job.commentId,
          commentAuthor: currentCommentAuthor,
          commentBody: this.truncateMemoryText(job.commentBody),
          agentReply: this.truncateMemoryText(responseText),
          filesChanged,
          timestamp: new Date().toISOString(),
        });
      } catch (err) {
        repoLogger.warn(
          { jobKey, error: err instanceof Error ? err.message : String(err) },
          "pr_mention_memory_append_failed",
        );
      }

      // Push before replying: if the reply POST later fails transiently, at least the
      // code work is committed so the next poll cycle (or a manual re-tag) can observe
      // that the change landed and only the comment needs to be reposted.
      let replyPrefix = "";
      let pushed = false;
      if (await manager.hasChanges(baseBranch)) {
        const safetyReport = await manager.getDiffStats();
        if (!safetyReport.passed) {
          logger.warn(
            { jobKey, violations: safetyReport.violations },
            "pr_mention_safety_violation",
          );
          replyPrefix = [
            "> _I made the requested changes but they exceed the safety limits, so I'm not pushing them automatically._",
            "> _Violations:_",
            ...safetyReport.violations.map((v) => `> _- ${v}_`),
            "",
            "",
          ].join("\n");
        } else {
          try {
            await configureGitAuth(this.deps.getGitHubToken, repoLogger);
            const commitMessage = `chore(${job.repoName}): address @hu-agent mention #${job.commentId}`;
            await manager.commitAndPush(job.branch, commitMessage, baseBranch);
            pushed = true;
            logger.info({ jobKey }, "pr_mention_commit_pushed");
          } catch (pushErr) {
            logger.warn(
              { jobKey, error: pushErr instanceof Error ? pushErr.message : String(pushErr) },
              "pr_mention_push_failed",
            );
            replyPrefix =
              "> _I made code changes but was unable to push to this branch (it may be from a fork). Please apply the changes manually._\n\n";
          }
        }
      }

      const replyBody = replyPrefix + responseText;
      if (job.isReviewComment) {
        await runWithAuthRetry(
          buildGhClient,
          (client) => client.createReviewCommentReply(job.prNumber, job.commentId, replyBody),
          repoLogger,
        );
      } else {
        await runWithAuthRetry(
          buildGhClient,
          (client) => client.createIssueComment(job.prNumber, replyBody),
          repoLogger,
        );
      }
      logger.info({ jobKey }, "pr_mention_replied");

      if (this.deps.slackNotifier) {
        try {
          const issueKeyFromBranch = job.branch.match(DAILY_REPORT.TICKET_PREFIX_RE)?.[0];
          await this.deps.slackNotifier.notifyCommentAttended(
            job.repoName,
            job.prNumber,
            job.prUrl,
            issueKeyFromBranch,
            this.deps.slackPrCommentChannelId,
          );
        } catch (err) {
          logger.warn(
            { jobKey, error: err instanceof Error ? err.message : String(err) },
            "slack_pr_mention_attended_failed",
          );
        }
      }

      try {
        await manager.cleanStaleBranches(baseBranch);
      } catch (cleanErr) {
        logger.warn(
          { jobKey, error: cleanErr instanceof Error ? cleanErr.message : String(cleanErr) },
          "pr_mention_clean_stale_branches_failed",
        );
      }

      const action = pushed ? "code_change" : "reply_only";
      this.deps.metrics.increment(METRIC.prMentionHandled, { repo: job.repoName, action });
      this.deps.metrics.timing(METRIC.prMentionDuration, Date.now() - __t, {
        repo: job.repoName,
        action,
      });
      logger.info(
        { jobKey, pendingJobs: this.deps.getPendingCount() },
        "pr_mention_pipeline_completed",
      );
      return {
        success: true,
        issueKey: jobKey,
        outcome: "completed",
      };
    } catch (err) {
      const message = err instanceof Error ? err.message : String(err);
      const cause =
        err instanceof Error && err.cause instanceof Error ? err.cause.message : undefined;
      logger.error({ jobKey, error: message, cause }, "pr_mention_pipeline_aborted");
      // Re-throw so the queue's retry path (src/queue/memory-queue.ts) kicks in.
      // Pollers also rely on "bot hasn't replied yet" to re-enqueue, bounded by
      // MAX_ENQUEUE_ATTEMPTS in the mention poller to prevent infinite loops.
      throw err;
    }
  }

  // ── Shared utility methods (mirrored from PrCommentPipeline) ───────────────

  private async downloadCommentImages(
    commentBody: string,
    commentId: number,
    githubClient: GitHubClientImpl,
    repoPath: string,
    logger: Logger,
  ): Promise<string[]> {
    if (!this.hasImageUrls(commentBody)) return [];

    const html = await githubClient.getCommentBodyHtml(commentId);
    if (!html) return [];

    const urls = this.extractImageUrls(html);
    if (urls.length === 0) return [];

    logger.info({ count: urls.length }, "mention_images_urls_extracted");
    const toDownload = urls.slice(0, ATTACHMENTS.MAX_IMAGES);
    const dir = join(repoPath, HU_BOT_DIR);
    mkdirSync(dir, { recursive: true });

    const saved: string[] = [];
    for (let i = 0; i < toDownload.length; i++) {
      const url = toDownload[i]!;
      const ext = this.guessExtension(url);
      const filename = `comment-image-${i + 1}${ext}`;
      try {
        const res = await fetch(url);
        if (!res.ok) {
          logger.warn(
            { url: url.slice(0, 120), status: res.status },
            "mention_image_download_failed",
          );
          continue;
        }
        const buf = Buffer.from(await res.arrayBuffer());
        if (buf.length > ATTACHMENTS.MAX_SIZE_BYTES) {
          logger.warn({ size: buf.length }, "mention_image_too_large");
          continue;
        }
        writeFileSync(join(dir, filename), buf);
        saved.push(`.hu-bot/${filename}`);
      } catch (err) {
        logger.warn(
          { error: err instanceof Error ? err.message : String(err) },
          "mention_image_download_error",
        );
      }
    }

    if (saved.length > 0) {
      logger.info({ count: saved.length, total: urls.length }, "mention_images_saved");
    }
    return saved;
  }

  /**
   * When the mention is a review-comment reply, walk up the `in_reply_to_id` chain
   * to the root review comment and return it as primary context for the prompt.
   * Returns null when the mention is not inside a review thread, or when the parent
   * cannot be fetched (the agent still has flat history as a fallback).
   */
  private async loadParentThreadComment(
    githubClient: GitHubClientImpl,
    job: PrMentionJobPayload,
    logger: Logger,
  ): Promise<ParentThreadComment | null> {
    if (!job.isReviewComment || !job.inReplyToId) return null;

    try {
      let parent = await githubClient.getPullReviewComment(job.inReplyToId);
      // Walk to the root of the thread so the agent sees the original request, not
      // an intermediate reply.
      const visited = new Set<number>([parent.id]);
      while (parent.in_reply_to_id && !visited.has(parent.in_reply_to_id)) {
        visited.add(parent.in_reply_to_id);
        parent = await githubClient.getPullReviewComment(parent.in_reply_to_id);
      }
      return {
        author: parent.user?.login ?? "unknown",
        body: parent.body,
        path: parent.path,
        line: parent.line,
        createdAt: parent.created_at,
      };
    } catch (err) {
      logger.debug(
        {
          prNumber: job.prNumber,
          inReplyToId: job.inReplyToId,
          error: err instanceof Error ? err.message : String(err),
        },
        "pr_mention_parent_thread_fetch_failed",
      );
      return null;
    }
  }

  private async loadConversationContext(
    githubClient: GitHubClientImpl,
    job: PrMentionJobPayload,
    memoryKey: string,
    logger: Logger,
  ): Promise<{ conversationHistory: string | null; currentCommentAuthor: string }> {
    const [issueResult, reviewResult] = await Promise.allSettled([
      githubClient.listIssueComments(job.prNumber),
      githubClient.listPullReviewComments(job.prNumber),
    ]);

    const comments: ConversationComment[] = [];
    if (issueResult.status === "fulfilled") {
      comments.push(
        ...issueResult.value.map((comment) => ({
          id: comment.id,
          author: comment.user?.login ?? "unknown",
          body: comment.body ?? "",
          createdAt: comment.created_at,
          kind: "issue" as const,
        })),
      );
    } else {
      logger.warn(
        { prNumber: job.prNumber, error: String(issueResult.reason) },
        "pr_mention_history_issue_comments_failed",
      );
    }

    if (reviewResult.status === "fulfilled") {
      comments.push(
        ...reviewResult.value.map((comment) => ({
          id: comment.id,
          author: comment.user?.login ?? "unknown",
          body: comment.body,
          createdAt: comment.created_at,
          kind: "review" as const,
        })),
      );
    } else {
      logger.warn(
        { prNumber: job.prNumber, error: String(reviewResult.reason) },
        "pr_mention_history_review_comments_failed",
      );
    }

    comments.sort((a, b) => new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime());

    const currentComment = comments.find((comment) => comment.id === job.commentId);
    const previousComments = comments.filter((comment) => comment.id !== job.commentId);
    const memoryEntries = loadPrMemory(memoryKey);

    return {
      conversationHistory: this.renderConversationHistory(previousComments, memoryEntries),
      currentCommentAuthor: currentComment?.author ?? job.mentionAuthor,
    };
  }

  private renderConversationHistory(
    comments: ConversationComment[],
    memoryEntries: PrMemoryEntry[],
  ): string | null {
    const blocks = [
      ...comments.map((comment) => ({
        timestamp: comment.createdAt,
        text: [
          `### ${comment.kind === "review" ? "Review comment" : "Issue comment"} (${this.formatDate(comment.createdAt)})`,
          `**${comment.author}**`,
          `> ${this.truncateMemoryText(comment.body).replace(/\n/g, "\n> ")}`,
        ].join("\n"),
      })),
      ...memoryEntries.map((entry) => ({
        timestamp: entry.timestamp,
        text: [
          `### Prior bot interaction (${this.formatDate(entry.timestamp)})`,
          `**Human (${entry.commentAuthor})**`,
          `> ${entry.commentBody.replace(/\n/g, "\n> ")}`,
          "",
          "**Agent reply**",
          `> ${entry.agentReply.replace(/\n/g, "\n> ")}`,
          entry.filesChanged.length > 0
            ? `**Files changed:** ${entry.filesChanged.join(", ")}`
            : "",
        ]
          .filter(Boolean)
          .join("\n"),
      })),
    ].sort((a, b) => new Date(a.timestamp).getTime() - new Date(b.timestamp).getTime());

    if (blocks.length === 0) return null;

    const selected: string[] = [];
    let total = 0;
    for (let i = blocks.length - 1; i >= 0; i--) {
      const block = blocks[i]!.text;
      const nextTotal = total + block.length;
      if (selected.length > 0 && nextTotal > MEMORY.MAX_HISTORY_CHARS) break;
      selected.push(block);
      total = nextTotal;
    }

    return [
      "Earlier conversation and prior bot interactions on this PR (oldest to newest):",
      "",
      ...selected.reverse(),
    ].join("\n\n");
  }

  private formatDate(value: string): string {
    return value.split("T")[0] ?? value;
  }

  private truncateMemoryText(text: string): string {
    return text.trim().slice(0, MEMORY.MAX_COMMENT_CHARS);
  }

  private hasImageUrls(text: string): boolean {
    for (const re of [ATTACHMENTS.MD_IMAGE_RE, ATTACHMENTS.HTML_IMG_RE]) {
      re.lastIndex = 0;
      if (re.test(text)) return true;
    }
    return false;
  }

  private extractImageUrls(html: string): string[] {
    const urls: string[] = [];
    let match: RegExpExecArray | null;
    ATTACHMENTS.HTML_IMG_RE.lastIndex = 0;
    while ((match = ATTACHMENTS.HTML_IMG_RE.exec(html)) !== null) {
      if (match[1]) urls.push(match[1]);
    }
    return urls;
  }

  private guessExtension(url: string): string {
    const path = new URL(url).pathname.toLowerCase();
    if (path.endsWith(".png")) return ".png";
    if (path.endsWith(".jpg") || path.endsWith(".jpeg")) return ".jpg";
    if (path.endsWith(".gif")) return ".gif";
    if (path.endsWith(".webp")) return ".webp";
    return ".png";
  }

  private cleanupImages(repoPath: string): void {
    const dir = join(repoPath, HU_BOT_DIR);
    if (existsSync(dir)) {
      rmSync(dir, { recursive: true, force: true });
    }
  }

  private loadRepoRules(repo: RepoConfig, localRepoPath: string): string | null {
    const parts: string[] = [];

    const repoRules = this.loadIndexFile(
      join(localRepoPath, repo.rulesIndexFile ?? ".cursor/rules/hu-agent.mdc"),
    );
    if (repoRules) parts.push(repoRules);

    if (repo.rulesFile) {
      const botRules = this.loadIndexFile(resolve(process.cwd(), repo.rulesFile));
      if (botRules) parts.push(botRules);
    }

    return parts.length > 0 ? parts.join("\n\n") : null;
  }

  private loadIndexFile(indexPath: string): string | null {
    if (!existsSync(indexPath)) return null;

    let indexContent: string;
    try {
      indexContent = readFileSync(indexPath, "utf-8");
    } catch {
      return null;
    }

    const indexDir = dirname(indexPath);
    const LINK_RE = /\[([^\]]*)\]\(([^)]+)\)/g;
    const sections: string[] = [indexContent];
    let totalSize = indexContent.length;
    const { SIZE_LIMIT, PER_FILE_LIMIT } = RULES_LOADER;

    let match: RegExpExecArray | null;
    while ((match = LINK_RE.exec(indexContent)) !== null) {
      const [, label, href] = match;
      if (!href || /^https?:\/\//i.test(href)) continue;

      const filePath = resolve(indexDir, href);
      try {
        if (!existsSync(filePath)) continue;
        const raw = readFileSync(filePath, "utf-8");
        const remaining = SIZE_LIMIT - totalSize;
        if (remaining <= 0) break;
        const chunk = raw.slice(0, Math.min(remaining, PER_FILE_LIMIT));
        sections.push(`\n## ${label || href}\n${chunk}`);
        totalSize += chunk.length;
      } catch {
        this.deps.logger.debug({ href }, "rules_index_ref_read_error");
      }
    }

    return sections.join("\n");
  }
}
