import type { Logger } from "pino";
import { Cron } from "croner";
import { DAILY_REPORT } from "@/core/constants.js";
import type { JiraClientImpl } from "@/jira/client.js";
import { GitHubClientImpl } from "@/github/client.js";
import type { RepoRegistry } from "@/repo/registry.js";
import type { SlackNotifier } from "@/slack/client.js";
import type { DailyReportStore } from "./daily-report-store.js";

/** Issue keys embedded in a branch name (e.g. fix/SQDP-3528-desc). */
const ISSUE_KEY_IN_REF_RE = /[A-Z]{2,10}-\d+/g;

function extractIssueKeysFromHeadRef(headRef: string): Set<string> {
  const set = new Set<string>();
  const re = new RegExp(ISSUE_KEY_IN_REF_RE.source, "g");
  let m: RegExpExecArray | null;
  while ((m = re.exec(headRef)) !== null) {
    set.add(m[0]);
  }
  return set;
}

function extractTeamFromIssueKey(issueKey: string): string {
  const m = issueKey.match(DAILY_REPORT.TICKET_PREFIX_RE);
  return m?.[1] ?? "Other";
}

export interface DailyReportPrRef {
  url: string;
  title: string;
  repoName: string;
}

export interface DailyReportTicketRow {
  key: string;
  summary: string;
  jiraStatus: string;
  jiraUrl: string;
  prs: DailyReportPrRef[];
  /** ISO date of the last status transition, or `null` if unknown. */
  lastStatusChangeAt: string | null;
}

export interface PrSnapshot {
  repo: string;
  number: number;
  url: string;
  title: string;
}

export interface DailyReportData {
  totalTickets: number;
  totalWithPr: number;
  totalWithoutPr: number;
  /** Distinct team prefixes with at least one ticket. */
  teams: number;
  ticketsByTeam: Record<string, DailyReportTicketRow[]>;
  /** Current open bot PRs keyed by `repo#number`. */
  openPrs: Map<string, PrSnapshot>;
  /** Names of repos whose `listOpenPrs` call failed; their PRs are missing from `openPrs`. */
  failedRepos: string[];
}

export interface CollectReportDeps {
  repoRegistry: RepoRegistry;
  getGitHubToken: () => Promise<string>;
  githubBotUser: string;
  logger: Logger;
  jiraClient: JiraClientImpl;
  jiraDomain: string;
  squadScope: string[];
  botEmail: string;
}

async function resolveBotAccountId(
  jiraClient: JiraClientImpl,
  botEmail: string,
  logger: Logger,
): Promise<string | null> {
  const accountId = await jiraClient.resolveAccountIdForBotEmail(botEmail);
  if (!accountId) {
    logger.warn({ botEmail }, "daily_report_bot_account_not_found");
  }
  return accountId;
}

