import { describe, test, expect, mock, beforeEach, afterEach, spyOn } from "bun:test";

import {
  DailyReportScheduler,
  collectDailyReport,
  type CollectReportDeps,
  type DailyReportData,
  type DailyReportSchedulerDeps,
  type PrSnapshot,
} from "@/scheduler/daily-report.js";
import type { DailyReportStore } from "@/scheduler/daily-report-store.js";
import type { JiraClientImpl } from "@/jira/client.js";
import type { RepoRegistry } from "@/repo/registry.js";
import type { SlackNotifier } from "@/slack/client.js";
import { mockLogger } from "../_helpers/mock-logger.js";
import { jsonResponse } from "../_helpers/fake-fetch.js";

const BOT_USER = "hu-agent[bot]";

interface NotifyDailyReportPayload {
  ticketsByTeam: Record<string, unknown[]>;
  closedPrs?: PrSnapshot[];
}

function makeNotifier() {
  const notifyDailyReport = mock(
    async (_payload: NotifyDailyReportPayload, _channel?: string) => {},
  );
  return { notifyDailyReport };
}

function makeStore(initial: Map<string, PrSnapshot> = new Map()) {
  const loadOpenPrs = mock(async () => initial);
  const saveOpenPrs = mock(async (_prs: Map<string, PrSnapshot>) => {});
  const store = { loadOpenPrs, saveOpenPrs } as unknown as DailyReportStore;
  return { store, loadOpenPrs, saveOpenPrs };
}

/**
 * Builds a fake `collect` function returning a fixed report. Each invocation can be
 * overridden via successive return values (FIFO) to simulate state changes across ticks.
 */
function fakeCollect(reports: DailyReportData[]) {
  let i = 0;
  return mock(async (_deps: CollectReportDeps): Promise<DailyReportData> => {
    const next = reports[Math.min(i, reports.length - 1)];
    i++;
    if (!next) throw new Error("fakeCollect: no report queued");
    return next;
  });
}

function emptyReport(overrides: Partial<DailyReportData> = {}): DailyReportData {
  return {
    totalTickets: 0,
    totalWithPr: 0,
    totalWithoutPr: 0,
    teams: 0,
    ticketsByTeam: {},
    openPrs: new Map(),
    failedRepos: [],
    ...overrides,
  };
}

function makeScheduler(opts: {
  notifier: ReturnType<typeof makeNotifier>;
  store?: DailyReportStore;
  collect: (deps: CollectReportDeps) => Promise<DailyReportData>;
}): DailyReportScheduler {
  const deps: DailyReportSchedulerDeps = {
    repoRegistry: { getAll: () => [] } as unknown as RepoRegistry,
    getGitHubToken: async () => "token",
    slackNotifier: opts.notifier as unknown as SlackNotifier,
    slackDailyReportChannelId: "C123",
    dailyReportHours: [],
    dailyReportTimezone: "UTC",
    githubBotUser: BOT_USER,
    jiraClient: {} as unknown as JiraClientImpl,
    jiraDomain: "example",
    squadScope: [],
    botEmail: "bot@example.com",
    logger: mockLogger(),
    store: opts.store,
    collect: opts.collect,
  };
  return new DailyReportScheduler(deps);
}

const PR42: PrSnapshot = {
  repo: "repo-a",
  number: 42,
  url: "https://gh/repo-a/pull/42",
  title: "Old PR",
};
const PR42_KEY = "repo-a#42";

async function loadState(scheduler: DailyReportScheduler): Promise<void> {
  await (scheduler as unknown as { loadPersistedState: () => Promise<void> }).loadPersistedState();
}

// ── Helpers for collectDailyReport / DailyReportScheduler integration tests ──

/** Builds an array of PR objects in Octokit pull-list wire format */
function makePrList(
  prs: Array<{
    number: number;
    url: string;
    title: string;
    author: string;
    headRef: string;
  }> = [],
) {
  return prs.map((pr) => ({
    number: pr.number,
    html_url: pr.url,
    title: pr.title,
    user: { login: pr.author },
    head: { ref: pr.headRef },
  }));
}

/**
 * Creates a fetch spy that returns canned GitHub API responses.
 * prs: open PRs for ALL repos (all return the same list unless overridden per-call).
 */
