import type { MiddlewareHandler } from 'hono';
import * as errors from '../custom-errors';
import { IS_DEV_BUILD } from '../build-env';
import { resolveAuthToken } from '../auth-token';
import { isSuperAdmin } from '../super-admin';
import { isApiRequestAllowed } from '../capabilities/authorize';
import { isExemptApiPath } from '../capabilities/totality';

const { PUBLIC_URL } = process.env;

// The allowed production web origin comes from PUBLIC_URL in .env (e.g. https://officer.pastilhas.dev),
// not a hardcoded domain.
const PUBLIC_ORIGIN = (() => {
  try {
    return PUBLIC_URL ? new URL(PUBLIC_URL).origin : undefined;
  } catch {
    return undefined;
  }
})();

const WEB_ORIGINS: string[] = PUBLIC_ORIGIN ? [PUBLIC_ORIGIN] : [];

// Host authorities (`example.com`, or `example.com:8080` off the default port) for the same origins.
// Officer always sits behind an HTTPS reverse proxy, so the proxy's `Host` header is expected to
// match PUBLIC_URL's authority exactly.
const WEB_HOSTS: string[] = WEB_ORIGINS.map((o) => new URL(o).host);

const CHROME_EXTENSIONS: string[] = [
  // 'chrome-extension://<id>'
];

// Every `OFFICER_<APP>_ORIGIN` in the environment is an allowed app origin — each app ships a
// custom-scheme origin with an embedded token (`officer://<hex>`), set on the host, never in the repo.
// Adding an app is adding an env var; no code change, which is the point. The slug is what sits between
// the two fixed words: OFFICER_MUSIC_ORIGIN → MUSIC.
const APP_ORIGIN_VAR = /^OFFICER_([A-Z0-9_]+)_ORIGIN$/;

// Apps that are the whole platform rather than one feature of it, so they get NO path scoping — the main
// Officer app needs every prefix the web app needs.
//
// These used to be `superAdminOnly`, which refused a non-owner outright, here and at signin. That was
// correct while single-user was the invariant and the only non-owner accounts were music-app accounts:
// there was no way to express "this person may use the platform, but only these parts of it", so the
// honest answer was to keep them out of it entirely.
//
// Capabilities express exactly that, per feature, at both doors. So the blunt version is gone — a member
// signs into the web app and sees what their role was granted. Keeping both would mean a member who has
// been granted Gitea still cannot reach the page, which is not a second layer of defence, just a bug.
const PLATFORM_APPS = new Set(['APP']);

// Where an app's API surface isn't `/api/<slug>`. Only exceptions belong here.
const APP_SCOPE_OVERRIDES: Record<string, string[]> = {
  // OffTail signs in and mints a Headscale pre-auth key. The VPN's own traffic goes straight to
  // Headscale and never through /api, so this is its entire platform surface.
  TAIL: ['/api/vpn'],
};

type AppOrigin = { slug: string; origin: string };

const APP_ORIGIN_LIST: AppOrigin[] = Object.entries(process.env).flatMap(([key, value]) => {
  const slug = key.match(APP_ORIGIN_VAR)?.[1];
  return slug && value ? [{ slug, origin: value }] : [];
});

const APP_ORIGINS: string[] = APP_ORIGIN_LIST.map((a) => a.origin);

// The account backstop used to live here as two hand-written lists: NON_OWNER_PATHS, confining every
// non-owner to '/api/auth' + '/api/music', and NON_OWNER_WS_PROVIDERS doing the same for sockets. Both
// are gone, replaced by the capability registry (src/servers/capabilities/).
//
// They were not wrong, they were unscalable in one specific way: a hardcoded allow-list answers "which
// paths" but never "why", so onboarding anyone who needed anything other than music meant editing an
// array in a middleware file and hoping the socket half got edited too. The registry makes the two doors
// read the same declaration, and the boot-time totality check makes a THIRD door impossible to add
// without noticing. See capabilities/totality.ts for the incident that motivated it.

function pathAllowed(path: string, prefixes: string[]): boolean {
  return prefixes.some((prefix) => path === prefix || path.startsWith(`${prefix}/`));
}

// Per-origin access rules, enforced globally by originScopeMiddleware (mounted in hono.ts):
//  - paths: this Origin may reach ONLY these path prefixes; anything else is 403.
// Origins with no rule keep full access. Rules no-op for env values that are unset.
//
// App rules are derived, not written: an app may reach /api/auth (it has to sign in) plus the one
// feature it is named for — OFFICER_VAULT_ORIGIN gets /api/auth + /api/vault.
type OriginRule = { paths?: string[] };
const ORIGIN_RULES: Record<string, OriginRule> = {};

for (const { slug, origin } of APP_ORIGIN_LIST) {
  ORIGIN_RULES[origin] = PLATFORM_APPS.has(slug)
    ? {} // no path scoping; the capability backstop is what limits a platform app's caller
    : { paths: ['/api/auth', ...(APP_SCOPE_OVERRIDES[slug] ?? [`/api/${slug.toLowerCase()}`])] };
}

// Last, so the web origin wins if an app ever declares the same one. No path scoping, for the same reason
// as PLATFORM_APPS above: the web app is the whole platform, and what its caller may reach is decided by
// their capabilities rather than by their Origin.
if (PUBLIC_ORIGIN) ORIGIN_RULES[PUBLIC_ORIGIN] = {};

