/**
 * Org / project usage rollups from the UsageFact ledger (ADR-0021, ADR-0026).
 * Operational usage accounting, not payment collection. Organizations may use
 * either their own provider credentials or a distinct platform-managed
 * connection; commercial billing remains outside the pilot scope.
 *
 * The truth is in `UsageFact`; `AgentRun.costUsd / inputTokens / outputTokens`
 * are a derived rollup cache. This service reads `UsageFact` directly so that
 * external-capability consumption (PDF→MD, ASR, …) is attributed correctly,
 * not just the main model loop. A run with no cost-bearing fact is
 * `runsWithoutCost` — ADR-0022: missing cost ≠ zero.
 */
import type { Prisma, PrismaClient } from "@prisma/client";

export interface ProjectUsageRow {
  readonly projectId: string;
  readonly projectName: string;
  readonly folderId: string | null;
  readonly runCount: number;
  readonly runsWithCost: number;
  readonly runsWithoutCost: number;
  readonly inputTokens: number;
  readonly outputTokens: number;
  readonly costUsd: number | null;
}

export interface UsageReport {
  readonly from: string | null;
  readonly to: string | null;
  readonly projects: readonly ProjectUsageRow[];
  readonly totals: {
    readonly runCount: number;
    readonly runsWithCost: number;
    readonly runsWithoutCost: number;
    readonly inputTokens: number;
    readonly outputTokens: number;
    readonly costUsd: number | null;
  };
}

type FactRow = {
  readonly inputTokens: number | null;
  readonly outputTokens: number | null;
  readonly costUsd: unknown;
};

type RunWithFacts = {
  readonly projectId: string;
  readonly usageFacts: readonly FactRow[];
};

/** A run is "recorded with cost" if any of its facts carries a known costUsd. */
function runHasRecordedCost(facts: readonly FactRow[]): boolean {
  return facts.some((f) => f.costUsd !== null && f.costUsd !== undefined);
}

/** Decimal→number for Prisma Decimal; null/undefined → null. */
function factCostUsdToNumber(value: unknown): number | null {
  if (value === null || value === undefined) return null;
  const n = typeof value === "number" ? value : Number(value);
  return Number.isFinite(n) ? n : null;
}

interface RunRollup {
  readonly hasCost: boolean;
  readonly inputTokens: number;
  readonly outputTokens: number;
  readonly costUsd: number | null;
}

function rollupRun(facts: readonly FactRow[]): RunRollup {
  let inputTokens = 0;
  let outputTokens = 0;
  let costUsd: number | null = null;
  let hasCost = false;
  for (const f of facts) {
    inputTokens += f.inputTokens ?? 0;
    outputTokens += f.outputTokens ?? 0;
    const c = factCostUsdToNumber(f.costUsd);
    if (c !== null) {
      hasCost = true;
      costUsd = (costUsd ?? 0) + c;
    }
  }
  return { hasCost, inputTokens, outputTokens, costUsd };
}