function makeFetchSpy(prs: Parameters<typeof makePrList>[0] = []) {
  const impl = async (...args: Parameters<typeof fetch>) => {
    const [input] = args;
    const url =
      typeof input === "string"
        ? input
        : input instanceof URL
          ? input.href
          : (input as Request).url;

    // GET /repos/.../pulls?state=open → listOpenPrs
    if (url.match(/\/repos\/[^/]+\/[^/]+\/pulls(\?|$)/)) {
      return jsonResponse(makePrList(prs));
    }

    // GET /repos/{owner}/{repo} → repo info for GitHubClientImpl constructor
    if (url.match(/api\.github\.com\/repos\/[^/]+\/[^/]+$/)) {
      return jsonResponse({ full_name: "org/my-api", default_branch: "main" });
    }

    return jsonResponse({});
  };
  return spyOn(globalThis, "fetch").mockImplementation(impl as typeof fetch);
}

function makeJiraClient(
  opts: {
    accountId?: string | null;
    issues?: Array<{
      key: string;
      summary: string;
      status: string;
      assignee?: { accountId: string } | null;
    }>;
  } = {},
) {
  const accountId = "accountId" in opts ? opts.accountId : "bot-account-123";
  const issues = opts.issues ?? [
    {
      key: "SQDP-1",
      summary: "Fix login bug",
      status: "In Progress",
      assignee: accountId ? { accountId } : null,
    },
  ];

  return {
    resolveAccountIdForBotEmail: mock(async () => accountId),
    searchIssues: mock(async () => ({
      issues: issues.map((i) => ({
        ...i,
        id: "1",
        description: null,
        issueType: "Bug",
        created: null,
        acceptanceCriteria: null,
        stacktrace: null,
        flow: null,
        parent: null,
        subtasks: [],
        children: [],
        attachments: [],
        comments: [],
        components: [],
        assignee:
          i.assignee ??
          (accountId
            ? { accountId, displayName: "hu-agent", emailAddress: "bot@example.com" }
            : null),
      })),
      total: issues.length,
    })),
    getLastStatusChangeDates: mock(
      async (keys: string[]) => new Map(keys.map((k) => [k, "2026-05-01T10:00:00Z"])),
    ),
  };
}

function makeSlackNotifier() {
  return {
    notifyDailyReport: mock(async () => {}),
  };
}

function makeSchedulerDeps(
  overrides: Partial<DailyReportSchedulerDeps> = {},
): DailyReportSchedulerDeps {
  return {
    repoRegistry: {
      getAll: mock(() => []),
      getByName: mock(() => undefined),
    } as unknown as DailyReportSchedulerDeps["repoRegistry"],
    getGitHubToken: async () => "ghs_token",
    slackNotifier: makeSlackNotifier() as unknown as DailyReportSchedulerDeps["slackNotifier"],
    slackDailyReportChannelId: "C01TEST",
    dailyReportHours: [9, 17],
    dailyReportTimezone: "America/Argentina/Buenos_Aires",
    githubBotUser: "hu-agent[bot]",
    jiraClient: makeJiraClient() as unknown as DailyReportSchedulerDeps["jiraClient"],
    jiraDomain: "test-company",
    squadScope: [],
    botEmail: "bot@example.com",
    logger: mockLogger(),
    ...overrides,
  };
}