// ── TEMPORARY: origin checking switched off ──
// ALLOW_ANY_ORIGIN=true accepts every Origin, everywhere, and skips the per-origin path scoping.
// ALLOW_ANY_ORIGIN_MUSIC=true is the narrower version, /api/music only — which is not enough on its own,
// because an app has to reach /api/auth to sign in before it ever calls its own feature.
//
// What still holds with these on: every protected route requires a valid token (userMiddleware), and the
// capability backstop below still confines a non-owner account to what its ROLE has been granted,
// whatever Origin it claims — that one is deliberately NOT disabled, since it is account-based, not
// origin-based.
//
// What is lost: defence in depth, not the lock. Origin was never authentication here — `officer://<hex>`
// is chosen by the client, forgeable outside a browser, and extractable from a shipped app binary.
// Both flags and their call sites come out once the tailnet is the perimeter.
// Defaults to ON — origin checking is off unless ALLOW_ANY_ORIGIN is explicitly 'false'. That is a
// deliberate inversion of the usual fail-closed rule, and it is safe only because of where this runs: the
// perimeter is the tailnet, devices are admitted by hand, and a valid token is still required on every
// protected route. Set ALLOW_ANY_ORIGIN=false to put the checks back.
const ALLOW_ANY_ORIGIN = (process.env.ALLOW_ANY_ORIGIN ?? 'true') !== 'false';
const ALLOW_ANY_ORIGIN_MUSIC = process.env.ALLOW_ANY_ORIGIN_MUSIC === 'true';

export function isOriginCheckDisabled(path?: string): boolean {
  if (ALLOW_ANY_ORIGIN) return true;
  return ALLOW_ANY_ORIGIN_MUSIC && !!path && pathAllowed(path, ['/api/music']);
}

/** @deprecated use isOriginCheckDisabled — kept so existing call sites read the same. */
export const isMusicOriginExempt = isOriginCheckDisabled;

export function isOriginAllowed(origin: string | undefined, host?: string): boolean {
  if (IS_DEV_BUILD) return true;
  if (ALLOW_ANY_ORIGIN) return true;

  if (origin) {
    if (origin.startsWith('chrome-extension://')) {
      return CHROME_EXTENSIONS.includes(origin);
    }

    if (APP_ORIGINS.includes(origin)) {
      return true;
    }

    return WEB_ORIGINS.includes(origin);
  }

  if (host) {
    return WEB_HOSTS.includes(host);
  }

  return false;
}

export const originValidationMiddleware: MiddlewareHandler = function (ctx, next) {
  const origin = ctx.get('origin') as string | undefined;
  const host = ctx.req.header('host');
  if (!isOriginAllowed(origin, host)) {
    throw errors.FORBIDDEN('Invalid origin');
  }
  return next();
};

function resolveOrigin(headerOrigin: string | undefined, referer: string | undefined): string | undefined {
  if (headerOrigin) return headerOrigin;
  if (referer) {
    try {
      return new URL(referer).origin;
    } catch {
      return undefined;
    }
  }
  return undefined;
}

// Global gate applied to every request (mounted in hono.ts). Reads the Origin header directly (not
// ctx 'origin') so it covers the whole /api tree — the public /api/auth and the protected /api/music
// alike — regardless of which routers mount originMiddleware. Two layers:
//   1. Capability backstop (origin-INDEPENDENT): a valid NON-owner token may reach only what its ROLE
//      has been granted, no matter the origin. This is the airtight rule — it holds even if a client
//      omits or forges the Origin header. It replaced a hardcoded "/api/auth + /api/music" list on
//      2026-08-07; that list was why a Member could not reach /api/gitea and no UI could change it.
//   2. Per-origin rules (ORIGIN_RULES): path scoping for the single-feature apps. Redundant with the
//      backstop for the account dimension, but blocks unknown-path access from an app origin.
// A missing/invalid token passes both layers (signin needs it; userMiddleware rejects bad tokens on
// protected routes). Only a VALID non-owner token is constrained.
export const originScopeMiddleware: MiddlewareHandler = async function (ctx, next) {
  const path = ctx.req.path;
  const origin = resolveOrigin(ctx.req.header('origin'), ctx.req.header('referer'));

  // Resolve the caller once (if any); a missing/invalid/revoked credential stays null. This goes through
  // the shared resolver rather than verifying a JWT here, so an API key is the same caller at this door as
  // it is at userMiddleware — the two must never disagree about who someone is. See auth-token.ts.
  const authorization = ctx.req.header('authorization');
  const token = authorization ? authorization.split(' ')[1] : ctx.req.query('token') || undefined;
  const payload = token ? await resolveAuthToken(token) : null;
  const isOwner = payload ? await isSuperAdmin(payload) : false;

  // 1. Capability backstop — origin-INDEPENDENT, and the airtight half of this middleware. A valid
  // non-owner token may reach only what its ROLE has been granted, whatever Origin it claims and whether
  // or not it sends one. Exempt paths (signin, the public pages, the Bitwarden door) are skipped because
  // they are served above the account gate — the same list the boot check uses, deliberately.
  //
  // Non-/api paths are not ours: the DAV sync door authenticates with its own app password and carries no
  // platform account, so there is nothing here to resolve.
  if (payload && !isOwner && path.startsWith('/api') && !isExemptApiPath(path)) {
    const { allowed, reason } = await isApiRequestAllowed(payload.id, ctx.req.method, path);
    if (!allowed) throw errors.FORBIDDEN(reason ?? 'Not permitted for this account');
  }

  // 2. Per-origin rules. Skipped entirely while origin checking is off (the account backstop above is
  // account-based, not origin-based, so it deliberately still applies).
  if (isOriginCheckDisabled(path)) return next();

  const rule = origin ? ORIGIN_RULES[origin] : undefined;
  if (rule) {
    if (rule.paths && !pathAllowed(path, rule.paths)) {
      throw errors.FORBIDDEN('Origin not permitted for this resource');
    }
  }

  return next();
};
