/**
 * GET /api/v3/protocol/solvency
 *
 * Produces per-contract evidence for Invariant #1 (assets >= liabilities).
 * This module deliberately has no viem or Next.js runtime dependency: callers
 * inject a reader and explicit liability read plans, which keeps the core
 * deterministic in tests and prevents the V2 registry from being assigned
 * guessed liabilities.
 */

import deploymentRegistry from "../../../baseline/deployments.json";

export const SOLVENCY_SCHEMA_VERSION = "1.0.0";
export const SOLVENCY_PROTOCOL_VERSION = "v3";

export type ProtocolVersion = "v2" | "v3";
export type ContractStatus = "active" | "legacy" | "deprecated" | "paused" | "read-only";
export type Finality = "soft" | "safe" | "finalized";

export interface SolvencyDeployment {
  chainId: number;
  address: string;
  name: string;
  version: ProtocolVersion;
  status: ContractStatus;
  /**
   * Retained for read-plan selection. It is not used to infer a liability:
   * a plan must still explicitly support the deployment.
   */
  custodiesFunds?: boolean;
  category?: string;
}

export interface SolvencyBlock {
  number: bigint;
  hash: string;
}

export interface BlockReadRequest {
  chainId: number;
  finality: Finality;
}

export interface BalanceReadRequest {
  chainId: number;
  address: string;
  block: SolvencyBlock;
}

/**
 * The minimum chain interface required by the core reader. A production
 * adapter may add contract-call support, while tests can use only the methods
 * their selected liability plan needs.
 */
export interface SolvencyReadClient {
  getBlock(request: BlockReadRequest): Promise<SolvencyBlock>;
  getBalance(request: BalanceReadRequest): Promise<bigint>;
}

export interface ContractViewReadRequest {
  chainId: number;
  address: string;
  data: `0x${string}`;
  block: SolvencyBlock;
}

export interface ContractViewReadClient extends SolvencyReadClient {
  readContract(request: ContractViewReadRequest): Promise<`0x${string}`>;
}

export interface LiabilityReadPlan {
  /** Stable operator-visible identifier for the audited liability adapter. */
  id: string;
  /**
   * Plans must opt in to a specific deployment or contract family. A generic
   * fallback that returns zero is intentionally not supported.
   */
  supports(deployment: SolvencyDeployment): boolean;
  /**
   * Return the complete, non-negative liability total at the supplied block.
   * The plan owns ABI details and aggregation semantics.
   */
  readLiabilities(args: {
    deployment: SolvencyDeployment;
    block: SolvencyBlock;
    client: SolvencyReadClient;
  }): Promise<bigint>;
}

export interface SolvencyRow {
  chainId: number;
  /** Explicit envelope field; `address` remains for existing row consumers. */
  contractAddress: string;
  address: string;
  contract: string;
  protocolVersion: ProtocolVersion;
  contractStatus: ContractStatus;
  status: "ok" | "degraded";
  blockNumber: string | null;
  blockHash: string | null;
  observedAt: string;
  finality: Finality;
  assetsWei: string | null;
  liabilitiesWei: string | null;
  deltaWei: string | null;
  liabilityReadPlan: string | null;
  dataSourceErrors: string[];
  schemaVersion: typeof SOLVENCY_SCHEMA_VERSION;
}

export interface SolvencyReport {
  /** Aggregate report fields are null; each row carries concrete contract context. */
  chainId: null;
  contractAddress: null;
  protocolVersion: typeof SOLVENCY_PROTOCOL_VERSION;
  contractStatus: "unknown";
  schemaVersion: typeof SOLVENCY_SCHEMA_VERSION;
  blockNumber: null;
  blockHash: null;
  generatedAt: string;
  observedAt: string;
  finality: Finality;
  /** "ok" only means every required block, asset, and liability read succeeded. */
  status: "ok" | "degraded";
  dataSourceErrors: string[];
  rows: SolvencyRow[];
}

export interface SolvencyDependencies {
  deployments: readonly SolvencyDeployment[];
  client: SolvencyReadClient;
  liabilityReadPlans: readonly LiabilityReadPlan[];
  finality?: Finality;
  now?: () => Date;
}

type ReadOutcome<T> =
  | { ok: true; value: T }
  | { ok: false; error: string };

function describeError(error: unknown): string {
  if (error instanceof Error && error.message) return error.message;
  if (typeof error === "string" && error) return error;
  return "unknown error";
}

function asNonNegativeBigInt(value: bigint, field: string): bigint {
  if (typeof value !== "bigint") {
    throw new TypeError(`${field} reader returned a non-bigint value`);
  }
  if (value < 0n) {
    throw new RangeError(`${field} reader returned a negative value`);
  }
  return value;
}

function asBlock(block: SolvencyBlock): SolvencyBlock {
  if (typeof block?.number !== "bigint" || block.number < 0n) {
    throw new TypeError("block reader returned an invalid block number");
  }
  if (typeof block.hash !== "string" || block.hash.length === 0) {
    throw new TypeError("block reader returned an invalid block hash");
  }
  return block;
}