describe("DailyReportScheduler.sendReport (closedPrs gating)", () => {
  test("does not report closedPrs when collect signals a partial fetch", async () => {
    const { store, saveOpenPrs } = makeStore(new Map([[PR42_KEY, PR42]]));
    const notifier = makeNotifier();
    // Partial fetch: failedRepos is non-empty AND openPrs is empty (PR #42 missing).
    const collect = fakeCollect([emptyReport({ failedRepos: ["repo-a"] })]);
    const scheduler = makeScheduler({ notifier, store, collect });

    await loadState(scheduler);
    await scheduler.triggerNow();

    for (const call of notifier.notifyDailyReport.mock.calls) {
      expect(call[0].closedPrs).toBeUndefined();
    }
    expect(saveOpenPrs).not.toHaveBeenCalled();
  });

  test("does not advance state when store.saveOpenPrs fails, and reports cleanly on retry", async () => {
    const { store, saveOpenPrs } = makeStore(new Map([[PR42_KEY, PR42]]));
    saveOpenPrs.mockImplementation(async () => {
      throw new Error("DynamoDB unavailable");
    });
    const notifier = makeNotifier();
    // Both ticks return the same: PR #42 has been merged externally.
    const merged = emptyReport({ totalTickets: 0, openPrs: new Map() });
    const collect = fakeCollect([merged, merged]);
    const scheduler = makeScheduler({ notifier, store, collect });

    await loadState(scheduler);

    // First tick: save fails → closedPrs is suppressed AND in-memory state is preserved.
    await scheduler.triggerNow();
    for (const call of notifier.notifyDailyReport.mock.calls) {
      expect(call[0].closedPrs).toBeUndefined();
    }

    // Second tick: save succeeds → closedPrs is reported because the in-memory state
    // was never advanced past the original snapshot.
    saveOpenPrs.mockImplementation(async () => {});
    notifier.notifyDailyReport.mockClear();
    await scheduler.triggerNow();

    expect(notifier.notifyDailyReport.mock.calls).toHaveLength(1);
    const closed = notifier.notifyDailyReport.mock.calls[0]?.[0].closedPrs;
    expect(closed).toBeDefined();
    expect(closed).toHaveLength(1);
    expect(closed?.[0]?.number).toBe(42);
  });

  test("reports closedPrs once and does not duplicate on the next tick", async () => {
    const { store } = makeStore(new Map([[PR42_KEY, PR42]]));
    const notifier = makeNotifier();
    // Tick 1: PR #42 has been merged.   Tick 2: nothing changed, no new closures.
    const collect = fakeCollect([emptyReport(), emptyReport()]);
    const scheduler = makeScheduler({ notifier, store, collect });

    await loadState(scheduler);

    await scheduler.triggerNow();
    expect(notifier.notifyDailyReport.mock.calls).toHaveLength(1);
    expect(notifier.notifyDailyReport.mock.calls[0]?.[0].closedPrs).toHaveLength(1);

    notifier.notifyDailyReport.mockClear();
    await scheduler.triggerNow();
    // Empty report (no tickets, no closedPrs) → skipped entirely.
    expect(notifier.notifyDailyReport).not.toHaveBeenCalled();
  });

  test("partial fetch does not advance memory state, so closure is reported on the next clean tick", async () => {
    const { store } = makeStore(new Map([[PR42_KEY, PR42]]));
    const notifier = makeNotifier();
    const collect = fakeCollect([
      // Tick 1: GitHub partially failed. PR #42 is missing from openPrs but should NOT be reported.
      emptyReport({ failedRepos: ["repo-a"] }),
      // Tick 2: GitHub recovered, PR #42 is genuinely missing → report it as closed exactly once.
      emptyReport(),
    ]);
    const scheduler = makeScheduler({ notifier, store, collect });

    await loadState(scheduler);

    await scheduler.triggerNow();
    for (const call of notifier.notifyDailyReport.mock.calls) {
      expect(call[0].closedPrs).toBeUndefined();
    }

    notifier.notifyDailyReport.mockClear();
    await scheduler.triggerNow();
    expect(notifier.notifyDailyReport.mock.calls).toHaveLength(1);
    expect(notifier.notifyDailyReport.mock.calls[0]?.[0].closedPrs).toHaveLength(1);
  });
});

// ── collectDailyReport tests ──────────────────────────────────────────────────

