import type { Company, UpstreamTarget } from './upstream';
import {
  createInvoiceshelfAccount,
  deleteInvoiceshelfAccount,
  getInvoiceshelfCredentials,
  listInvoiceshelfAccounts,
  recordInvoiceshelfProbe,
  setActiveInvoiceshelfAccount,
  updateInvoiceshelfAccount,
} from 'officerdb';
import { UpstreamError, callUpstream, invalidateConfig, listCompanies, mintToken, normalizeBase } from './upstream';

// `/_config` — the InvoiceShelf account registry, driven from the app rather than from a shell on the server.
//
// The Sanctum token is WRITE-ONLY across this boundary. The list reports each account's label, URL, pinned
// company and whether it is selected; it has no field that could carry a token, masked or otherwise. The
// only way to change one is to send a new one, which is the same shape photos and headscale use.
//
// A save is validated before it is stored, and the validation does something the photos one does not: it
// lists the companies the token can act for and PINS one. The `company` header is the sharpest edge in this
// API — a wrong or missing value silently returns another company's books instead of erroring — so the
// choice is made once, here, and stored, rather than falling back per request.
//
// Two accounts on the same URL with the same token but different companies is therefore an ordinary thing
// to have, and is exactly why the label is what has to be unique.

export type ProbeResult =
  | { ok: true; version: string | null; companies: Company[] }
  | { ok: false; version: string | null; error: string };

/** 401/419, or a 3xx that is Laravel redirecting to its HTML login — all three mean the token was refused. */
const rejected = (status: number): boolean => status === 401 || status === 419 || (status >= 300 && status < 400);

const REJECTED_MESSAGE =
  'the API token was rejected — mint a new one and paste the whole value, including the leading "1|"';

/**
 * Ask an instance whether it is really there and whether the token works.
 *
 * Two calls, because they answer different questions, but NOT the two you would expect: `/app/version` is an
 * UNAUTHENTICATED route on 2.4.2, so it answers 200 to a revoked, truncated or empty token. It proves only
 * that the URL points at an InvoiceShelf. The companies lookup is the first call that exercises the token at
 * all, which is why its 401 has to be reported as a credential problem and not as "companies lookup failed".
 */
export async function probe(cfg: UpstreamTarget): Promise<ProbeResult> {
  let version: string | null = null;

  try {
    const res = await callUpstream(cfg, { path: '/api/v1/app/version', withCompany: false });
    if (!res.ok) {
      return { ok: false, version: null, error: `that URL does not look like an InvoiceShelf (${res.status})` };
    }
    const payload = (await res.json()) as { version?: string };
    version = payload.version ?? null;
  } catch (err) {
    return { ok: false, version: null, error: `could not reach the instance (${String(err)})` };
  }

  try {
    const companies = await listCompanies(cfg);
    if (!companies.length) return { ok: false, version, error: 'no company is visible to this token' };
    return { ok: true, version, companies };
  } catch (err) {
    const status = err instanceof UpstreamError ? err.status : 0;
    if (rejected(status)) return { ok: false, version, error: REJECTED_MESSAGE };
    return { ok: false, version, error: String(err) };
  }
}

/** What the browser is allowed to know about the registry. Never includes a token. */
async function accountList(userId: number): Promise<Response> {
  const accounts = await listInvoiceshelfAccounts(userId);
  const active = accounts.find((account) => account.isActive) ?? null;
  return Response.json({ configured: !!active, activeId: active?.id ?? null, accounts });
}

/** Record that the instance answered, so the UI can tell "never connected" from "was working, now isn't". */
export async function noteProbe(userId: number, id: number, version: string | null): Promise<void> {
  await recordInvoiceshelfProbe(userId, id, version).catch(() => {
    /* a stale lastSeenAt is not worth failing a request over */
  });
}

const bad = (error: string, status = 400) => Response.json({ error }, { status });

type AccountBody = {
  label?: unknown;
  url?: unknown;
  token?: unknown;
  companyId?: unknown;
  email?: unknown;
  password?: unknown;
};

const readBody = async (req: Request): Promise<AccountBody> =>
  ((await req.json().catch(() => null)) as AccountBody | null) ?? {};