async function readRequired<T>(read: () => Promise<T>, label: string): Promise<ReadOutcome<T>> {
  try {
    return { ok: true, value: await read() };
  } catch (error) {
    return { ok: false, error: `${label}: ${describeError(error)}` };
  }
}

function selectReadPlan(
  deployment: SolvencyDeployment,
  readPlans: readonly LiabilityReadPlan[],
): ReadOutcome<LiabilityReadPlan> {
  try {
    const plan = readPlans.find((candidate) => candidate.supports(deployment));
    if (!plan) {
      return { ok: false, error: "liability-read-plan-unconfigured" };
    }
    return { ok: true, value: plan };
  } catch (error) {
    return { ok: false, error: `liability-read-plan-selection-failed: ${describeError(error)}` };
  }
}

/**
 * Reads one deployment at one finalized/safe/soft block. There are no fallback
 * values: a failed required read is represented by null and a row error.
 */
export async function readSolvencyRow(
  deployment: SolvencyDeployment,
  dependencies: Pick<SolvencyDependencies, "client" | "liabilityReadPlans">,
  options: { finality: Finality; observedAt: string },
): Promise<SolvencyRow> {
  const row: SolvencyRow = {
    chainId: deployment.chainId,
    contractAddress: deployment.address,
    address: deployment.address,
    contract: deployment.name,
    protocolVersion: deployment.version,
    contractStatus: deployment.status,
    status: "degraded",
    blockNumber: null,
    blockHash: null,
    observedAt: options.observedAt,
    finality: options.finality,
    assetsWei: null,
    liabilitiesWei: null,
    deltaWei: null,
    liabilityReadPlan: null,
    dataSourceErrors: [],
    schemaVersion: SOLVENCY_SCHEMA_VERSION,
  };

  const blockResult = await readRequired(
    async () => asBlock(await dependencies.client.getBlock({
      chainId: deployment.chainId,
      finality: options.finality,
    })),
    "block-read-failed",
  );
  if (!blockResult.ok) {
    row.dataSourceErrors = [blockResult.error];
    return row;
  }

  const block = blockResult.value;
  row.blockNumber = block.number.toString(10);
  row.blockHash = block.hash;

  const planResult = selectReadPlan(deployment, dependencies.liabilityReadPlans);
  if (planResult.ok) row.liabilityReadPlan = planResult.value.id;

  const [assetResult, liabilityResult] = await Promise.all([
    readRequired(
      async () => asNonNegativeBigInt(
        await dependencies.client.getBalance({
          chainId: deployment.chainId,
          address: deployment.address,
          block,
        }),
        "asset balance",
      ),
      "asset-balance-read-failed",
    ),
    planResult.ok
      ? readRequired(
        async () => asNonNegativeBigInt(
          await planResult.value.readLiabilities({
            deployment,
            block,
            client: dependencies.client,
          }),
          "liability",
        ),
        "liability-view-read-failed",
      )
      : Promise.resolve(planResult),
  ]);

  // Keep error order deterministic even when the reads complete concurrently.
  if (!assetResult.ok) row.dataSourceErrors.push(assetResult.error);
  if (!liabilityResult.ok) row.dataSourceErrors.push(liabilityResult.error);

  if (assetResult.ok) row.assetsWei = assetResult.value.toString(10);
  if (liabilityResult.ok) row.liabilitiesWei = liabilityResult.value.toString(10);
  if (assetResult.ok && liabilityResult.ok) {
    row.deltaWei = (assetResult.value - liabilityResult.value).toString(10);
  }
  row.status = row.dataSourceErrors.length === 0 ? "ok" : "degraded";

  return row;
}

/**
 * Framework-neutral report builder. The report is HTTP-200 eligible only when
 * every row has all required data source reads.
 */
export async function readSolvencyReport(dependencies: SolvencyDependencies): Promise<SolvencyReport> {
  const finality = dependencies.finality ?? "safe";
  const generatedAt = (dependencies.now ?? (() => new Date()))().toISOString();
  const rows = await Promise.all(
    dependencies.deployments.map((deployment) =>
      readSolvencyRow(deployment, dependencies, { finality, observedAt: generatedAt }),
    ),
  );
  const status = rows.some((row) => row.dataSourceErrors.length > 0) ? "degraded" : "ok";
  const dataSourceErrors = rows.flatMap((row) =>
    row.dataSourceErrors.map((error) => `${row.chainId}:${row.contractAddress}: ${error}`),
  );

  return {
    chainId: null,
    contractAddress: null,
    protocolVersion: SOLVENCY_PROTOCOL_VERSION,
    contractStatus: "unknown",
    schemaVersion: SOLVENCY_SCHEMA_VERSION,
    blockNumber: null,
    blockHash: null,
    generatedAt,
    observedAt: generatedAt,
    finality,
    status,
    dataSourceErrors,
    rows,
  };
}