describe("collectDailyReport()", () => {
  let fetchSpy: ReturnType<typeof spyOn>;

  beforeEach(() => {
    fetchSpy = makeFetchSpy();
  });

  afterEach(() => {
    fetchSpy.mockRestore();
  });

  test("returns empty report when no repos are configured", async () => {
    const report = await collectDailyReport({
      repoRegistry: {
        getAll: mock(() => []),
      } as unknown as Parameters<typeof collectDailyReport>[0]["repoRegistry"],
      getGitHubToken: async () => "tok",
      githubBotUser: "hu-agent[bot]",
      logger: mockLogger(),
      jiraClient: makeJiraClient({
        issues: [],
      }) as unknown as Parameters<typeof collectDailyReport>[0]["jiraClient"],
      jiraDomain: "test",
      squadScope: [],
      botEmail: "bot@example.com",
    });

    expect(report.totalTickets).toBe(0);
    expect(report.totalWithPr).toBe(0);
    expect(report.totalWithoutPr).toBe(0);
    expect(report.teams).toBe(0);
    expect(report.openPrs.size).toBe(0);
  });

  test("collects bot PRs and maps them to Jira tickets via branch name", async () => {
    // Override fetch spy to return one PR
    fetchSpy.mockRestore();
    fetchSpy = makeFetchSpy([
      {
        number: 1,
        url: "https://github.com/org/my-api/pull/1",
        title: "Fix login",
        author: "hu-agent[bot]",
        headRef: "fix/SQDP-1-login-bug",
      },
    ]);

    const jiraClient = makeJiraClient({
      issues: [{ key: "SQDP-1", summary: "Fix login bug", status: "In Progress" }],
    });

    const report = await collectDailyReport({
      repoRegistry: {
        getAll: mock(() => [
          {
            name: "my-api",
            url: "https://github.com/org/my-api",
            description: "",
            keywords: [],
          },
        ]),
      } as unknown as Parameters<typeof collectDailyReport>[0]["repoRegistry"],
      getGitHubToken: async () => "tok",
      githubBotUser: "hu-agent[bot]",
      logger: mockLogger(),
      jiraClient: jiraClient as unknown as Parameters<typeof collectDailyReport>[0]["jiraClient"],
      jiraDomain: "test-company",
      squadScope: [],
      botEmail: "bot@example.com",
    });

    expect(report.totalTickets).toBe(1);
    expect(report.totalWithPr).toBe(1);
    expect(report.totalWithoutPr).toBe(0);
    const sqdpTickets = report.ticketsByTeam["SQDP"];
    expect(sqdpTickets).toBeDefined();
    expect(sqdpTickets?.[0]?.prs).toHaveLength(1);
    expect(sqdpTickets?.[0]?.prs[0]?.url).toBe("https://github.com/org/my-api/pull/1");
  });

  test("skips PRs from non-bot authors", async () => {
    fetchSpy.mockRestore();
    fetchSpy = makeFetchSpy([
      {
        number: 2,
        url: "https://github.com/org/my-api/pull/2",
        title: "Human PR",
        author: "human-dev",
        headRef: "fix/SQDP-1-human",
      },
    ]);

    const jiraClient = makeJiraClient({
      issues: [{ key: "SQDP-1", summary: "Fix login", status: "In Progress" }],
    });

    const report = await collectDailyReport({
      repoRegistry: {
        getAll: mock(() => [
          {
            name: "my-api",
            url: "https://github.com/org/my-api",
            description: "",
            keywords: [],
          },
        ]),
      } as unknown as Parameters<typeof collectDailyReport>[0]["repoRegistry"],
      getGitHubToken: async () => "tok",
      githubBotUser: "hu-agent[bot]",
      logger: mockLogger(),
      jiraClient: jiraClient as unknown as Parameters<typeof collectDailyReport>[0]["jiraClient"],
      jiraDomain: "test-company",
      squadScope: [],
      botEmail: "bot@example.com",
    });

    expect(report.totalWithPr).toBe(0);
    expect(report.openPrs.size).toBe(0);
  });

  test("Jira API failure is non-blocking: returns partial report", async () => {
    const jiraClient = {
      resolveAccountIdForBotEmail: mock(async () => {
        throw new Error("Jira down");
      }),
      searchIssues: mock(async () => ({ issues: [], total: 0 })),
      getLastStatusChangeDates: mock(async () => new Map()),
    };

    const report = await collectDailyReport({
      repoRegistry: {
        getAll: mock(() => []),
      } as unknown as Parameters<typeof collectDailyReport>[0]["repoRegistry"],
      getGitHubToken: async () => "tok",
      githubBotUser: "hu-agent[bot]",
      logger: mockLogger(),
      jiraClient: jiraClient as unknown as Parameters<typeof collectDailyReport>[0]["jiraClient"],
      jiraDomain: "test",
      squadScope: [],
      botEmail: "bot@example.com",
    });

    // Should not throw and return empty report
    expect(report.totalTickets).toBe(0);
  });

  test("bot account not found: skips Jira issue collection", async () => {
    const jiraClient = makeJiraClient({ accountId: null, issues: [] });

    const report = await collectDailyReport({
      repoRegistry: {
        getAll: mock(() => []),
      } as unknown as Parameters<typeof collectDailyReport>[0]["repoRegistry"],
      getGitHubToken: async () => "tok",
      githubBotUser: "hu-agent[bot]",
      logger: mockLogger(),
      jiraClient: jiraClient as unknown as Parameters<typeof collectDailyReport>[0]["jiraClient"],
      jiraDomain: "test",
      squadScope: [],
      botEmail: "bot@example.com",
    });

    expect(report.totalTickets).toBe(0);
    expect(jiraClient.searchIssues.mock.calls.length).toBe(0);
  });
});

// ── DailyReportScheduler tests ────────────────────────────────────────────────