export async function collectDailyReport(deps: CollectReportDeps): Promise<DailyReportData> {
  const token = await deps.getGitHubToken();
  const repos = deps.repoRegistry.getAll();
  const prByIssueKey = new Map<string, DailyReportPrRef[]>();
  const openPrs = new Map<string, PrSnapshot>();
  const failedRepos: string[] = [];

  for (const repo of repos) {
    const client = new GitHubClientImpl(token, repo.url, "", deps.logger);
    let prs;
    try {
      prs = await client.listOpenPrs();
    } catch (err) {
      deps.logger.warn(
        { repo: repo.name, error: err instanceof Error ? err.message : String(err) },
        "daily_report_list_prs_failed",
      );
      failedRepos.push(repo.name);
      continue;
    }

    const botPrs = prs.filter((pr) => pr.author === deps.githubBotUser);
    for (const pr of botPrs) {
      const ref: DailyReportPrRef = { url: pr.url, title: pr.title, repoName: repo.name };
      openPrs.set(`${repo.name}#${pr.number}`, {
        repo: repo.name,
        number: pr.number,
        url: pr.url,
        title: pr.title,
      });
      for (const key of extractIssueKeysFromHeadRef(pr.headRef)) {
        const list = prByIssueKey.get(key) ?? [];
        list.push(ref);
        prByIssueKey.set(key, list);
      }
    }
  }

  const ticketsByTeam: Record<string, DailyReportTicketRow[]> = {};

  try {
    const accountId = await resolveBotAccountId(deps.jiraClient, deps.botEmail, deps.logger);
    if (accountId) {
      const projectFilter =
        deps.squadScope.length > 0 ? `project in (${deps.squadScope.join(", ")}) AND ` : "";
      const jql = `${projectFilter}assignee = "${accountId}" AND statusCategory != Done ORDER BY updated DESC`;
      const result = await deps.jiraClient.searchIssues(jql, 100);
      const base = `https://${deps.jiraDomain}.atlassian.net/browse`;

      // Fetch last status change dates for all issues in parallel.
      const allKeys = result.issues
        .filter((i) => i.assignee?.accountId === accountId)
        .map((i) => i.key);
      const statusChangeDates = await deps.jiraClient.getLastStatusChangeDates(allKeys);

      let droppedByAssigneeMismatch = 0;
      for (const issue of result.issues) {
        if (issue.assignee?.accountId !== accountId) {
          droppedByAssigneeMismatch++;
          continue;
        }
        const team = extractTeamFromIssueKey(issue.key);
        if (!ticketsByTeam[team]) ticketsByTeam[team] = [];
        ticketsByTeam[team]!.push({
          key: issue.key,
          summary: issue.summary,
          jiraStatus: issue.status,
          jiraUrl: `${base}/${issue.key}`,
          prs: prByIssueKey.get(issue.key) ?? [],
          lastStatusChangeAt: statusChangeDates.get(issue.key) ?? null,
        });
      }

      for (const team of Object.keys(ticketsByTeam)) {
        ticketsByTeam[team]!.sort((a, b) => a.key.localeCompare(b.key));
      }
      if (droppedByAssigneeMismatch > 0) {
        deps.logger.warn(
          { droppedByAssigneeMismatch, accountId },
          "daily_report_dropped_assignee_mismatch",
        );
      }
    }
  } catch (err) {
    deps.logger.warn(
      { error: err instanceof Error ? err.message : String(err) },
      "daily_report_jira_collect_failed",
    );
  }

  const flat = Object.values(ticketsByTeam).flat();
  const totalTickets = flat.length;
  const totalWithPr = flat.filter((t) => t.prs.length > 0).length;
  const totalWithoutPr = totalTickets - totalWithPr;

  return {
    totalTickets,
    totalWithPr,
    totalWithoutPr,
    teams: Object.keys(ticketsByTeam).length,
    ticketsByTeam,
    openPrs,
    failedRepos,
  };
}

export interface DailyReportSchedulerDeps {
  repoRegistry: RepoRegistry;
  getGitHubToken: () => Promise<string>;
  slackNotifier: SlackNotifier;
  slackDailyReportChannelId: string | undefined;
  dailyReportHours: number[];
  /** IANA timezone used for `dailyReportHours` and deduplication by calendar day. */
  dailyReportTimezone: string;
  githubBotUser: string;
  jiraClient: JiraClientImpl;
  jiraDomain: string;
  squadScope: string[];
  botEmail: string;
  logger: Logger;
  store?: DailyReportStore;
  /** Override for the collector — used in tests. Defaults to {@link collectDailyReport}. */
  collect?: (deps: CollectReportDeps) => Promise<DailyReportData>;
}

export class DailyReportScheduler {
  private jobs: Cron[] = [];
  /** Snapshot of open bot PRs from the previous report, keyed by `repo#number`. */
  private previousOpenPrs = new Map<string, PrSnapshot>();
  /** Prevents overlapping sends. */
  private sendInFlight = false;

  constructor(private readonly deps: DailyReportSchedulerDeps) {}