export function solvencyHttpStatus(report: SolvencyReport): 200 | 503 {
  return report.status === "ok" ? 200 : 503;
}

export async function createSolvencyResponse(dependencies: SolvencyDependencies): Promise<Response> {
  const report = await readSolvencyReport(dependencies);
  return Response.json(report, { status: solvencyHttpStatus(report) });
}

type JsonRpcResponse = {
  result?: unknown;
  error?: { code?: number; message?: string };
};

export type FetchLike = (input: string, init?: RequestInit) => Promise<Response>;

function parseRpcQuantity(value: unknown, label: string): bigint {
  if (typeof value !== "string" || !/^0x[0-9a-fA-F]+$/.test(value)) {
    throw new TypeError(`${label} returned an invalid hex quantity`);
  }
  return BigInt(value);
}

function blockTag(block: SolvencyBlock): `0x${string}` {
  return `0x${block.number.toString(16)}`;
}

/**
 * Lightweight default RPC implementation. It deliberately does not include a
 * liability plan; each audited contract family must provide its own explicit
 * plan before this endpoint can report a successful solvency row.
 */
export class JsonRpcSolvencyClient implements ContractViewReadClient {
  constructor(
    private readonly rpcUrls: Readonly<Record<number, string>>,
    private readonly fetchImpl: FetchLike = globalThis.fetch as FetchLike,
  ) {}

  private async rpc(chainId: number, method: string, params: unknown[]): Promise<unknown> {
    const url = this.rpcUrls[chainId];
    if (!url) throw new Error(`no RPC URL configured for chain ${chainId}`);

    const response = await this.fetchImpl(url, {
      method: "POST",
      headers: { "content-type": "application/json" },
      body: JSON.stringify({ jsonrpc: "2.0", id: 1, method, params }),
    });
    const payload = await response.json() as JsonRpcResponse;
    if (!response.ok) throw new Error(`RPC HTTP ${response.status}`);
    if (payload.error) {
      throw new Error(`RPC ${payload.error.code ?? "error"}: ${payload.error.message ?? "unknown error"}`);
    }
    if (!("result" in payload)) throw new Error("RPC response omitted result");
    return payload.result;
  }

  async getBlock(request: BlockReadRequest): Promise<SolvencyBlock> {
    const tag = request.finality === "soft"
      ? "latest"
      : request.finality;
    const result = await this.rpc(request.chainId, "eth_getBlockByNumber", [tag, false]);
    if (!result || typeof result !== "object") throw new TypeError("eth_getBlockByNumber returned no block");
    const block = result as { number?: unknown; hash?: unknown };
    const number = parseRpcQuantity(block.number, "eth_getBlockByNumber.number");
    if (typeof block.hash !== "string" || !/^0x[0-9a-fA-F]+$/.test(block.hash)) {
      throw new TypeError("eth_getBlockByNumber.hash returned an invalid hash");
    }
    return { number, hash: block.hash };
  }

  async getBalance(request: BalanceReadRequest): Promise<bigint> {
    return parseRpcQuantity(
      await this.rpc(request.chainId, "eth_getBalance", [request.address, blockTag(request.block)]),
      "eth_getBalance",
    );
  }

  async readContract(request: ContractViewReadRequest): Promise<`0x${string}`> {
    const result = await this.rpc(request.chainId, "eth_call", [
      { to: request.address, data: request.data },
      blockTag(request.block),
    ]);
    if (typeof result !== "string" || !/^0x[0-9a-fA-F]*$/.test(result)) {
      throw new TypeError("eth_call returned invalid hex data");
    }
    return result as `0x${string}`;
  }
}

function registryDeployments(): SolvencyDeployment[] {
  return deploymentRegistry.contracts.map((contract) => ({
    chainId: contract.chainId,
    address: contract.address,
    name: contract.name,
    version: contract.version as ProtocolVersion,
    status: contract.status as ContractStatus,
    custodiesFunds: contract.custodiesFunds,
    category: contract.category,
  }));
}

function registryRpcUrls(): Record<number, string> {
  return Object.values(deploymentRegistry.chainMeta).reduce<Record<number, string>>((urls, chain) => {
    urls[chain.chainId] = chain.rpc;
    return urls;
  }, {});
}

/**
 * The shipped V2 deployment registry has no verified common liability view.
 * Returning an empty plan list makes that gap visible as HTTP 503 rather than
 * silently treating liabilities as zero. Deployments should inject audited
 * plans through createSolvencyResponse/readSolvencyReport.
 */
export function createDefaultSolvencyDependencies(): SolvencyDependencies {
  return {
    deployments: registryDeployments(),
    client: new JsonRpcSolvencyClient(registryRpcUrls()),
    liabilityReadPlans: [],
    finality: "safe",
  };
}

/**
 * Next.js route handlers accept standard Request/Response values, so avoiding
 * a direct next/server import keeps this entry usable in unit tests too.
 */
export async function GET(_request: Request): Promise<Response> {
  return createSolvencyResponse(createDefaultSolvencyDependencies());
}