describe("DailyReportScheduler", () => {
  let fetchSpy: ReturnType<typeof spyOn>;

  beforeEach(() => {
    fetchSpy = makeFetchSpy();
  });

  afterEach(() => {
    fetchSpy.mockRestore();
  });

  test("start() registers Cron jobs and stop() clears them", () => {
    const deps = makeSchedulerDeps();
    const scheduler = new DailyReportScheduler(deps);

    scheduler.start();
    // Second call is idempotent
    scheduler.start();

    // Calling stop should not throw
    scheduler.stop();
    scheduler.stop(); // Idempotent stop too
  });

  test("triggerNow() sends report when there are open tickets", async () => {
    fetchSpy.mockRestore();
    fetchSpy = makeFetchSpy([
      {
        number: 1,
        url: "https://github.com/org/my-api/pull/1",
        title: "Fix login",
        author: "hu-agent[bot]",
        headRef: "fix/SQDP-1-login-bug",
      },
    ]);

    const slackNotifier = makeSlackNotifier();
    const jiraClient = makeJiraClient({
      issues: [{ key: "SQDP-1", summary: "Fix login bug", status: "In Progress" }],
    });

    const deps = makeSchedulerDeps({
      repoRegistry: {
        getAll: mock(() => [
          {
            name: "my-api",
            url: "https://github.com/org/my-api",
            description: "",
            keywords: [],
          },
        ]),
        getByName: mock(() => undefined),
      } as unknown as DailyReportSchedulerDeps["repoRegistry"],
      slackNotifier: slackNotifier as unknown as DailyReportSchedulerDeps["slackNotifier"],
      jiraClient: jiraClient as unknown as DailyReportSchedulerDeps["jiraClient"],
    });

    const scheduler = new DailyReportScheduler(deps);
    await scheduler.triggerNow();

    expect((slackNotifier.notifyDailyReport as ReturnType<typeof mock>).mock.calls.length).toBe(1);
  });

  test("triggerNow() skips report when there are no tickets and no closed PRs", async () => {
    const slackNotifier = makeSlackNotifier();
    const jiraClient = makeJiraClient({ issues: [], accountId: "bot-account-123" });

    const deps = makeSchedulerDeps({
      slackNotifier: slackNotifier as unknown as DailyReportSchedulerDeps["slackNotifier"],
      jiraClient: jiraClient as unknown as DailyReportSchedulerDeps["jiraClient"],
    });

    const scheduler = new DailyReportScheduler(deps);
    await scheduler.triggerNow();

    // notifyDailyReport must NOT be called when report is empty
    expect((slackNotifier.notifyDailyReport as ReturnType<typeof mock>).mock.calls.length).toBe(0);
  });

  test("concurrent triggerNow() calls are deduplicated", async () => {
    const slackNotifier = makeSlackNotifier();
    const jiraClient = makeJiraClient({
      issues: [{ key: "SQDP-1", summary: "Fix login", status: "In Progress" }],
    });

    const deps = makeSchedulerDeps({
      slackNotifier: slackNotifier as unknown as DailyReportSchedulerDeps["slackNotifier"],
      jiraClient: jiraClient as unknown as DailyReportSchedulerDeps["jiraClient"],
    });

    const scheduler = new DailyReportScheduler(deps);
    // Fire two concurrent triggers — only one report should be sent
    await Promise.all([scheduler.triggerNow(), scheduler.triggerNow()]);

    // At most 1 call (second is deduplicated via sendInFlight flag)
    expect(
      (slackNotifier.notifyDailyReport as ReturnType<typeof mock>).mock.calls.length,
    ).toBeLessThanOrEqual(1);
  });

  test("store is used to persist and load open PRs", async () => {
    const store = {
      loadOpenPrs: mock(async () => new Map()),
      saveOpenPrs: mock(async () => {}),
    };

    const jiraClient = makeJiraClient({
      issues: [{ key: "SQDP-1", summary: "Fix login bug", status: "In Progress" }],
    });

    const deps = makeSchedulerDeps({
      store: store as unknown as DailyReportSchedulerDeps["store"],
      jiraClient: jiraClient as unknown as DailyReportSchedulerDeps["jiraClient"],
    });

    const scheduler = new DailyReportScheduler(deps);
    scheduler.start();
    await scheduler.triggerNow();
    scheduler.stop();

    // saveOpenPrs should have been called after the report
    expect((store.saveOpenPrs as ReturnType<typeof mock>).mock.calls.length).toBe(1);
  });

  test("Slack notifyDailyReport error is caught and logged", async () => {
    const slackNotifier = {
      notifyDailyReport: mock(async () => {
        throw new Error("Slack API down");
      }),
    };

    const jiraClient = makeJiraClient({
      issues: [{ key: "SQDP-1", summary: "Fix login bug", status: "In Progress" }],
    });

    const logger = mockLogger();
    const deps = makeSchedulerDeps({
      slackNotifier: slackNotifier as unknown as DailyReportSchedulerDeps["slackNotifier"],
      jiraClient: jiraClient as unknown as DailyReportSchedulerDeps["jiraClient"],
      logger,
    });

    const scheduler = new DailyReportScheduler(deps);
    // Should not throw
    await scheduler.triggerNow();

    expect((logger.error as ReturnType<typeof mock>).mock.calls.length).toBe(1);
  });
});