export async function getOrgUsage(
  prisma: PrismaClient,
  input: {
    readonly organizationId: string;
    readonly from?: Date | undefined;
    readonly to?: Date | undefined;
    readonly folderId?: string | undefined;
  },
): Promise<UsageReport> {
  const projectWhere: Prisma.ProjectWhereInput = {
    organizationId: input.organizationId,
    ...(input.folderId !== undefined ? { folderId: input.folderId } : {}),
  };
  const projects = await prisma.project.findMany({
    where: projectWhere,
    select: { id: true, name: true, folderId: true },
    orderBy: { name: "asc" },
  });
  if (projects.length === 0) {
    return emptyReport(input.from, input.to);
  }

  const projectIds = projects.map((p) => p.id);
  const runWhere: Prisma.AgentRunWhereInput = {
    projectId: { in: projectIds },
    ...(input.from !== undefined || input.to !== undefined
      ? {
          startedAt: {
            ...(input.from !== undefined ? { gte: input.from } : {}),
            ...(input.to !== undefined ? { lte: input.to } : {}),
          },
        }
      : {}),
  };

  // Date range filters by run.startedAt, matching the pre-ADR-0026 semantics:
  // a run is in or out of the window based on when it started, and all of its
  // facts come along. Filtering facts by occurredAt independently would let a
  // run contribute partial cost to a window it doesn't belong to.
  const runs = await prisma.agentRun.findMany({
    where: runWhere,
    select: {
      projectId: true,
      usageFacts: {
        select: { inputTokens: true, outputTokens: true, costUsd: true },
        orderBy: { occurredAt: "asc" },
      },
    },
  });

  type MutableRow = {
    readonly projectId: string;
    readonly projectName: string;
    readonly folderId: string | null;
    runCount: number;
    runsWithCost: number;
    runsWithoutCost: number;
    inputTokens: number;
    outputTokens: number;
    costUsd: number | null;
  };

  const byProject = new Map<string, MutableRow>();
  for (const p of projects) {
    byProject.set(p.id, {
      projectId: p.id,
      projectName: p.name,
      folderId: p.folderId,
      runCount: 0,
      runsWithCost: 0,
      runsWithoutCost: 0,
      inputTokens: 0,
      outputTokens: 0,
      costUsd: null,
    });
  }

  for (const run of runs as readonly RunWithFacts[]) {
    const row = byProject.get(run.projectId);
    if (row === undefined) continue;
    const roll = rollupRun(run.usageFacts);
    row.runCount += 1;
    if (roll.hasCost) {
      row.runsWithCost += 1;
      row.costUsd = (row.costUsd ?? 0) + (roll.costUsd ?? 0);
    } else {
      row.runsWithoutCost += 1;
    }
    row.inputTokens += roll.inputTokens;
    row.outputTokens += roll.outputTokens;
  }

  const projectsOut: ProjectUsageRow[] = [...byProject.values()].map((r) => ({
    projectId: r.projectId,
    projectName: r.projectName,
    folderId: r.folderId,
    runCount: r.runCount,
    runsWithCost: r.runsWithCost,
    runsWithoutCost: r.runsWithoutCost,
    inputTokens: r.inputTokens,
    outputTokens: r.outputTokens,
    costUsd: r.costUsd,
  }));

  let runCount = 0;
  let runsWithCost = 0;
  let runsWithoutCost = 0;
  let inputTokens = 0;
  let outputTokens = 0;
  let costUsd: number | null = null;
  for (const row of projectsOut) {
    runCount += row.runCount;
    runsWithCost += row.runsWithCost;
    runsWithoutCost += row.runsWithoutCost;
    inputTokens += row.inputTokens;
    outputTokens += row.outputTokens;
    if (row.costUsd !== null) {
      costUsd = (costUsd ?? 0) + row.costUsd;
    }
  }

  return {
    from: input.from?.toISOString() ?? null,
    to: input.to?.toISOString() ?? null,
    projects: projectsOut,
    totals: {
      runCount,
      runsWithCost,
      runsWithoutCost,
      inputTokens,
      outputTokens,
      costUsd,
    },
  };
}

export async function getProjectUsage(
  prisma: PrismaClient,
  input: {
    readonly organizationId: string;
    readonly projectId: string;
    readonly from?: Date | undefined;
    readonly to?: Date | undefined;
  },
): Promise<ProjectUsageRow> {
  const project = await prisma.project.findFirst({
    where: { id: input.projectId, organizationId: input.organizationId },
    select: { id: true, name: true, folderId: true },
  });
  if (project === null) {
    throw new Error(`project not found: ${input.projectId}`);
  }
  const report = await getOrgUsage(prisma, {
    organizationId: input.organizationId,
    from: input.from,
    to: input.to,
  });
  const row = report.projects.find((p) => p.projectId === project.id);
  return (
    row ?? {
      projectId: project.id,
      projectName: project.name,
      folderId: project.folderId,
      runCount: 0,
      runsWithCost: 0,
      runsWithoutCost: 0,
      inputTokens: 0,
      outputTokens: 0,
      costUsd: null,
    }
  );
}

function emptyReport(from?: Date, to?: Date): UsageReport {
  return {
    from: from?.toISOString() ?? null,
    to: to?.toISOString() ?? null,
    projects: [],
    totals: {
      runCount: 0,
      runsWithCost: 0,
      runsWithoutCost: 0,
      inputTokens: 0,
      outputTokens: 0,
      costUsd: null,
    },
  };
}