const readLabel = (body: AccountBody): string => (typeof body.label === 'string' ? body.label.trim() : '');
const readUrl = (body: AccountBody): string => (typeof body.url === 'string' ? normalizeBase(body.url) : '');
const readToken = (body: AccountBody): string => (typeof body.token === 'string' ? body.token.trim() : '');

const readCompanyId = (body: AccountBody): number | null => {
  const n = Number(body.companyId);
  return Number.isInteger(n) && n > 0 ? n : null;
};

const readEmail = (body: AccountBody): string => (typeof body.email === 'string' ? body.email.trim() : '');
const readPassword = (body: AccountBody): string => (typeof body.password === 'string' ? body.password : '');

const isHttpUrl = (url: string): boolean => /^https?:\/\//i.test(url);

/**
 * The token to store, however the owner chose to supply it.
 *
 * A pasted token wins; otherwise an email and password are traded for a fresh one and the password is
 * discarded with this function's stack frame. Returns '' when neither was given, which for an edit means
 * "keep what is stored" and for an add is an error.
 */
async function resolveToken(body: AccountBody, base: string): Promise<string> {
  const token = readToken(body);
  if (token) return token;

  const email = readEmail(body);
  const password = readPassword(body);
  if (!email || !password) return '';

  return mintToken(base, email, password);
}

/** Sign-in failures, in the words of what the owner actually did. 422 is Laravel's validation refusal. */
function signInError(err: unknown): string {
  if (!(err instanceof UpstreamError)) return `could not reach the instance (${String(err)})`;
  if (err.status === 422 || err.status === 401) return 'that email and password were rejected by InvoiceShelf';
  if (err.status === 429) return 'too many sign-in attempts — wait a moment and try again';
  return err.message;
}

const duplicateLabel = (err: unknown): boolean => String(err).includes('uq_invoiceshelf_accounts_user_label');

/**
 * Pick the company to pin.
 *
 * An explicit choice is honoured only if the token can actually see it — pinning an invisible company is the
 * silent-wrong-books failure this whole mechanism exists to prevent. Otherwise the sole company is taken
 * without asking, and with several the caller must choose: `needsChoice` is the 409 that makes the UI show
 * the picker instead of guessing.
 */
function pickCompany(companies: Company[], requested: number | null): Company | null {
  if (requested != null) return companies.find((c) => c.id === requested) ?? null;
  return companies.length === 1 ? companies[0]! : null;
}

/** Add an account: validated against the live instance, company pinned, then stored encrypted. */
async function addAccount(req: Request, userId: number): Promise<Response> {
  const body = await readBody(req);
  const url = readUrl(body);
  const requested = readCompanyId(body);
  let label = readLabel(body);

  if (!url) return bad('url is required');
  if (!isHttpUrl(url)) return bad('url must start with http:// or https://');

  let token: string;
  try {
    token = await resolveToken(body, url);
  } catch (err) {
    return bad(signInError(err));
  }
  if (!token) return bad('an API token, or an email and password to mint one, is required');

  const result = await probe({ base: url, token, companyId: null });
  if (!result.ok) return Response.json({ error: result.error, version: result.version }, { status: 400 });

  const company = pickCompany(result.companies, requested);
  if (!company) {
    // 409, not 400: nothing is wrong with what was sent, it is just not enough to decide. The UI answers
    // this by showing the returned companies and asking, then posting again with companyId.
    return Response.json(
      {
        error: requested ? 'that company is not visible to this token' : 'choose which company to use',
        needsChoice: true,
        companies: result.companies,
        version: result.version,
      },
      { status: 409 },
    );
  }

  // An unlabelled account takes the company's own name, which is nearly always what the owner would type.
  // Falling back to the host keeps the switcher readable when the company is unnamed.
  if (!label) label = company.name ?? new URL(url).host;

  // The first account wins the selection: a registry with rows but nothing selected reads as "not connected".
  const existing = await listInvoiceshelfAccounts(userId);
  const activate = existing.length === 0;

  try {
    const account = await createInvoiceshelfAccount({
      userId,
      label,
      url,
      token,
      companyId: company.id,
      companyName: company.name,
      version: result.version,
      activate,
    });
    invalidateConfig(userId);
    return Response.json({ account });
  } catch (err) {
    if (duplicateLabel(err)) return bad(`you already have an account called "${label}"`);
    throw err;
  }
}