  start(): void {
    if (this.jobs.length > 0) return;

    const tz = this.deps.dailyReportTimezone;

    for (const hour of this.deps.dailyReportHours) {
      // "0 9 * * 1-5" = minute 0, hour 9, Mon-Fri
      const pattern = `0 ${hour} * * 1-5`;
      const job = new Cron(pattern, { timezone: tz, protect: true }, () => {
        void this.sendReport();
      });
      this.jobs.push(job);
    }

    this.deps.logger.info(
      {
        dailyReportHours: this.deps.dailyReportHours,
        dailyReportTimezone: tz,
        channelId: this.deps.slackDailyReportChannelId ?? "default",
      },
      "daily_report_scheduler_started",
    );

    void this.loadPersistedState();
  }

  private async loadPersistedState(): Promise<void> {
    if (!this.deps.store) return;
    try {
      this.previousOpenPrs = await this.deps.store.loadOpenPrs();
      this.deps.logger.info(
        { count: this.previousOpenPrs.size },
        "daily_report_loaded_persisted_state",
      );
    } catch (err) {
      this.deps.logger.warn(
        { error: err instanceof Error ? err.message : String(err) },
        "daily_report_load_persisted_state_failed",
      );
    }
  }

  stop(): void {
    for (const job of this.jobs) job.stop();
    this.jobs = [];
    this.deps.logger.info("daily_report_scheduler_stopped");
  }

  async triggerNow(): Promise<void> {
    await this.sendReport();
  }

  private async sendReport(): Promise<void> {
    if (this.sendInFlight) return;
    this.sendInFlight = true;
    const logger = this.deps.logger.child({ module: "daily-report" });

    try {
      const collect = this.deps.collect ?? collectDailyReport;
      const report = await collect({
        repoRegistry: this.deps.repoRegistry,
        getGitHubToken: this.deps.getGitHubToken,
        githubBotUser: this.deps.githubBotUser,
        logger,
        jiraClient: this.deps.jiraClient,
        jiraDomain: this.deps.jiraDomain,
        squadScope: this.deps.squadScope,
        botEmail: this.deps.botEmail,
      });

      // Only diff & persist when the GitHub fetch was complete. A partial fetch
      // (any repo's listOpenPrs failed) would falsely mark that repo's open PRs
      // as "closed" because they'd be missing from `report.openPrs`.
      let closedPrs: PrSnapshot[] | undefined;
      if (report.failedRepos.length === 0) {
        const diff: PrSnapshot[] = [];
        for (const [key, snap] of this.previousOpenPrs) {
          if (!report.openPrs.has(key)) diff.push(snap);
        }

        let stateCommitted = true;
        if (this.deps.store) {
          try {
            await this.deps.store.saveOpenPrs(report.openPrs);
          } catch (err) {
            stateCommitted = false;
            logger.warn(
              { error: err instanceof Error ? err.message : String(err) },
              "daily_report_persist_state_failed",
            );
          }
        }

        // Only commit the new snapshot (in-memory + closedPrs notification) if the
        // store write succeeded. Otherwise a restart would reload stale state and
        // re-report the same PRs as "closed" again.
        if (stateCommitted) {
          this.previousOpenPrs = report.openPrs;
          closedPrs = diff.length > 0 ? diff : undefined;
        }
      } else {
        logger.warn(
          { failedRepos: report.failedRepos },
          "daily_report_skipping_closed_prs_due_to_partial_fetch",
        );
      }

      if (report.totalTickets === 0 && (closedPrs?.length ?? 0) === 0) {
        logger.debug("daily_report_skipped_empty");
        return;
      }

      await this.deps.slackNotifier.notifyDailyReport(
        {
          ticketsByTeam: report.ticketsByTeam,
          closedPrs,
        },
        this.deps.slackDailyReportChannelId,
      );
      logger.info(
        {
          totalTickets: report.totalTickets,
          totalWithPr: report.totalWithPr,
          totalWithoutPr: report.totalWithoutPr,
          teams: report.teams,
          closedPrs: closedPrs?.length ?? 0,
          failedRepos: report.failedRepos.length,
        },
        "daily_report_sent",
      );
    } catch (err) {
      logger.error(
        { error: err instanceof Error ? err.message : String(err) },
        "daily_report_failed",
      );
    } finally {
      this.sendInFlight = false;
    }
  }
}