/** Edit one account. No credential means "keep the stored one", so a rename never needs one re-typed. */
async function editAccount(req: Request, userId: number, id: number): Promise<Response> {
  const body = await readBody(req);
  const label = readLabel(body);
  const url = readUrl(body);
  const requested = readCompanyId(body);

  const current = await getInvoiceshelfCredentials(userId, id);
  if (!current) return bad('no such account', 404);
  if (url && !isHttpUrl(url)) return bad('url must start with http:// or https://');

  let token: string;
  try {
    token = await resolveToken(body, url || current.url);
  } catch (err) {
    return bad(signInError(err));
  }

  // Re-validate whenever what we would talk to changes. A rename on its own never touches the instance.
  let version: string | null | undefined;
  let companyId: number | undefined;
  let companyName: string | null | undefined;

  if ((url && url !== current.url) || token || requested != null) {
    const target = { base: url || current.url, token: token || current.token, companyId: null };
    const result = await probe(target);
    if (!result.ok) return Response.json({ error: result.error, version: result.version }, { status: 400 });
    version = result.version;

    // Keep the pinned company across a token rotation, but only if the new token can still see it.
    const company = pickCompany(result.companies, requested ?? current.companyId);
    if (!company) {
      return Response.json(
        { error: 'choose which company to use', needsChoice: true, companies: result.companies },
        { status: 409 },
      );
    }
    companyId = company.id;
    companyName = company.name;
  }

  try {
    const account = await updateInvoiceshelfAccount(userId, id, {
      label: label || undefined,
      url: url || undefined,
      token: token || undefined,
      companyId,
      companyName,
      version,
    });
    if (!account) return bad('no such account', 404);
    invalidateConfig(userId);
    return Response.json({ account });
  } catch (err) {
    if (duplicateLabel(err)) return bad(`you already have an account called "${label}"`);
    throw err;
  }
}

/** The companies a stored account's token can act for — what the company picker is populated from. */
async function accountCompanies(userId: number, id: number): Promise<Response> {
  const creds = await getInvoiceshelfCredentials(userId, id);
  if (!creds) return bad('no such account', 404);
  try {
    const companies = await listCompanies({ base: normalizeBase(creds.url), token: creds.token, companyId: null });
    return Response.json({ companies, companyId: creds.companyId });
  } catch (err) {
    const status = err instanceof UpstreamError ? err.status : 0;
    return Response.json({ error: rejected(status) ? REJECTED_MESSAGE : String(err) }, { status: 502 });
  }
}

/** `subpath` is '' for /_config, or '/<id>', '/<id>/activate', '/<id>/companies'. */
export async function handleConfigRoute(req: Request, userId: number, subpath: string): Promise<Response> {
  const [, rawId, action] = subpath.split('/');

  if (!rawId) {
    if (req.method === 'GET') return accountList(userId);
    if (req.method === 'POST' || req.method === 'PUT') return addAccount(req, userId);
    return bad('method not allowed', 405);
  }

  const id = Number(rawId);
  if (!Number.isInteger(id) || id <= 0) return bad('invalid account id', 404);

  if (action === 'activate') {
    if (req.method !== 'POST') return bad('method not allowed', 405);
    const account = await setActiveInvoiceshelfAccount(userId, id);
    if (!account) return bad('no such account', 404);
    invalidateConfig(userId);
    return accountList(userId);
  }

  if (action === 'companies') {
    if (req.method !== 'GET') return bad('method not allowed', 405);
    return accountCompanies(userId, id);
  }

  if (action) return bad('not found', 404);

  if (req.method === 'PATCH' || req.method === 'PUT') return editAccount(req, userId, id);

  if (req.method === 'DELETE') {
    const removed = await deleteInvoiceshelfAccount(userId, id);
    if (!removed) return bad('no such account', 404);
    invalidateConfig(userId);
    return accountList(userId);
  }

  return bad('method not allowed', 405);
}
